97ff22027c
ci/woodpecker/push/woodpecker Pipeline was successful
- Nouveau type TransactionArc + buildCorridors + computeFlowStats - FlowMap : SVG overlay Leaflet, arcs bezier, flèches de direction, nœuds de villes cliquables - Clic sur une ville : arcs sortants orange, entrants teal, reste grisé - DataService : résolution géo des destinataires (toId) dans le même appel Cesium+ - useAnimation : expose visibleArcs filtré par frame - PeriodSelector : bouton toggle Heatmap / Flux - StatsPanel : stats flux (volume, top émetteurs, top récepteurs, balance nette) - App : state viewMode + focusCity, FlowMap conditionnel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
132 lines
4.3 KiB
TypeScript
132 lines
4.3 KiB
TypeScript
import { useState, useMemo, useEffect } from 'react';
|
||
import type { Transaction } from '../data/mockData';
|
||
import type { TransactionArc } from '../data/arcData';
|
||
|
||
export interface TimeFrame {
|
||
label: string;
|
||
from: number; // Unix ms
|
||
to: number; // Unix ms
|
||
}
|
||
|
||
function buildFrames(periodDays: number): TimeFrame[] {
|
||
const now = Date.now();
|
||
const start = now - periodDays * 24 * 60 * 60 * 1000;
|
||
|
||
const fmt = (ms: number, opts: Intl.DateTimeFormatOptions) =>
|
||
new Date(ms).toLocaleDateString('fr-FR', opts);
|
||
|
||
if (periodDays === 1) {
|
||
return Array.from({ length: 24 }, (_, i) => {
|
||
const from = start + i * 3_600_000;
|
||
const to = from + 3_600_000;
|
||
const h = new Date(from).getHours();
|
||
return {
|
||
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} · ${h}h – ${h + 1}h`,
|
||
from,
|
||
to,
|
||
};
|
||
});
|
||
}
|
||
|
||
if (periodDays === 7) {
|
||
return Array.from({ length: 7 }, (_, i) => {
|
||
const from = start + i * 86_400_000;
|
||
const to = from + 86_400_000;
|
||
return {
|
||
label: fmt(from, { weekday: 'long', day: 'numeric', month: 'short' }),
|
||
from,
|
||
to,
|
||
};
|
||
});
|
||
}
|
||
|
||
// 30 days → half-week frames (3.5 days ≈ 9–10 frames)
|
||
const HALF_WEEK = 3.5 * 86_400_000;
|
||
const frames: TimeFrame[] = [];
|
||
let cursor = start;
|
||
while (cursor < now) {
|
||
const from = cursor;
|
||
const to = Math.min(cursor + HALF_WEEK, now);
|
||
frames.push({
|
||
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} – ${fmt(to - 1, { weekday: 'short', day: 'numeric', month: 'short' })}`,
|
||
from,
|
||
to,
|
||
});
|
||
cursor = to;
|
||
}
|
||
return frames;
|
||
}
|
||
|
||
export function useAnimation(transactions: Transaction[], arcs: TransactionArc[], periodDays: number, allTimestamps: number[] = []) {
|
||
const [active, setActive] = useState(false);
|
||
const [currentIndex, setCurrentIndex] = useState(0);
|
||
const [playing, setPlaying] = useState(false);
|
||
const [speed, setSpeed] = useState<1 | 2 | 4>(2);
|
||
|
||
const frames = useMemo(() => buildFrames(periodDays), [periodDays]);
|
||
|
||
// Reset cursor when period or activation changes.
|
||
// Stop playback only on deactivation — not on activation, so activate() can
|
||
// start playing immediately without being overridden by this effect.
|
||
useEffect(() => {
|
||
setCurrentIndex(0);
|
||
if (!active) setPlaying(false);
|
||
}, [periodDays, active]);
|
||
|
||
// Auto-advance: one step every (2000 / speed) ms
|
||
useEffect(() => {
|
||
if (!playing || !active) return;
|
||
const delay = 1500 / speed; // ×1=1500ms, ×2=750ms, ×4=375ms
|
||
const t = setTimeout(() => {
|
||
setCurrentIndex((i) => {
|
||
if (i >= frames.length - 1) {
|
||
setPlaying(false);
|
||
return i;
|
||
}
|
||
return i + 1;
|
||
});
|
||
}, delay);
|
||
return () => clearTimeout(t);
|
||
}, [playing, active, currentIndex, speed, frames.length]);
|
||
|
||
const visibleTransactions = useMemo(() => {
|
||
if (!active || frames.length === 0) return transactions;
|
||
const frame = frames[currentIndex];
|
||
if (!frame) return transactions;
|
||
return transactions.filter((t) => t.timestamp >= frame.from && t.timestamp < frame.to);
|
||
}, [active, transactions, frames, currentIndex]);
|
||
|
||
const visibleArcs = useMemo(() => {
|
||
if (!active || frames.length === 0) return arcs;
|
||
const frame = frames[currentIndex];
|
||
if (!frame) return arcs;
|
||
return arcs.filter((a) => a.timestamp >= frame.from && a.timestamp < frame.to);
|
||
}, [active, arcs, frames, currentIndex]);
|
||
|
||
// Nombre total de transfers (géo + non-géo) dans la frame courante
|
||
const frameTotalCount = useMemo(() => {
|
||
if (!active || frames.length === 0 || allTimestamps.length === 0) return null;
|
||
const frame = frames[currentIndex];
|
||
if (!frame) return null;
|
||
return allTimestamps.filter((ts) => ts >= frame.from && ts < frame.to).length;
|
||
}, [active, allTimestamps, frames, currentIndex]);
|
||
|
||
return {
|
||
active,
|
||
activate: () => { setActive(true); setSpeed(1); setPlaying(true); },
|
||
deactivate: () => { setActive(false); },
|
||
playing,
|
||
play: () => setPlaying(true),
|
||
pause: () => setPlaying(false),
|
||
currentIndex,
|
||
seek: (i: number) => { setCurrentIndex(i); setPlaying(false); },
|
||
speed,
|
||
setSpeed,
|
||
frames,
|
||
currentFrame: frames[currentIndex] ?? null,
|
||
visibleTransactions,
|
||
visibleArcs,
|
||
frameTotalCount,
|
||
};
|
||
}
|