Skip to content

Commit 7876e0d

Browse files
authored
fix: improve map loading safety and UI (#300)
- Add auto-retry (every 5s) for GeoJSON fetch with timeout protection - Filter out invalid coordinates and [0,0] null island points - Always render map (no blocking loading states) - Add info disclaimer overlay to bottom-left of map - Remove "live" indicator from timeline controls - Fix timeline location formatting when city data is missing
1 parent b22e015 commit 7876e0d

File tree

4 files changed

+95
-49
lines changed

4 files changed

+95
-49
lines changed

src/components/Charts/Map2D/Map2D.tsx

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,24 +47,66 @@ function Map2DChartComponent({
4747
const chartStyle = useMemo(() => ({ height, width: '100%', minHeight: height }), [height]);
4848
const chartOpts = useMemo(() => ({ renderer: 'canvas' as const }), []);
4949

50-
// Load and register world map on mount
50+
// Load and register world map on mount - retry every 5s on failure
5151
useEffect(() => {
52+
const controller = new AbortController();
53+
let timeoutId: ReturnType<typeof setTimeout>;
54+
5255
const loadWorldMap = async (): Promise<void> => {
5356
try {
54-
// Fetch world map GeoJSON from local public directory
55-
const response = await fetch('/data/maps/world.json');
57+
// Fetch with timeout (10 seconds)
58+
const timeoutPromise = new Promise<never>((_, reject) => {
59+
timeoutId = setTimeout(() => reject(new Error('Map load timeout')), 10000);
60+
});
61+
62+
const fetchPromise = fetch('/data/maps/world.json', {
63+
signal: controller.signal,
64+
});
65+
66+
const response = await Promise.race([fetchPromise, timeoutPromise]);
67+
clearTimeout(timeoutId);
68+
69+
// Validate HTTP response
70+
if (!response.ok) {
71+
throw new Error(`Failed to load map: HTTP ${response.status} ${response.statusText}`);
72+
}
73+
74+
// Parse and validate JSON
5675
const worldGeoJson = await response.json();
5776

77+
if (!worldGeoJson || typeof worldGeoJson !== 'object') {
78+
throw new Error('Invalid map data: malformed GeoJSON');
79+
}
80+
5881
// Register the map with echarts
5982
echarts.registerMap('world', worldGeoJson);
6083
setMapLoaded(true);
6184
} catch (error) {
62-
console.error('Failed to load world map:', error);
63-
setMapLoaded(false);
85+
clearTimeout(timeoutId);
86+
87+
// Don't retry if aborted (component unmounted)
88+
if (error instanceof Error && error.name === 'AbortError') {
89+
return;
90+
}
91+
92+
const errorMessage = error instanceof Error ? error.message : 'Unknown error loading map';
93+
console.error('Failed to load world map:', errorMessage, '- retrying in 5s...');
94+
95+
// Retry every 5 seconds indefinitely
96+
timeoutId = setTimeout(() => {
97+
if (!controller.signal.aborted) {
98+
loadWorldMap();
99+
}
100+
}, 5000);
64101
}
65102
};
66103

67104
loadWorldMap();
105+
106+
return () => {
107+
controller.abort();
108+
clearTimeout(timeoutId);
109+
};
68110
}, []);
69111

70112
// Create a memoized function to generate scatter series config
@@ -135,8 +177,14 @@ function Map2DChartComponent({
135177
// Filter out points without valid coordinates
136178
let hasNewPoints = false;
137179
points.forEach(point => {
138-
// Skip points without valid coordinates
139-
if (!point.coords || point.coords.length < 2 || point.coords[0] == null || point.coords[1] == null) {
180+
// Skip points without valid coordinates or "null island" [0, 0] fallback coords
181+
if (
182+
!point.coords ||
183+
point.coords.length < 2 ||
184+
point.coords[0] == null ||
185+
point.coords[1] == null ||
186+
(point.coords[0] === 0 && point.coords[1] === 0) // Reject [0, 0] null island
187+
) {
140188
return;
141189
}
142190
const key = `${point.coords[0]},${point.coords[1]}`;
@@ -289,17 +337,9 @@ function Map2DChartComponent({
289337
// eslint-disable-next-line react-hooks/exhaustive-deps
290338
}, [mapLoaded]); // Only recalculate when map loads - after that we update via setOption
291339

292-
// Don't render until map is loaded
293-
if (!mapLoaded) {
294-
return (
295-
<div className="flex w-full items-center justify-center" style={{ height }}>
296-
<div className="text-foreground">Loading map...</div>
297-
</div>
298-
);
299-
}
300-
340+
// Always render the map container
301341
return (
302-
<div className="h-full w-full">
342+
<div className="relative h-full w-full">
303343
<ReactECharts
304344
ref={chartRef}
305345
option={option}
@@ -308,6 +348,9 @@ function Map2DChartComponent({
308348
lazyUpdate={true}
309349
opts={chartOpts}
310350
/>
351+
<div className="pointer-events-none absolute bottom-2 left-2 rounded bg-surface/90 px-2 py-1 text-xs text-muted backdrop-blur-sm">
352+
Data from nodes contributing to Xatu • Not representative of actual Ethereum network distribution
353+
</div>
311354
</div>
312355
);
313356
}

src/pages/ethereum/live/components/Timeline/Timeline.tsx

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ function TimelineComponent({
1818
onBackward,
1919
onForward,
2020
onTimeClick,
21-
isLive = false,
21+
isLive: _isLive = false,
2222
ariaLabel = 'Slot View Timeline',
2323
}: TimelineProps): JSX.Element {
2424
// Memoize the timeline click handler to prevent SlotTimeline re-renders
@@ -38,17 +38,9 @@ function TimelineComponent({
3838

3939
return (
4040
<div className="w-full">
41-
{/* Header with Live indicator, controls, and current time */}
41+
{/* Header with controls and current time */}
4242
<div className="mb-4 flex items-center gap-6">
43-
{/* Left: Live indicator */}
44-
{isLive && (
45-
<div className="flex items-center gap-2">
46-
<div className="size-2 animate-pulse rounded-sm bg-success" />
47-
<span className="text-sm text-success">Live</span>
48-
</div>
49-
)}
50-
51-
{/* Center: Playback controls */}
43+
{/* Playback controls */}
5244
<div className="flex shrink-0 items-center gap-2">
5345
{/* Backward button */}
5446
<button

src/pages/ethereum/live/hooks/useMapData/useMapData.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,32 @@ export function useMapData(nodes: FctBlockFirstSeenByNode[]): MapPointWithTiming
2020
});
2121

2222
// Convert to PointData format with timing information
23-
return Array.from(cityGroups.entries()).map(([, cityNodes]) => {
24-
const node = cityNodes[0];
25-
const name = node.meta_client_geo_city
26-
? `${node.meta_client_geo_city}, ${node.meta_client_geo_country}`
27-
: (node.meta_client_geo_country ?? 'Unknown');
23+
return Array.from(cityGroups.entries())
24+
.map(([, cityNodes]) => {
25+
const node = cityNodes[0];
26+
const name = node.meta_client_geo_city
27+
? `${node.meta_client_geo_city}, ${node.meta_client_geo_country}`
28+
: (node.meta_client_geo_country ?? 'Unknown');
2829

29-
// Find the earliest seen time for this city group
30-
const earliestSeenTime = Math.min(
31-
...cityNodes.map(n => n.seen_slot_start_diff ?? Infinity).filter(time => time !== Infinity)
32-
);
30+
// Find the earliest seen time for this city group
31+
const earliestSeenTime = Math.min(
32+
...cityNodes.map(n => n.seen_slot_start_diff ?? Infinity).filter(time => time !== Infinity)
33+
);
3334

34-
return {
35-
name,
36-
coords: [node.meta_client_geo_longitude ?? 0, node.meta_client_geo_latitude ?? 0] as [number, number],
37-
value: cityNodes.length, // Number of nodes at this location
38-
earliestSeenTime: earliestSeenTime === Infinity ? 0 : earliestSeenTime,
39-
};
40-
});
35+
// Get coordinates - return null if invalid
36+
const lon = node.meta_client_geo_longitude;
37+
const lat = node.meta_client_geo_latitude;
38+
39+
return {
40+
name,
41+
coords: [lon ?? null, lat ?? null] as [number | null, number | null],
42+
value: cityNodes.length, // Number of nodes at this location
43+
earliestSeenTime: earliestSeenTime === Infinity ? 0 : earliestSeenTime,
44+
};
45+
})
46+
.filter(point => {
47+
// Filter out points with invalid coordinates or null island [0, 0]
48+
return point.coords[0] != null && point.coords[1] != null && !(point.coords[0] === 0 && point.coords[1] === 0);
49+
}) as MapPointWithTiming[];
4150
}, [nodes]);
4251
}

src/pages/ethereum/live/hooks/useSidebarData/useSidebarData.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,17 @@ export function useSidebarData({
4242
const allItems: TimelineItem[] = [];
4343

4444
// 2. Block seen in locations - Group by city and take earliest
45-
const cityFirstSeen = new Map<string, { timestamp: number; country: string }>();
45+
const cityFirstSeen = new Map<string, { timestamp: number; location: string }>();
4646
blockNodes.forEach(node => {
47-
const city = node.meta_client_geo_city ?? 'Unknown';
47+
const city = node.meta_client_geo_city;
4848
const country = node.meta_client_geo_country ?? 'Unknown';
4949
const timestamp = node.seen_slot_start_diff ?? 0;
5050

51-
const key = `${city}, ${country}`;
52-
if (!cityFirstSeen.has(key) || timestamp < cityFirstSeen.get(key)!.timestamp) {
53-
cityFirstSeen.set(key, { timestamp, country });
51+
// Format location: "City, Country" or just "Country" if city is missing
52+
const location = city ? `${city}, ${country}` : country;
53+
54+
if (!cityFirstSeen.has(location) || timestamp < cityFirstSeen.get(location)!.timestamp) {
55+
cityFirstSeen.set(location, { timestamp, location });
5456
}
5557
});
5658

0 commit comments

Comments
 (0)