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
This commit is contained in:
committed by
GitHub
parent
436e81ecc9
commit
81a7e5d62e
397
apps/start/src/components/realtime/map/coordinates.ts
Normal file
397
apps/start/src/components/realtime/map/coordinates.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
export type Coordinate = {
|
||||
lat: number;
|
||||
long: number;
|
||||
city?: string;
|
||||
country?: string;
|
||||
};
|
||||
|
||||
export function haversineDistance(
|
||||
coord1: Coordinate,
|
||||
coord2: Coordinate,
|
||||
): number {
|
||||
const R = 6371; // Earth's radius in kilometers
|
||||
const lat1Rad = coord1.lat * (Math.PI / 180);
|
||||
const lat2Rad = coord2.lat * (Math.PI / 180);
|
||||
const deltaLatRad = (coord2.lat - coord1.lat) * (Math.PI / 180);
|
||||
const deltaLonRad = (coord2.long - coord1.long) * (Math.PI / 180);
|
||||
|
||||
const a =
|
||||
Math.sin(deltaLatRad / 2) * Math.sin(deltaLatRad / 2) +
|
||||
Math.cos(lat1Rad) *
|
||||
Math.cos(lat2Rad) *
|
||||
Math.sin(deltaLonRad / 2) *
|
||||
Math.sin(deltaLonRad / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return R * c; // Distance in kilometers
|
||||
}
|
||||
|
||||
export function findFarthestPoints(
|
||||
coordinates: Coordinate[],
|
||||
): [Coordinate, Coordinate] {
|
||||
if (coordinates.length < 2) {
|
||||
throw new Error('At least two coordinates are required');
|
||||
}
|
||||
|
||||
let maxDistance = 0;
|
||||
let point1: Coordinate = coordinates[0]!;
|
||||
let point2: Coordinate = coordinates[1]!;
|
||||
|
||||
for (let i = 0; i < coordinates.length; i++) {
|
||||
for (let j = i + 1; j < coordinates.length; j++) {
|
||||
const distance = haversineDistance(coordinates[i]!, coordinates[j]!);
|
||||
if (distance > maxDistance) {
|
||||
maxDistance = distance;
|
||||
point1 = coordinates[i]!;
|
||||
point2 = coordinates[j]!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [point1, point2];
|
||||
}
|
||||
|
||||
export function getAverageCenter(coordinates: Coordinate[]): Coordinate {
|
||||
if (coordinates.length === 0) {
|
||||
return { long: 0, lat: 20 };
|
||||
}
|
||||
|
||||
let sumLong = 0;
|
||||
let sumLat = 0;
|
||||
|
||||
for (const coord of coordinates) {
|
||||
sumLong += coord.long;
|
||||
sumLat += coord.lat;
|
||||
}
|
||||
|
||||
const avgLat = sumLat / coordinates.length;
|
||||
const avgLong = sumLong / coordinates.length;
|
||||
|
||||
return { long: avgLong, lat: avgLat };
|
||||
}
|
||||
|
||||
function sortCoordinates(a: Coordinate, b: Coordinate): number {
|
||||
return a.long === b.long ? a.lat - b.lat : a.long - b.long;
|
||||
}
|
||||
|
||||
function cross(o: Coordinate, a: Coordinate, b: Coordinate): number {
|
||||
return (
|
||||
(a.long - o.long) * (b.lat - o.lat) - (a.lat - o.lat) * (b.long - o.long)
|
||||
);
|
||||
}
|
||||
|
||||
// convex hull
|
||||
export function getOuterMarkers(coordinates: Coordinate[]): Coordinate[] {
|
||||
const sorted = coordinates.sort(sortCoordinates);
|
||||
|
||||
if (sorted.length <= 3) return sorted;
|
||||
|
||||
const lower: Coordinate[] = [];
|
||||
for (const coord of sorted) {
|
||||
while (
|
||||
lower.length >= 2 &&
|
||||
cross(lower[lower.length - 2]!, lower[lower.length - 1]!, coord) <= 0
|
||||
) {
|
||||
lower.pop();
|
||||
}
|
||||
lower.push(coord);
|
||||
}
|
||||
|
||||
const upper: Coordinate[] = [];
|
||||
for (let i = coordinates.length - 1; i >= 0; i--) {
|
||||
while (
|
||||
upper.length >= 2 &&
|
||||
cross(upper[upper.length - 2]!, upper[upper.length - 1]!, sorted[i]!) <= 0
|
||||
) {
|
||||
upper.pop();
|
||||
}
|
||||
upper.push(sorted[i]!);
|
||||
}
|
||||
|
||||
upper.pop();
|
||||
lower.pop();
|
||||
return lower.concat(upper);
|
||||
}
|
||||
|
||||
export function calculateCentroid(polygon: Coordinate[]): Coordinate {
|
||||
if (polygon.length < 3) {
|
||||
throw new Error('At least three points are required to form a polygon.');
|
||||
}
|
||||
|
||||
let area = 0;
|
||||
let centroidLat = 0;
|
||||
let centroidLong = 0;
|
||||
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const x0 = polygon[j]!.long;
|
||||
const y0 = polygon[j]!.lat;
|
||||
const x1 = polygon[i]!.long;
|
||||
const y1 = polygon[i]!.lat;
|
||||
const a = x0 * y1 - x1 * y0;
|
||||
area += a;
|
||||
centroidLong += (x0 + x1) * a;
|
||||
centroidLat += (y0 + y1) * a;
|
||||
}
|
||||
|
||||
area = area / 2;
|
||||
if (area === 0) {
|
||||
// This should not happen for a proper convex hull
|
||||
throw new Error('Area of the polygon is zero, check the coordinates.');
|
||||
}
|
||||
|
||||
centroidLat /= 6 * area;
|
||||
centroidLong /= 6 * area;
|
||||
|
||||
return { lat: centroidLat, long: centroidLong };
|
||||
}
|
||||
|
||||
export function calculateGeographicMidpoint(
|
||||
coordinate: Coordinate[],
|
||||
): Coordinate {
|
||||
let minLat = Number.POSITIVE_INFINITY;
|
||||
let maxLat = Number.NEGATIVE_INFINITY;
|
||||
let minLong = Number.POSITIVE_INFINITY;
|
||||
let maxLong = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const { lat, long } of coordinate) {
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (long < minLong) minLong = long;
|
||||
if (long > maxLong) maxLong = long;
|
||||
}
|
||||
|
||||
// Handling the wrap around the international date line
|
||||
let midLong: number;
|
||||
if (maxLong > minLong) {
|
||||
midLong = (maxLong + minLong) / 2;
|
||||
} else {
|
||||
// Adjust calculation when spanning the dateline
|
||||
midLong = ((maxLong + 360 + minLong) / 2) % 360;
|
||||
}
|
||||
|
||||
const midLat = (maxLat + minLat) / 2;
|
||||
|
||||
return { lat: midLat, long: midLong };
|
||||
}
|
||||
|
||||
export function clusterCoordinates(
|
||||
coordinates: Coordinate[],
|
||||
radius = 25,
|
||||
options: {
|
||||
useOptimizedClustering?: boolean;
|
||||
zoom?: number;
|
||||
stability?: boolean;
|
||||
adaptiveRadius?: boolean;
|
||||
viewport?: {
|
||||
center: Coordinate;
|
||||
bounds: {
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
minLong: number;
|
||||
maxLong: number;
|
||||
};
|
||||
};
|
||||
} = {},
|
||||
) {
|
||||
const { zoom = 1, adaptiveRadius = true, viewport } = options;
|
||||
|
||||
// Calculate adaptive radius based on zoom level and coordinate density
|
||||
let adjustedRadius = radius;
|
||||
|
||||
if (adaptiveRadius) {
|
||||
// Much more aggressive clustering at lower zoom levels
|
||||
// At zoom 1: use 3x larger radius for very aggressive clustering
|
||||
// At zoom 2: use 2x radius
|
||||
// At zoom 4: use 1x radius
|
||||
// At zoom 8: use 0.2x radius for precision
|
||||
const zoomFactor = Math.max(0.1, 4 / (zoom + 1));
|
||||
adjustedRadius = radius * zoomFactor;
|
||||
|
||||
// Further adjust based on coordinate density if viewport is provided
|
||||
if (viewport && coordinates.length > 0) {
|
||||
const viewportCoords = coordinates.filter(
|
||||
(coord) =>
|
||||
coord.lat >= viewport.bounds.minLat &&
|
||||
coord.lat <= viewport.bounds.maxLat &&
|
||||
coord.long >= viewport.bounds.minLong &&
|
||||
coord.long <= viewport.bounds.maxLong,
|
||||
);
|
||||
|
||||
if (viewportCoords.length > 0) {
|
||||
const viewportArea =
|
||||
(viewport.bounds.maxLat - viewport.bounds.minLat) *
|
||||
(viewport.bounds.maxLong - viewport.bounds.minLong);
|
||||
|
||||
const density = viewportCoords.length / viewportArea;
|
||||
|
||||
// Adjust radius based on density - higher density = larger radius for more aggressive clustering
|
||||
const densityFactor = Math.max(
|
||||
0.5,
|
||||
Math.min(5, Math.sqrt(density * 1000) + 1),
|
||||
);
|
||||
adjustedRadius *= densityFactor;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Simple zoom-based adjustment when adaptive is disabled
|
||||
adjustedRadius = radius * Math.max(0.5, 1 / Math.sqrt(zoom));
|
||||
}
|
||||
|
||||
// Always use basic clustering for now to ensure it works correctly
|
||||
// TODO: Re-enable optimized clustering after thorough testing
|
||||
const result = basicClusterCoordinates(coordinates, adjustedRadius);
|
||||
|
||||
// Debug: Log clustering results
|
||||
if (coordinates.length > 0) {
|
||||
console.log(
|
||||
`Clustering ${coordinates.length} coordinates with radius ${adjustedRadius.toFixed(2)}km resulted in ${result.length} clusters`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Aggressive clustering algorithm with iterative expansion
|
||||
function basicClusterCoordinates(coordinates: Coordinate[], radius: number) {
|
||||
if (coordinates.length === 0) return [];
|
||||
|
||||
const clusters: {
|
||||
center: Coordinate;
|
||||
count: number;
|
||||
members: Coordinate[];
|
||||
}[] = [];
|
||||
const visited = new Set<number>();
|
||||
|
||||
// Sort coordinates by density (coordinates near others first)
|
||||
const coordinatesWithDensity = coordinates
|
||||
.map((coord, idx) => {
|
||||
const nearbyCount = coordinates.filter(
|
||||
(other) => haversineDistance(coord, other) <= radius * 0.5,
|
||||
).length;
|
||||
return { ...coord, originalIdx: idx, nearbyCount };
|
||||
})
|
||||
.sort((a, b) => b.nearbyCount - a.nearbyCount);
|
||||
|
||||
coordinatesWithDensity.forEach(
|
||||
({ lat, long, city, country, originalIdx }) => {
|
||||
if (!visited.has(originalIdx)) {
|
||||
const cluster = {
|
||||
members: [{ lat, long, city, country }],
|
||||
center: { lat, long },
|
||||
count: 1,
|
||||
};
|
||||
|
||||
// Mark the initial coordinate as visited
|
||||
visited.add(originalIdx);
|
||||
|
||||
// Iteratively expand the cluster to include nearby points
|
||||
let expandedInLastIteration = true;
|
||||
while (expandedInLastIteration) {
|
||||
expandedInLastIteration = false;
|
||||
|
||||
// For each existing cluster member, find nearby unvisited coordinates
|
||||
for (const member of [...cluster.members]) {
|
||||
coordinatesWithDensity.forEach(
|
||||
({
|
||||
lat: otherLat,
|
||||
long: otherLong,
|
||||
city: otherCity,
|
||||
country: otherCountry,
|
||||
originalIdx: otherIdx,
|
||||
}) => {
|
||||
if (!visited.has(otherIdx)) {
|
||||
const distance = haversineDistance(member, {
|
||||
lat: otherLat,
|
||||
long: otherLong,
|
||||
});
|
||||
|
||||
if (distance <= radius) {
|
||||
cluster.members.push({
|
||||
lat: otherLat,
|
||||
long: otherLong,
|
||||
city: otherCity,
|
||||
country: otherCountry,
|
||||
});
|
||||
visited.add(otherIdx);
|
||||
cluster.count++;
|
||||
expandedInLastIteration = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the proper center for the cluster
|
||||
cluster.center = calculateClusterCenter(cluster.members);
|
||||
|
||||
clusters.push(cluster);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
||||
// Note: Optimized clustering algorithm was removed temporarily
|
||||
// TODO: Re-implement optimized clustering after basic algorithm is working well
|
||||
|
||||
// Utility function to get clustering statistics for debugging
|
||||
export function getClusteringStats(
|
||||
coordinates: Coordinate[],
|
||||
clusters: ReturnType<typeof clusterCoordinates>,
|
||||
) {
|
||||
const totalPoints = coordinates.length;
|
||||
const totalClusters = clusters.length;
|
||||
const singletonClusters = clusters.filter((c) => c.count === 1).length;
|
||||
const avgClusterSize = totalPoints > 0 ? totalPoints / totalClusters : 0;
|
||||
const maxClusterSize = Math.max(...clusters.map((c) => c.count));
|
||||
const compressionRatio = totalClusters > 0 ? totalPoints / totalClusters : 1;
|
||||
|
||||
return {
|
||||
totalPoints,
|
||||
totalClusters,
|
||||
singletonClusters,
|
||||
avgClusterSize: Math.round(avgClusterSize * 100) / 100,
|
||||
maxClusterSize,
|
||||
compressionRatio: Math.round(compressionRatio * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to calculate cluster center with longitude wrapping
|
||||
function calculateClusterCenter(members: Coordinate[]): Coordinate {
|
||||
if (members.length === 1) {
|
||||
return { ...members[0]! };
|
||||
}
|
||||
|
||||
// Check if we need to handle longitude wrapping around the dateline
|
||||
const longitudes = members.map((m) => m.long);
|
||||
const minLong = Math.min(...longitudes);
|
||||
const maxLong = Math.max(...longitudes);
|
||||
|
||||
let avgLat = 0;
|
||||
let avgLong = 0;
|
||||
|
||||
if (maxLong - minLong > 180) {
|
||||
// Handle dateline crossing
|
||||
let adjustedLongSum = 0;
|
||||
for (const member of members) {
|
||||
avgLat += member.lat;
|
||||
const adjustedLong = member.long < 0 ? member.long + 360 : member.long;
|
||||
adjustedLongSum += adjustedLong;
|
||||
}
|
||||
avgLat /= members.length;
|
||||
avgLong = (adjustedLongSum / members.length) % 360;
|
||||
if (avgLong > 180) avgLong -= 360;
|
||||
} else {
|
||||
// Normal case - no dateline crossing
|
||||
for (const member of members) {
|
||||
avgLat += member.lat;
|
||||
avgLong += member.long;
|
||||
}
|
||||
avgLat /= members.length;
|
||||
avgLong /= members.length;
|
||||
}
|
||||
|
||||
return { lat: avgLat, long: avgLong };
|
||||
}
|
||||
352
apps/start/src/components/realtime/map/index.tsx
Normal file
352
apps/start/src/components/realtime/map/index.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
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;
|
||||
93
apps/start/src/components/realtime/map/map.helpers.tsx
Normal file
93
apps/start/src/components/realtime/map/map.helpers.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useZoomPan } from 'react-simple-maps';
|
||||
|
||||
import type { Coordinate } from './coordinates';
|
||||
|
||||
export const GEO_MAP_URL =
|
||||
'https://unpkg.com/world-atlas@2.0.2/countries-50m.json';
|
||||
|
||||
export function useAnimatedState(initialValue: number) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [target, setTarget] = useState(initialValue);
|
||||
const ref = useRef<number>(0);
|
||||
const animate = useCallback(() => {
|
||||
ref.current = requestAnimationFrame(() => {
|
||||
setValue((prevValue) => {
|
||||
const diff = target - prevValue;
|
||||
if (Math.abs(diff) < 0.01) {
|
||||
return target; // Stop animating when close enough
|
||||
}
|
||||
// Adjust this factor (e.g., 0.02) to control the speed of the animation
|
||||
return prevValue + diff * 0.05;
|
||||
});
|
||||
animate(); // Loop the animation frame
|
||||
});
|
||||
}, [target]);
|
||||
|
||||
useEffect(() => {
|
||||
animate(); // Start the animation
|
||||
return () => cancelAnimationFrame(ref.current!); // Cleanup the animation on unmount
|
||||
}, [animate]);
|
||||
|
||||
useEffect(() => {
|
||||
setTarget(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return [value, setTarget] as const;
|
||||
}
|
||||
|
||||
export const getBoundingBox = (coordinates: Coordinate[]) => {
|
||||
const longitudes = coordinates.map((coord) => coord.long);
|
||||
const latitudes = coordinates.map((coord) => coord.lat);
|
||||
const minLat = Math.min(...latitudes);
|
||||
const maxLat = Math.max(...latitudes);
|
||||
const minLong = Math.min(...longitudes);
|
||||
const maxLong = Math.max(...longitudes);
|
||||
|
||||
return { minLat, maxLat, minLong, maxLong };
|
||||
};
|
||||
|
||||
export const determineZoom = (
|
||||
bbox: ReturnType<typeof getBoundingBox>,
|
||||
aspectRatio = 1.0,
|
||||
): number => {
|
||||
const latDiff = bbox.maxLat - bbox.minLat;
|
||||
const longDiff = bbox.maxLong - bbox.minLong;
|
||||
|
||||
// Normalize longitudinal span based on latitude to correct for increasing distortion
|
||||
// towards the poles in a Mercator projection.
|
||||
const avgLat = (bbox.maxLat + bbox.minLat) / 2;
|
||||
const longDiffAdjusted = longDiff * Math.cos((avgLat * Math.PI) / 180);
|
||||
|
||||
// Adjust calculations depending on the aspect ratio.
|
||||
const maxDiff =
|
||||
aspectRatio > 1
|
||||
? Math.max(latDiff / aspectRatio, longDiffAdjusted) // Wider than tall
|
||||
: Math.max(latDiff, longDiffAdjusted * aspectRatio); // Taller than wide
|
||||
|
||||
// Adjust zoom level scaling factor based on application or testing.
|
||||
const zoom = Math.max(1, Math.min(20, 200 / maxDiff));
|
||||
return zoom;
|
||||
};
|
||||
|
||||
export function CustomZoomableGroup({
|
||||
zoom,
|
||||
center,
|
||||
children,
|
||||
}: {
|
||||
zoom: number;
|
||||
center: [number, number];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { mapRef, transformString } = useZoomPan({
|
||||
center: center,
|
||||
zoom,
|
||||
filterZoomEvent: () => false,
|
||||
});
|
||||
|
||||
return (
|
||||
<g ref={mapRef}>
|
||||
<g transform={transformString}>{children}</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
43
apps/start/src/components/realtime/map/markers.ts
Normal file
43
apps/start/src/components/realtime/map/markers.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { Coordinate } from './coordinates';
|
||||
|
||||
const useActiveMarkers = (initialMarkers: Coordinate[]) => {
|
||||
const [activeMarkers, setActiveMarkers] = useState(initialMarkers);
|
||||
|
||||
const toggleActiveMarkers = useCallback(() => {
|
||||
// Shuffle array function
|
||||
const shuffled = [...initialMarkers].sort(() => 0.5 - Math.random());
|
||||
// Cut the array in half randomly to simulate changes in active markers
|
||||
const selected = shuffled.slice(
|
||||
0,
|
||||
Math.floor(Math.random() * shuffled.length) + 1,
|
||||
);
|
||||
setActiveMarkers(selected);
|
||||
}, [activeMarkers]);
|
||||
|
||||
return { markers: activeMarkers, toggle: toggleActiveMarkers };
|
||||
};
|
||||
|
||||
export default useActiveMarkers;
|
||||
|
||||
export function calculateMarkerSize(count: number) {
|
||||
const minSize = 3; // Minimum size for single visitor (reduced from 4)
|
||||
const maxSize = 14; // Maximum size for very large clusters (reduced from 20)
|
||||
|
||||
if (count <= 1) return minSize;
|
||||
|
||||
// Use square root scaling for better visual differentiation
|
||||
// This creates more noticeable size differences for common visitor counts
|
||||
// Examples:
|
||||
// 1 visitor: 3px
|
||||
// 2 visitors: ~5px
|
||||
// 5 visitors: ~7px
|
||||
// 10 visitors: ~9px
|
||||
// 25 visitors: ~12px
|
||||
// 50+ visitors: ~14px (max)
|
||||
const scaledSize = minSize + Math.sqrt(count - 1) * 1.8;
|
||||
|
||||
// Ensure size does not exceed maxSize or fall below minSize
|
||||
return Math.max(minSize, Math.min(scaledSize, maxSize));
|
||||
}
|
||||
Reference in New Issue
Block a user