feat: raccourcis clavier, URL partageable, sparkline, recherche identité
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Raccourcis clavier : ←/→ (frames), Espace (play/pause), Échap (quitter animation/fermer info), H (basculer heatmap↔flux) - URL partageable : ?period=7&view=flow&city=Paris — état restauré au chargement et mis à jour sans rechargement (history.replaceState) - Sparkline : mini bar-chart SVG dans le StatsPanel montrant l'activité sur la période (données déjà en mémoire, aucune requête) - Recherche identité : champ flottant (⌕) acceptant un nom Ğ1 ou une clé g1…, résout via Subsquid + Cesium+, bascule en vue flux et met la ville en focus Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+44
-3
@@ -4,6 +4,7 @@ import { PeriodSelector } from './components/PeriodSelector';
|
|||||||
import { HeatMap } from './components/HeatMap';
|
import { HeatMap } from './components/HeatMap';
|
||||||
import { FlowMap } from './components/FlowMap';
|
import { FlowMap } from './components/FlowMap';
|
||||||
import { AnimationPlayer } from './components/AnimationPlayer';
|
import { AnimationPlayer } from './components/AnimationPlayer';
|
||||||
|
import { SearchBar } from './components/SearchBar';
|
||||||
import { fetchData } from './services/DataService';
|
import { fetchData } from './services/DataService';
|
||||||
import type { PeriodStats } from './services/DataService';
|
import type { PeriodStats } from './services/DataService';
|
||||||
import type { Transaction } from './data/mockData';
|
import type { Transaction } from './data/mockData';
|
||||||
@@ -13,9 +14,10 @@ import { computeFlowStats } from './data/arcData';
|
|||||||
import { useAnimation } from './hooks/useAnimation';
|
import { useAnimation } from './hooks/useAnimation';
|
||||||
import { useMediaQuery } from './hooks/useMediaQuery';
|
import { useMediaQuery } from './hooks/useMediaQuery';
|
||||||
import { InfoPanel } from './components/InfoPanel';
|
import { InfoPanel } from './components/InfoPanel';
|
||||||
|
import { initialUrlState, useUrlSync } from './hooks/useUrlState';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [periodDays, setPeriodDays] = useState(7);
|
const [periodDays, setPeriodDays] = useState(initialUrlState.period);
|
||||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||||
const [arcs, setArcs] = useState<TransactionArc[]>([]);
|
const [arcs, setArcs] = useState<TransactionArc[]>([]);
|
||||||
const [stats, setStats] = useState<PeriodStats | null>(null);
|
const [stats, setStats] = useState<PeriodStats | null>(null);
|
||||||
@@ -25,14 +27,17 @@ export default function App() {
|
|||||||
const [source, setSource] = useState<'live' | 'mock'>('mock');
|
const [source, setSource] = useState<'live' | 'mock'>('mock');
|
||||||
const [currentUD, setCurrentUD] = useState<number>(11.78);
|
const [currentUD, setCurrentUD] = useState<number>(11.78);
|
||||||
const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
|
const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
|
||||||
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap');
|
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>(initialUrlState.view);
|
||||||
const [focusCity, setFocusCity] = useState<string | null>(null);
|
const [focusCity, setFocusCity] = useState<string | null>(initialUrlState.city);
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
const [infoOpen, setInfoOpen] = useState(false);
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
const isMobile = useMediaQuery('(max-width: 639px)');
|
const isMobile = useMediaQuery('(max-width: 639px)');
|
||||||
|
|
||||||
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
|
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
|
||||||
|
|
||||||
|
// Synchronise l'état dans l'URL (deep link / partage)
|
||||||
|
useUrlSync(periodDays, viewMode, focusCity);
|
||||||
|
|
||||||
const handlePeriodChange = (days: number) => {
|
const handlePeriodChange = (days: number) => {
|
||||||
animation.deactivate();
|
animation.deactivate();
|
||||||
setPeriodDays(days);
|
setPeriodDays(days);
|
||||||
@@ -43,6 +48,31 @@ export default function App() {
|
|||||||
setFocusCity(null);
|
setFocusCity(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Raccourcis clavier
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||||
|
if (e.key === 'ArrowLeft' && animation.active) {
|
||||||
|
animation.seek(Math.max(0, animation.currentIndex - 1));
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'ArrowRight' && animation.active) {
|
||||||
|
animation.seek(Math.min(animation.frames.length - 1, animation.currentIndex + 1));
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === ' ' && animation.active) {
|
||||||
|
animation.playing ? animation.pause() : animation.play();
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
if (infoOpen) { setInfoOpen(false); e.preventDefault(); }
|
||||||
|
else if (animation.active) { animation.deactivate(); e.preventDefault(); }
|
||||||
|
} else if (e.key === 'h' || e.key === 'H') {
|
||||||
|
handleViewModeChange(viewMode === 'heatmap' ? 'flow' : 'heatmap');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [animation.active, animation.playing, animation.currentIndex, animation.frames.length, infoOpen, viewMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -101,6 +131,7 @@ export default function App() {
|
|||||||
viewMode,
|
viewMode,
|
||||||
flowStats,
|
flowStats,
|
||||||
focusCity,
|
focusCity,
|
||||||
|
allTimestamps,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -140,6 +171,16 @@ export default function App() {
|
|||||||
ℹ
|
ℹ
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Barre de recherche identité */}
|
||||||
|
<div className={`absolute ${isMobile ? 'top-28' : 'top-16'} left-4 z-[1001]`}>
|
||||||
|
<SearchBar
|
||||||
|
onResult={(city) => {
|
||||||
|
setViewMode('flow');
|
||||||
|
setFocusCity(city);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Period selector — floating over map */}
|
{/* Period selector — floating over map */}
|
||||||
<div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
|
<div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
|
||||||
<PeriodSelector
|
<PeriodSelector
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ss58ToDuniterKey, SUBSQUID_ENDPOINT } from '../services/adapters/SubsquidAdapter';
|
||||||
|
import { resolveGeoByKeys } from '../services/adapters/CesiumAdapter';
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
/** Appelé quand une ville est trouvée — App bascule en vue flux et met la ville en focus. */
|
||||||
|
onResult: (city: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveQuery(query: string): Promise<{ name: string; city: string } | null> {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return null;
|
||||||
|
|
||||||
|
// Clé SS58 Ğ1v2 : commence par "g1" et fait ~50 caractères
|
||||||
|
const isKey = /^g1[1-9A-HJ-NP-Za-km-z]{40,}$/.test(q);
|
||||||
|
|
||||||
|
let duniterKey: string;
|
||||||
|
let identityName: string;
|
||||||
|
|
||||||
|
if (isKey) {
|
||||||
|
duniterKey = ss58ToDuniterKey(q);
|
||||||
|
identityName = q.slice(0, 10) + '…';
|
||||||
|
} else {
|
||||||
|
const res = await fetch(SUBSQUID_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `
|
||||||
|
query($q: String!) {
|
||||||
|
identities(filter: { name: { includesInsensitive: $q } }, first: 1) {
|
||||||
|
nodes {
|
||||||
|
accountId
|
||||||
|
name
|
||||||
|
ownerKeyChange(orderBy: BLOCK_NUMBER_ASC, first: 1) {
|
||||||
|
nodes { previousId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
variables: { q },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Subsquid HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const node = data?.data?.identities?.nodes?.[0];
|
||||||
|
if (!node) return null;
|
||||||
|
|
||||||
|
const genesisKey: string = node.ownerKeyChange.nodes[0]?.previousId ?? node.accountId;
|
||||||
|
duniterKey = ss58ToDuniterKey(genesisKey);
|
||||||
|
identityName = node.name as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geoMap = await resolveGeoByKeys([duniterKey]);
|
||||||
|
const geo = geoMap.get(duniterKey);
|
||||||
|
if (!geo) return null;
|
||||||
|
|
||||||
|
return { name: identityName, city: geo.city.split(',')[0].trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({ onResult }: SearchBarProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [found, setFound] = useState<{ name: string; city: string } | null>(null);
|
||||||
|
|
||||||
|
const close = () => { setOpen(false); setQuery(''); setError(null); setFound(null); };
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!query.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setFound(null);
|
||||||
|
try {
|
||||||
|
const result = await resolveQuery(query);
|
||||||
|
if (result) setFound(result);
|
||||||
|
else setError('Introuvable dans Cesium+');
|
||||||
|
} catch {
|
||||||
|
setError('Erreur de connexion');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = () => {
|
||||||
|
if (!found) return;
|
||||||
|
onResult(found.city);
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="w-10 h-10 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-xl flex items-center justify-center text-[#6b7280] hover:text-[#d4a843] transition-colors text-sm"
|
||||||
|
aria-label="Rechercher une identité Ğ1"
|
||||||
|
title="Rechercher une identité (nom ou clé g1…)"
|
||||||
|
>
|
||||||
|
⌕
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 bg-[#0a0b0f]/95 backdrop-blur-sm border border-[#2e2f3a] rounded-xl p-2 w-60 shadow-xl">
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSubmit();
|
||||||
|
if (e.key === 'Escape') close();
|
||||||
|
}}
|
||||||
|
placeholder="Nom ou clé g1…"
|
||||||
|
className="flex-1 min-w-0 bg-[#0f1016] border border-[#2e2f3a] rounded-lg px-2 py-1.5 text-xs text-white placeholder-[#4b5563] focus:outline-none focus:border-[#d4a843] transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading || !query.trim()}
|
||||||
|
className="text-[#d4a843] disabled:text-[#4b5563] text-sm px-1.5 hover:text-white transition-colors shrink-0"
|
||||||
|
aria-label="Rechercher"
|
||||||
|
>
|
||||||
|
{loading ? <span className="animate-spin inline-block">↻</span> : '↵'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={close}
|
||||||
|
className="text-[#4b5563] hover:text-white transition-colors shrink-0 text-xs"
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-400 text-xs px-1">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{found && (
|
||||||
|
<button
|
||||||
|
onClick={handleSelect}
|
||||||
|
className="text-left px-2 py-2 rounded-lg bg-[#1e1f2a] hover:bg-[#2e2f3a] transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-[#d4a843] text-xs font-medium truncate">{found.name}</p>
|
||||||
|
<p className="text-[#6b7280] text-xs mt-0.5">📍 {found.city} — cliquer pour zoomer</p>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
interface SparklineProps {
|
||||||
|
timestamps: number[];
|
||||||
|
periodDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mini bar-chart SVG affichant l'activité journalière sur la période.
|
||||||
|
* Utilise les timestamps déjà en mémoire — aucune requête supplémentaire.
|
||||||
|
*/
|
||||||
|
export function Sparkline({ timestamps, periodDays }: SparklineProps) {
|
||||||
|
const buckets = useMemo(() => {
|
||||||
|
if (timestamps.length === 0) return [];
|
||||||
|
const n = periodDays === 1 ? 24 : Math.min(periodDays, 30);
|
||||||
|
const now = Date.now();
|
||||||
|
const start = now - periodDays * 864e5;
|
||||||
|
const step = (periodDays * 864e5) / n;
|
||||||
|
const counts = new Array(n).fill(0);
|
||||||
|
for (const ts of timestamps) {
|
||||||
|
const i = Math.floor((ts - start) / step);
|
||||||
|
if (i >= 0 && i < n) counts[i]++;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [timestamps, periodDays]);
|
||||||
|
|
||||||
|
if (buckets.length === 0) return null;
|
||||||
|
|
||||||
|
const n = buckets.length;
|
||||||
|
const max = Math.max(...buckets, 1);
|
||||||
|
const W = 100;
|
||||||
|
const H = 32;
|
||||||
|
const barW = W / n;
|
||||||
|
const gap = barW * 0.18;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className="w-full h-8"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{buckets.map((count, i) => {
|
||||||
|
const h = Math.max(1, (count / max) * H);
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={i}
|
||||||
|
x={i * barW + gap / 2}
|
||||||
|
y={H - h}
|
||||||
|
width={barW - gap}
|
||||||
|
height={h}
|
||||||
|
fill="#d4a843"
|
||||||
|
opacity={0.25 + 0.75 * (count / max)}
|
||||||
|
rx={0.5}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
<div className="flex justify-between text-[10px] text-[#4b5563]">
|
||||||
|
<span>{periodDays === 1 ? '0h' : 'J-' + periodDays}</span>
|
||||||
|
<span>{periodDays === 1 ? 'maintenant' : 'aujourd\'hui'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import type { PeriodStats } from '../services/DataService';
|
import type { PeriodStats } from '../services/DataService';
|
||||||
import type { FlowStats } from '../data/arcData';
|
import type { FlowStats } from '../data/arcData';
|
||||||
|
import { Sparkline } from './Sparkline';
|
||||||
|
|
||||||
interface StatsPanelProps {
|
interface StatsPanelProps {
|
||||||
stats: PeriodStats | null;
|
stats: PeriodStats | null;
|
||||||
@@ -14,6 +15,7 @@ interface StatsPanelProps {
|
|||||||
flowStats?: FlowStats | null;
|
flowStats?: FlowStats | null;
|
||||||
focusCity?: string | null;
|
focusCity?: string | null;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
allTimestamps?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
@@ -60,7 +62,7 @@ function CityRow({ city, volume, count, countryCode, accent }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, className }: StatsPanelProps) {
|
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, className, allTimestamps = [] }: StatsPanelProps) {
|
||||||
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
||||||
const prevStats = useRef<PeriodStats | null>(null);
|
const prevStats = useRef<PeriodStats | null>(null);
|
||||||
|
|
||||||
@@ -113,13 +115,18 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
|||||||
Visualisation en temps réel des flux de la monnaie libre <span className="text-[#d4a843]">Ğ1</span> sur une carte mondiale.
|
Visualisation en temps réel des flux de la monnaie libre <span className="text-[#d4a843]">Ğ1</span> sur une carte mondiale.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Period label */}
|
{/* Period label + sparkline */}
|
||||||
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3">
|
<div className="border-t border-[#1e1f2a] pt-3 space-y-2">
|
||||||
|
<p className="text-[#4b5563] text-xs">
|
||||||
{animationLabel
|
{animationLabel
|
||||||
? <><span className="text-[#d4a843]">▶</span> <span className="text-[#d4a843]">{animationLabel}</span></>
|
? <><span className="text-[#d4a843]">▶</span> <span className="text-[#d4a843]">{animationLabel}</span></>
|
||||||
: <>Période : <span className="text-[#6b7280]">{periodLabel}</span></>
|
: <>Période : <span className="text-[#6b7280]">{periodLabel}</span></>
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
{!animationLabel && allTimestamps.length > 0 && (
|
||||||
|
<Sparkline timestamps={allTimestamps} periodDays={periodDays} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ---- Vue HEATMAP ---- */}
|
{/* ---- Vue HEATMAP ---- */}
|
||||||
{viewMode === 'heatmap' && (
|
{viewMode === 'heatmap' && (
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* useUrlState — synchronisation bidirectionnelle de l'état App ↔ URL.
|
||||||
|
*
|
||||||
|
* Lecture initiale : appelée une fois au démarrage (module-level).
|
||||||
|
* Écriture : useUrlSync() à appeler dans App pour maintenir l'URL à jour.
|
||||||
|
*
|
||||||
|
* Paramètres supportés :
|
||||||
|
* ?period=7&view=flow&city=Paris
|
||||||
|
*/
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
function parseInitialState(): { period: number; view: 'heatmap' | 'flow'; city: string | null } {
|
||||||
|
const p = new URLSearchParams(window.location.search);
|
||||||
|
const period = parseInt(p.get('period') ?? '', 10);
|
||||||
|
return {
|
||||||
|
period: Number.isFinite(period) && period >= 1 && period <= 365 ? period : 7,
|
||||||
|
view: p.get('view') === 'flow' ? 'flow' : 'heatmap',
|
||||||
|
city: p.get('city') ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valeurs lues depuis l'URL au chargement de la page. */
|
||||||
|
export const initialUrlState = parseInitialState();
|
||||||
|
|
||||||
|
/** Écrit l'état courant dans l'URL (history.replaceState, sans recharger). */
|
||||||
|
export function useUrlSync(
|
||||||
|
periodDays: number,
|
||||||
|
viewMode: 'heatmap' | 'flow',
|
||||||
|
focusCity: string | null,
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (periodDays !== 7) p.set('period', String(periodDays));
|
||||||
|
if (viewMode !== 'heatmap') p.set('view', viewMode);
|
||||||
|
if (focusCity) p.set('city', focusCity);
|
||||||
|
const qs = p.toString();
|
||||||
|
history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname);
|
||||||
|
}, [periodDays, viewMode, focusCity]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user