Files
g1flux/src/hooks/useAnimation.ts
T
syoul 97ff22027c
ci/woodpecker/push/woodpecker Pipeline was successful
feat: vue flux — arcs dirigés entre villes géolocalisées
- 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>
2026-03-24 00:21:03 +01:00

132 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ≈ 910 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,
};
}