Files
stats/apps/start/src/components/realtime/map/index.tsx
Carl-Gerhard Lindesvärd 81a7e5d62e feat: dashboard v2, esm, upgrades (#211)
* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
2025-10-16 12:27:44 +02:00

353 lines
11 KiB
TypeScript

import { Tooltiper } from '@/components/ui/tooltip';
import { bind } from 'bind-event-listener';
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
ComposableMap,
Geographies,
Geography,
Marker,
ZoomableGroup,
} from 'react-simple-maps';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { useTheme } from '@/hooks/use-theme';
import type { Coordinate } from './coordinates';
// Interpolate function similar to React Native Reanimated
const interpolate = (
value: number,
inputRange: [number, number],
outputRange: [number, number],
extrapolate?: 'clamp' | 'extend' | 'identity',
): number => {
const [inputMin, inputMax] = inputRange;
const [outputMin, outputMax] = outputRange;
// Handle edge cases
if (inputMin === inputMax) return outputMin;
const progress = (value - inputMin) / (inputMax - inputMin);
// Apply extrapolation
if (extrapolate === 'clamp') {
const clampedProgress = Math.max(0, Math.min(1, progress));
return outputMin + clampedProgress * (outputMax - outputMin);
}
return outputMin + progress * (outputMax - outputMin);
};
import {
calculateGeographicMidpoint,
clusterCoordinates,
getAverageCenter,
getOuterMarkers,
} from './coordinates';
import { GEO_MAP_URL, determineZoom, getBoundingBox } from './map.helpers';
import { calculateMarkerSize } from './markers';
type Props = {
markers: Coordinate[];
sidebarConfig?: {
width: number;
position: 'left' | 'right';
};
};
const Map = ({ markers, sidebarConfig }: Props) => {
const showCenterMarker = false;
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState<{ width: number; height: number } | null>(
null,
);
const [currentZoom, setCurrentZoom] = useState(1);
const [debouncedZoom, setDebouncedZoom] = useState(1);
const zoomTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Memoize expensive calculations
const { hull, center, initialZoom } = useMemo(() => {
const hull = getOuterMarkers(markers);
const center =
hull.length < 2
? getAverageCenter(markers)
: calculateGeographicMidpoint(hull);
// Calculate initial zoom based on markers distribution
const boundingBox = getBoundingBox(hull.length > 0 ? hull : markers);
const minZoom = 1;
const maxZoom = 20;
const aspectRatio = size ? size.width / size.height : 1;
const autoZoom = Math.max(
minZoom,
Math.min(maxZoom, determineZoom(boundingBox, aspectRatio) * 0.4),
);
// Use calculated zoom if we have markers, otherwise default to 1
const initialZoom = markers.length > 0 ? autoZoom : 1;
return { hull, center, initialZoom };
}, [markers, size]);
// Update current zoom when initial zoom changes (when new markers are loaded)
useEffect(() => {
setCurrentZoom(initialZoom);
setDebouncedZoom(initialZoom);
}, [initialZoom]);
// Debounced zoom update for marker clustering
const updateDebouncedZoom = useCallback((newZoom: number) => {
if (zoomTimeoutRef.current) {
clearTimeout(zoomTimeoutRef.current);
}
zoomTimeoutRef.current = setTimeout(() => {
setDebouncedZoom(newZoom);
}, 100); // 100ms debounce delay
}, []);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (zoomTimeoutRef.current) {
clearTimeout(zoomTimeoutRef.current);
}
};
}, []);
// Memoize center coordinates adjustment for sidebar
const { long, lat } = useMemo(() => {
let adjustedLong = center.long;
if (sidebarConfig && size) {
// Calculate how much to shift the map to center content in visible area
const sidebarOffset =
sidebarConfig.position === 'left'
? sidebarConfig.width / 2
: -sidebarConfig.width / 2;
// Convert pixel offset to longitude degrees
// This is a rough approximation - degrees per pixel at current zoom
const longitudePerPixel = 360 / (size.width * initialZoom);
const longitudeOffset = sidebarOffset * longitudePerPixel;
adjustedLong = center.long - longitudeOffset; // Subtract to shift map right for left sidebar
}
return { long: adjustedLong, lat: center.lat };
}, [center.long, center.lat, sidebarConfig, size, initialZoom]);
const minZoom = 1;
const maxZoom = 20;
useEffect(() => {
return bind(window, {
type: 'resize',
listener() {
if (ref.current) {
const parentRect = ref.current.parentElement?.getBoundingClientRect();
setSize({
width: parentRect?.width ?? 0,
height: parentRect?.height ?? 0,
});
}
},
});
}, []);
useEffect(() => {
if (ref.current) {
const parentRect = ref.current.parentElement?.getBoundingClientRect();
setSize({
width: parentRect?.width ?? 0,
height: parentRect?.height ?? 0,
});
}
}, []);
// Dynamic marker size based on zoom level - balanced scaling for new size range
const getMarkerSize = useCallback(
(baseSize: number) => {
// Interpolate the adjustment value from zoom 1 to 20
// At zoom 1: adjustThisValue = 1
// At zoom 20: adjustThisValue = 0.5
const adjustThisValue = interpolate(
currentZoom,
[1, 20],
[1.5, 0.6],
'clamp',
);
const scaleFactor = (1 / Math.sqrt(currentZoom)) * adjustThisValue;
// Ensure minimum size for visibility, but allow smaller sizes for precision
const minSize = baseSize * 0.05;
const scaledSize = baseSize * scaleFactor;
return Math.max(minSize, scaledSize);
},
[currentZoom],
);
const getBorderWidth = useCallback(() => {
const map = {
0.1: [15, 20],
0.15: [10, 15],
0.25: [5, 10],
0.5: [0, 5],
};
const found = Object.entries(map).find(([, value]) => {
if (currentZoom >= value[0] && currentZoom <= value[1]) {
return true;
}
});
return found ? Number.parseFloat(found[0]) : 0.1;
}, [currentZoom]);
const theme = useTheme();
// Memoize clustered markers
const clusteredMarkers = useMemo(() => {
return clusterCoordinates(markers, 150, {
zoom: debouncedZoom,
adaptiveRadius: true,
});
}, [markers, debouncedZoom]);
return (
<div ref={ref} className="relative">
<div className="bg-gradient-to-t from-def-100 to-transparent h-1/10 absolute bottom-0 left-0 right-0" />
{size === null ? (
<></>
) : (
<>
<ComposableMap
projection="geoMercator"
width={size?.width || 800}
height={size?.height || 400}
>
<ZoomableGroup
center={[long, lat]}
zoom={initialZoom}
minZoom={minZoom}
maxZoom={maxZoom}
onMove={(event) => {
if (currentZoom !== event.zoom) {
setCurrentZoom(event.zoom);
updateDebouncedZoom(event.zoom);
}
}}
>
<Geographies geography={GEO_MAP_URL}>
{({ geographies }) =>
geographies
.filter((geo) => {
return geo.properties.name !== 'Antarctica';
})
.map((geo) => (
<Geography
key={geo.rsmKey}
geography={geo}
fill={theme.theme === 'dark' ? '#000' : '#f0f0f0'}
stroke={theme.theme === 'dark' ? '#333' : '#999'}
strokeWidth={getBorderWidth()}
pointerEvents={'none'}
/>
))
}
</Geographies>
{showCenterMarker && (
<Marker coordinates={[center.long, center.lat]}>
<circle r={getMarkerSize(10)} fill="green" stroke="#fff" />
</Marker>
)}
{clusteredMarkers.map((marker, index) => {
const size = getMarkerSize(calculateMarkerSize(marker.count));
const coordinates: [number, number] = [
marker.center.long,
marker.center.lat,
];
return (
<Fragment
key={`cluster-${index}-${marker.center.long}-${marker.center.lat}`}
>
{/* Animated ping effect */}
<Marker coordinates={coordinates}>
<circle
r={size}
fill={theme.theme === 'dark' ? '#3d79ff' : '#2266ec'}
className="animate-ping opacity-20"
/>
</Marker>
{/* Main marker with tooltip */}
<Tooltiper
asChild
content={
<div className="flex min-w-[200px] flex-col gap-2">
<h3 className="font-semibold capitalize">
{`${marker.count} visitor${marker.count !== 1 ? 's' : ''}`}
</h3>
{marker.members
.slice(0, 5)
.filter((item) => item.country || item.city)
.map((item) => (
<div
className="row items-center gap-2"
key={`${item.long}-${item.lat}`}
>
<SerieIcon
name={
item.country || `${item.lat}, ${item.long}`
}
/>
{item.city || 'Unknown'}
</div>
))}
{marker.members.length > 5 && (
<div className="text-sm text-gray-500">
+ {marker.members.length - 5} more
</div>
)}
</div>
}
>
<Marker coordinates={coordinates}>
<circle
r={size}
fill={theme.theme === 'dark' ? '#3d79ff' : '#2266ec'}
fillOpacity={0.8}
stroke="#fff"
strokeWidth={getBorderWidth() * 0.5}
/>
<text
x={0}
y={0}
fill="#fff"
textAnchor="middle"
dominantBaseline="middle"
fontSize={size * 0.6}
fontWeight="bold"
>
{marker.count}
</text>
</Marker>
</Tooltiper>
</Fragment>
);
})}
</ZoomableGroup>
</ComposableMap>
</>
)}
</div>
);
};
export default Map;