fix: realtime improvements
This commit is contained in:
@@ -1,13 +1,133 @@
|
||||
export type Coordinate = {
|
||||
export interface Coordinate {
|
||||
lat: number;
|
||||
long: number;
|
||||
city?: string;
|
||||
country?: string;
|
||||
};
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export type ClusterDetailLevel = 'country' | 'city' | 'coordinate';
|
||||
|
||||
export interface CoordinateCluster {
|
||||
center: Coordinate;
|
||||
count: number;
|
||||
members: Coordinate[];
|
||||
location: {
|
||||
city?: string;
|
||||
country?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const COUNTRY_GROUP_MAX_ZOOM = 2;
|
||||
const CITY_GROUP_MAX_ZOOM = 4.5;
|
||||
|
||||
function normalizeLocationValue(value?: string) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function getClusterDetailLevel(zoom: number): ClusterDetailLevel {
|
||||
if (zoom <= COUNTRY_GROUP_MAX_ZOOM) {
|
||||
return 'country';
|
||||
}
|
||||
|
||||
if (zoom <= CITY_GROUP_MAX_ZOOM) {
|
||||
return 'city';
|
||||
}
|
||||
|
||||
return 'coordinate';
|
||||
}
|
||||
|
||||
function getLocationSummary(members: Coordinate[]) {
|
||||
const cityCounts = new Map<string, number>();
|
||||
const countryCounts = new Map<string, number>();
|
||||
|
||||
for (const member of members) {
|
||||
const city = normalizeLocationValue(member.city);
|
||||
const country = normalizeLocationValue(member.country);
|
||||
const weight = member.count ?? 1;
|
||||
|
||||
if (city) {
|
||||
cityCounts.set(city, (cityCounts.get(city) ?? 0) + weight);
|
||||
}
|
||||
|
||||
if (country) {
|
||||
countryCounts.set(country, (countryCounts.get(country) ?? 0) + weight);
|
||||
}
|
||||
}
|
||||
|
||||
const getTopLocation = (counts: Map<string, number>) =>
|
||||
[...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||
|
||||
return {
|
||||
city: getTopLocation(cityCounts),
|
||||
country: getTopLocation(countryCounts),
|
||||
};
|
||||
}
|
||||
|
||||
function getAggregationKey(
|
||||
member: Coordinate,
|
||||
detailLevel: Exclude<ClusterDetailLevel, 'coordinate'>
|
||||
) {
|
||||
const city = normalizeLocationValue(member.city);
|
||||
const country = normalizeLocationValue(member.country);
|
||||
|
||||
if (detailLevel === 'country') {
|
||||
return country ?? city;
|
||||
}
|
||||
|
||||
if (country && city) {
|
||||
return `${country}::${city}`;
|
||||
}
|
||||
|
||||
return city ?? country;
|
||||
}
|
||||
|
||||
function regroupClustersByDetail(
|
||||
clusters: CoordinateCluster[],
|
||||
detailLevel: Exclude<ClusterDetailLevel, 'coordinate'>
|
||||
): CoordinateCluster[] {
|
||||
const grouped = new Map<string, Coordinate[]>();
|
||||
const ungrouped: CoordinateCluster[] = [];
|
||||
|
||||
for (const cluster of clusters) {
|
||||
for (const member of cluster.members) {
|
||||
const key = getAggregationKey(member, detailLevel);
|
||||
|
||||
if (!key) {
|
||||
ungrouped.push({
|
||||
members: [member],
|
||||
center: calculateClusterCenter([member]),
|
||||
count: member.count ?? 1,
|
||||
location: {
|
||||
city: normalizeLocationValue(member.city),
|
||||
country: normalizeLocationValue(member.country),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), member]);
|
||||
}
|
||||
}
|
||||
|
||||
const regrouped = [...grouped.values()].map((members) => {
|
||||
const location = getLocationSummary(members);
|
||||
|
||||
return {
|
||||
members,
|
||||
center: calculateClusterCenter(members),
|
||||
count: members.reduce((sum, member) => sum + (member.count ?? 1), 0),
|
||||
location,
|
||||
};
|
||||
});
|
||||
|
||||
return [...regrouped, ...ungrouped];
|
||||
}
|
||||
|
||||
export function haversineDistance(
|
||||
coord1: Coordinate,
|
||||
coord2: Coordinate,
|
||||
coord2: Coordinate
|
||||
): number {
|
||||
const R = 6371; // Earth's radius in kilometers
|
||||
const lat1Rad = coord1.lat * (Math.PI / 180);
|
||||
@@ -27,7 +147,7 @@ export function haversineDistance(
|
||||
}
|
||||
|
||||
export function findFarthestPoints(
|
||||
coordinates: Coordinate[],
|
||||
coordinates: Coordinate[]
|
||||
): [Coordinate, Coordinate] {
|
||||
if (coordinates.length < 2) {
|
||||
throw new Error('At least two coordinates are required');
|
||||
@@ -58,14 +178,17 @@ export function getAverageCenter(coordinates: Coordinate[]): Coordinate {
|
||||
|
||||
let sumLong = 0;
|
||||
let sumLat = 0;
|
||||
let totalWeight = 0;
|
||||
|
||||
for (const coord of coordinates) {
|
||||
sumLong += coord.long;
|
||||
sumLat += coord.lat;
|
||||
const weight = coord.count ?? 1;
|
||||
sumLong += coord.long * weight;
|
||||
sumLat += coord.lat * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
|
||||
const avgLat = sumLat / coordinates.length;
|
||||
const avgLong = sumLong / coordinates.length;
|
||||
const avgLat = sumLat / totalWeight;
|
||||
const avgLong = sumLong / totalWeight;
|
||||
|
||||
return { long: avgLong, lat: avgLat };
|
||||
}
|
||||
@@ -82,15 +205,17 @@ function cross(o: Coordinate, a: Coordinate, b: Coordinate): number {
|
||||
|
||||
// convex hull
|
||||
export function getOuterMarkers(coordinates: Coordinate[]): Coordinate[] {
|
||||
const sorted = coordinates.sort(sortCoordinates);
|
||||
const sorted = [...coordinates].sort(sortCoordinates);
|
||||
|
||||
if (sorted.length <= 3) return sorted;
|
||||
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
|
||||
cross(lower.at(-2)!, lower.at(-1)!, coord) <= 0
|
||||
) {
|
||||
lower.pop();
|
||||
}
|
||||
@@ -101,7 +226,7 @@ export function getOuterMarkers(coordinates: Coordinate[]): 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
|
||||
cross(upper.at(-2)!, upper.at(-1)!, sorted[i]!) <= 0
|
||||
) {
|
||||
upper.pop();
|
||||
}
|
||||
@@ -133,7 +258,7 @@ export function calculateCentroid(polygon: Coordinate[]): Coordinate {
|
||||
centroidLat += (y0 + y1) * a;
|
||||
}
|
||||
|
||||
area = area / 2;
|
||||
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.');
|
||||
@@ -146,7 +271,7 @@ export function calculateCentroid(polygon: Coordinate[]): Coordinate {
|
||||
}
|
||||
|
||||
export function calculateGeographicMidpoint(
|
||||
coordinate: Coordinate[],
|
||||
coordinate: Coordinate[]
|
||||
): Coordinate {
|
||||
let minLat = Number.POSITIVE_INFINITY;
|
||||
let maxLat = Number.NEGATIVE_INFINITY;
|
||||
@@ -154,10 +279,18 @@ export function calculateGeographicMidpoint(
|
||||
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;
|
||||
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
|
||||
@@ -191,9 +324,10 @@ export function clusterCoordinates(
|
||||
maxLong: number;
|
||||
};
|
||||
};
|
||||
} = {},
|
||||
} = {}
|
||||
) {
|
||||
const { zoom = 1, adaptiveRadius = true, viewport } = options;
|
||||
const detailLevel = getClusterDetailLevel(zoom);
|
||||
|
||||
// Calculate adaptive radius based on zoom level and coordinate density
|
||||
let adjustedRadius = radius;
|
||||
@@ -214,7 +348,7 @@ export function clusterCoordinates(
|
||||
coord.lat >= viewport.bounds.minLat &&
|
||||
coord.lat <= viewport.bounds.maxLat &&
|
||||
coord.long >= viewport.bounds.minLong &&
|
||||
coord.long <= viewport.bounds.maxLong,
|
||||
coord.long <= viewport.bounds.maxLong
|
||||
);
|
||||
|
||||
if (viewportCoords.length > 0) {
|
||||
@@ -227,7 +361,7 @@ export function clusterCoordinates(
|
||||
// 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),
|
||||
Math.min(5, Math.sqrt(density * 1000) + 1)
|
||||
);
|
||||
adjustedRadius *= densityFactor;
|
||||
}
|
||||
@@ -241,44 +375,44 @@ export function clusterCoordinates(
|
||||
// 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`,
|
||||
);
|
||||
if (detailLevel === 'coordinate') {
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
return regroupClustersByDetail(result, detailLevel);
|
||||
}
|
||||
|
||||
// Aggressive clustering algorithm with iterative expansion
|
||||
function basicClusterCoordinates(coordinates: Coordinate[], radius: number) {
|
||||
if (coordinates.length === 0) return [];
|
||||
if (coordinates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const clusters: {
|
||||
center: Coordinate;
|
||||
count: number;
|
||||
members: Coordinate[];
|
||||
}[] = [];
|
||||
const clusters: CoordinateCluster[] = [];
|
||||
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,
|
||||
(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 }) => {
|
||||
({ lat, long, city, country, count, originalIdx }) => {
|
||||
if (!visited.has(originalIdx)) {
|
||||
const initialCount = count ?? 1;
|
||||
const cluster = {
|
||||
members: [{ lat, long, city, country }],
|
||||
members: [{ lat, long, city, country, count: initialCount }],
|
||||
center: { lat, long },
|
||||
count: 1,
|
||||
count: initialCount,
|
||||
location: {
|
||||
city: normalizeLocationValue(city),
|
||||
country: normalizeLocationValue(country),
|
||||
},
|
||||
};
|
||||
|
||||
// Mark the initial coordinate as visited
|
||||
@@ -297,6 +431,7 @@ function basicClusterCoordinates(coordinates: Coordinate[], radius: number) {
|
||||
long: otherLong,
|
||||
city: otherCity,
|
||||
country: otherCountry,
|
||||
count: otherCount,
|
||||
originalIdx: otherIdx,
|
||||
}) => {
|
||||
if (!visited.has(otherIdx)) {
|
||||
@@ -306,28 +441,31 @@ function basicClusterCoordinates(coordinates: Coordinate[], radius: number) {
|
||||
});
|
||||
|
||||
if (distance <= radius) {
|
||||
const memberCount = otherCount ?? 1;
|
||||
cluster.members.push({
|
||||
lat: otherLat,
|
||||
long: otherLong,
|
||||
city: otherCity,
|
||||
country: otherCountry,
|
||||
count: memberCount,
|
||||
});
|
||||
visited.add(otherIdx);
|
||||
cluster.count++;
|
||||
cluster.count += memberCount;
|
||||
expandedInLastIteration = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the proper center for the cluster
|
||||
cluster.center = calculateClusterCenter(cluster.members);
|
||||
cluster.location = getLocationSummary(cluster.members);
|
||||
|
||||
clusters.push(cluster);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return clusters;
|
||||
@@ -339,9 +477,12 @@ function basicClusterCoordinates(coordinates: Coordinate[], radius: number) {
|
||||
// Utility function to get clustering statistics for debugging
|
||||
export function getClusteringStats(
|
||||
coordinates: Coordinate[],
|
||||
clusters: ReturnType<typeof clusterCoordinates>,
|
||||
clusters: ReturnType<typeof clusterCoordinates>
|
||||
) {
|
||||
const totalPoints = coordinates.length;
|
||||
const totalPoints = coordinates.reduce(
|
||||
(sum, coordinate) => sum + (coordinate.count ?? 1),
|
||||
0
|
||||
);
|
||||
const totalClusters = clusters.length;
|
||||
const singletonClusters = clusters.filter((c) => c.count === 1).length;
|
||||
const avgClusterSize = totalPoints > 0 ? totalPoints / totalClusters : 0;
|
||||
@@ -371,26 +512,33 @@ function calculateClusterCenter(members: Coordinate[]): Coordinate {
|
||||
|
||||
let avgLat = 0;
|
||||
let avgLong = 0;
|
||||
let totalWeight = 0;
|
||||
|
||||
if (maxLong - minLong > 180) {
|
||||
// Handle dateline crossing
|
||||
let adjustedLongSum = 0;
|
||||
for (const member of members) {
|
||||
avgLat += member.lat;
|
||||
const weight = member.count ?? 1;
|
||||
avgLat += member.lat * weight;
|
||||
const adjustedLong = member.long < 0 ? member.long + 360 : member.long;
|
||||
adjustedLongSum += adjustedLong;
|
||||
adjustedLongSum += adjustedLong * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
avgLat /= totalWeight;
|
||||
avgLong = (adjustedLongSum / totalWeight) % 360;
|
||||
if (avgLong > 180) {
|
||||
avgLong -= 360;
|
||||
}
|
||||
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;
|
||||
const weight = member.count ?? 1;
|
||||
avgLat += member.lat * weight;
|
||||
avgLong += member.long * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
avgLat /= members.length;
|
||||
avgLong /= members.length;
|
||||
avgLat /= totalWeight;
|
||||
avgLong /= totalWeight;
|
||||
}
|
||||
|
||||
return { lat: avgLat, long: avgLong };
|
||||
|
||||
@@ -1,350 +1,20 @@
|
||||
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 { useRef } from 'react';
|
||||
import { MapBadgeDetails } from './map-badge-details';
|
||||
import { MapCanvas } from './map-canvas';
|
||||
import type { RealtimeMapProps } from './map-types';
|
||||
|
||||
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]);
|
||||
const Map = ({ projectId, markers, sidebarConfig }: RealtimeMapProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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'}
|
||||
<div className="relative h-full w-full" ref={containerRef}>
|
||||
<MapCanvas
|
||||
markers={markers}
|
||||
projectId={projectId}
|
||||
sidebarConfig={sidebarConfig}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
<MapBadgeDetails containerRef={containerRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
267
apps/start/src/components/realtime/map/map-badge-detail-card.tsx
Normal file
267
apps/start/src/components/realtime/map/map-badge-detail-card.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import type { RefObject } from 'react';
|
||||
import type { DisplayMarker } from './map-types';
|
||||
import {
|
||||
getBadgeOverlayPosition,
|
||||
getProfileDisplayName,
|
||||
getUniqueCoordinateDetailLocations,
|
||||
getUniquePlaceDetailLocations,
|
||||
} from './map-utils';
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
export function MapBadgeDetailCard({
|
||||
marker,
|
||||
onClose,
|
||||
panelRef,
|
||||
projectId,
|
||||
size,
|
||||
}: {
|
||||
marker: DisplayMarker;
|
||||
onClose: () => void;
|
||||
panelRef: RefObject<HTMLDivElement | null>;
|
||||
projectId: string;
|
||||
size: { width: number; height: number };
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
const input = {
|
||||
detailScope: marker.detailScope,
|
||||
projectId,
|
||||
locations:
|
||||
marker.detailScope === 'coordinate'
|
||||
? getUniqueCoordinateDetailLocations(marker.members)
|
||||
: getUniquePlaceDetailLocations(marker.members),
|
||||
};
|
||||
const query = useQuery(
|
||||
trpc.realtime.mapBadgeDetails.queryOptions(input, {
|
||||
enabled: input.locations.length > 0,
|
||||
})
|
||||
);
|
||||
const position = getBadgeOverlayPosition(marker, size);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="absolute z-[90]"
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
ref={panelRef}
|
||||
style={{
|
||||
left: position.left,
|
||||
top: position.top,
|
||||
width: position.overlayWidth,
|
||||
}}
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="overflow-hidden rounded-2xl border border-white/10 bg-background shadow-2xl"
|
||||
initial={{ opacity: 0.98 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Realtime cluster
|
||||
</div>
|
||||
<div className="truncate text-lg" style={{ fontWeight: 600 }}>
|
||||
{marker.label}
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 text-muted-foreground"
|
||||
style={{ fontSize: 13 }}
|
||||
>
|
||||
{query.data?.summary.totalSessions ?? marker.count} sessions
|
||||
{query.data?.summary.totalProfiles
|
||||
? ` • ${query.data.summary.totalProfiles} profiles`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b p-4 text-sm">
|
||||
<div className="col gap-1 rounded-lg bg-def-200 p-3">
|
||||
<div className="text-muted-foreground text-xs">Locations</div>
|
||||
<div className="font-semibold">
|
||||
{query.data?.summary.totalLocations ?? marker.members.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col gap-1 rounded-lg bg-def-200 p-3">
|
||||
<div className="text-muted-foreground text-xs">Countries</div>
|
||||
<div className="font-semibold">
|
||||
{query.data?.summary.totalCountries ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col gap-1 rounded-lg bg-def-200 p-3">
|
||||
<div className="text-muted-foreground text-xs">Cities</div>
|
||||
<div className="font-semibold">
|
||||
{query.data?.summary.totalCities ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[420px] space-y-4 overflow-y-auto p-4">
|
||||
{query.isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="h-16 animate-pulse rounded-xl bg-def-200" />
|
||||
<div className="h-24 animate-pulse rounded-xl bg-def-200" />
|
||||
<div className="h-24 animate-pulse rounded-xl bg-def-200" />
|
||||
</div>
|
||||
) : query.data ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-2 font-medium text-sm">Top referrers</div>
|
||||
<div className="space-y-2">
|
||||
{query.data.topReferrers.length > 0 ? (
|
||||
query.data.topReferrers.map((item) => (
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
key={item.referrerName || '(not set)'}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={item.referrerName} />
|
||||
<span className="truncate">
|
||||
{item.referrerName
|
||||
.replaceAll('https://', '')
|
||||
.replaceAll('http://', '')
|
||||
.replaceAll('www.', '') || '(Not set)'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono">{item.count}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No data
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-2 font-medium text-sm">Top events</div>
|
||||
<div className="space-y-2">
|
||||
{query.data.topEvents.length > 0 ? (
|
||||
query.data.topEvents.map((item) => (
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
key={item.name}
|
||||
>
|
||||
<span className="truncate">{item.name}</span>
|
||||
<span className="font-mono">{item.count}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No data
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 rounded-xl border p-3">
|
||||
<div className="mb-2 font-medium text-sm">Top paths</div>
|
||||
<div className="space-y-2">
|
||||
{query.data.topPaths.length > 0 ? (
|
||||
query.data.topPaths.map((item) => (
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
key={`${item.origin}${item.path}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{item.path || '(Not set)'}
|
||||
</span>
|
||||
<span className="font-mono">{item.count}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No data
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-3 font-medium text-sm">Recent sessions</div>
|
||||
<div className="space-y-3">
|
||||
{query.data.recentProfiles.length > 0 ? (
|
||||
query.data.recentProfiles.map((profile) => {
|
||||
const href = profile.profileId
|
||||
? `/profiles/${encodeURIComponent(profile.profileId)}`
|
||||
: `/sessions/${encodeURIComponent(profile.sessionId)}`;
|
||||
return (
|
||||
<ProjectLink
|
||||
className="-mx-1 flex items-center gap-3 rounded-lg px-1 py-0.5 transition-colors hover:bg-def-200"
|
||||
href={href}
|
||||
key={
|
||||
profile.profileId
|
||||
? `p:${profile.profileId}`
|
||||
: `s:${profile.sessionId}`
|
||||
}
|
||||
>
|
||||
<ProfileAvatar
|
||||
avatar={profile.avatar}
|
||||
email={profile.email}
|
||||
firstName={profile.firstName}
|
||||
lastName={profile.lastName}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate"
|
||||
style={{ fontSize: 14, fontWeight: 500 }}
|
||||
>
|
||||
{getProfileDisplayName(profile)}
|
||||
</div>
|
||||
<div
|
||||
className="truncate text-muted-foreground"
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{profile.latestPath || profile.latestEvent}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-right text-muted-foreground"
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
<div>
|
||||
{[profile.city, profile.country]
|
||||
.filter(Boolean)
|
||||
.join(', ') || 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
</ProjectLink>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No recent sessions
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Could not load badge details.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
92
apps/start/src/components/realtime/map/map-badge-details.tsx
Normal file
92
apps/start/src/components/realtime/map/map-badge-details.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { bind } from 'bind-event-listener';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MapBadgeDetailCard } from './map-badge-detail-card';
|
||||
import { closeMapBadgeDetails } from './realtime-map-badge-slice';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
|
||||
export function MapBadgeDetails({
|
||||
containerRef,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const { open, marker, projectId } = useSelector(
|
||||
(state) => state.realtimeMapBadge
|
||||
);
|
||||
const [overlaySize, setOverlaySize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(open && marker)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!panelRef.current?.contains(event.target as Node)) {
|
||||
dispatch(closeMapBadgeDetails());
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
dispatch(closeMapBadgeDetails());
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mousedown', onPointerDown);
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', onPointerDown);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [dispatch, marker, open]);
|
||||
|
||||
useEffect(() => {
|
||||
const measure = () => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOverlaySize({ width: rect.width, height: rect.height });
|
||||
};
|
||||
|
||||
measure();
|
||||
|
||||
return bind(window, {
|
||||
type: 'resize',
|
||||
listener: measure,
|
||||
});
|
||||
}, [containerRef]);
|
||||
|
||||
if (!(open && marker && projectId && overlaySize)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.button
|
||||
animate={{ opacity: 1 }}
|
||||
className="fixed inset-0 z-[80] bg-black/10"
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
key="map-badge-backdrop"
|
||||
onClick={() => dispatch(closeMapBadgeDetails())}
|
||||
type="button"
|
||||
/>
|
||||
<MapBadgeDetailCard
|
||||
key="map-badge-panel"
|
||||
marker={marker}
|
||||
onClose={() => dispatch(closeMapBadgeDetails())}
|
||||
panelRef={panelRef}
|
||||
projectId={projectId}
|
||||
size={overlaySize}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
314
apps/start/src/components/realtime/map/map-canvas.tsx
Normal file
314
apps/start/src/components/realtime/map/map-canvas.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { bind } from 'bind-event-listener';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ComposableMap,
|
||||
Geographies,
|
||||
Geography,
|
||||
Marker,
|
||||
ZoomableGroup,
|
||||
} from 'react-simple-maps';
|
||||
import {
|
||||
calculateGeographicMidpoint,
|
||||
clusterCoordinates,
|
||||
getAverageCenter,
|
||||
getOuterMarkers,
|
||||
} from './coordinates';
|
||||
import { determineZoom, GEO_MAP_URL, getBoundingBox } from './map.helpers';
|
||||
import { createDisplayMarkers } from './map-display-markers';
|
||||
import { MapMarkerPill } from './map-marker-pill';
|
||||
import type {
|
||||
DisplayMarkerCache,
|
||||
GeographyFeature,
|
||||
MapCanvasProps,
|
||||
MapProjection,
|
||||
ZoomMoveEndPosition,
|
||||
ZoomMovePosition,
|
||||
} from './map-types';
|
||||
import {
|
||||
ANCHOR_R,
|
||||
isValidCoordinate,
|
||||
PILL_GAP,
|
||||
PILL_H,
|
||||
PILL_W,
|
||||
} from './map-utils';
|
||||
import {
|
||||
closeMapBadgeDetails,
|
||||
openMapBadgeDetails,
|
||||
} from './realtime-map-badge-slice';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { useDispatch } from '@/redux';
|
||||
|
||||
export const MapCanvas = memo(function MapCanvas({
|
||||
projectId,
|
||||
markers,
|
||||
sidebarConfig,
|
||||
}: MapCanvasProps) {
|
||||
const dispatch = useDispatch();
|
||||
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 [viewCenter, setViewCenter] = useState<[number, number]>([0, 20]);
|
||||
const zoomTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const displayMarkersCacheRef = useRef<DisplayMarkerCache>({
|
||||
markers: [],
|
||||
projection: null,
|
||||
viewportCenter: [0, 20],
|
||||
zoom: 1,
|
||||
size: null,
|
||||
result: [],
|
||||
});
|
||||
|
||||
const { center, initialZoom } = useMemo(() => {
|
||||
const hull = getOuterMarkers(markers);
|
||||
const center =
|
||||
hull.length < 2
|
||||
? getAverageCenter(markers)
|
||||
: calculateGeographicMidpoint(hull);
|
||||
|
||||
const boundingBox = getBoundingBox(hull.length > 0 ? hull : markers);
|
||||
const aspectRatio = size ? size.width / size.height : 1;
|
||||
const autoZoom = Math.max(
|
||||
1,
|
||||
Math.min(20, determineZoom(boundingBox, aspectRatio) * 0.4)
|
||||
);
|
||||
const initialZoom = markers.length > 0 ? autoZoom : 1;
|
||||
|
||||
return { center, initialZoom };
|
||||
}, [markers, size]);
|
||||
|
||||
const updateDebouncedZoom = useCallback((newZoom: number) => {
|
||||
if (zoomTimeoutRef.current) {
|
||||
clearTimeout(zoomTimeoutRef.current);
|
||||
}
|
||||
zoomTimeoutRef.current = setTimeout(() => {
|
||||
setDebouncedZoom(newZoom);
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (zoomTimeoutRef.current) {
|
||||
clearTimeout(zoomTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { long, lat } = useMemo(() => {
|
||||
let adjustedLong = center.long;
|
||||
if (sidebarConfig && size) {
|
||||
const sidebarOffset =
|
||||
sidebarConfig.position === 'left'
|
||||
? sidebarConfig.width / 2
|
||||
: -sidebarConfig.width / 2;
|
||||
const longitudePerPixel = 360 / (size.width * initialZoom);
|
||||
const longitudeOffset = sidebarOffset * longitudePerPixel;
|
||||
adjustedLong = center.long - longitudeOffset;
|
||||
}
|
||||
return { long: adjustedLong, lat: center.lat };
|
||||
}, [center.long, center.lat, sidebarConfig, size, initialZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
setViewCenter([long, lat]);
|
||||
setCurrentZoom(initialZoom);
|
||||
setDebouncedZoom(initialZoom);
|
||||
}, [long, lat, initialZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
return bind(window, {
|
||||
type: 'resize',
|
||||
listener() {
|
||||
if (ref.current) {
|
||||
const parentRect = ref.current.getBoundingClientRect();
|
||||
setSize({
|
||||
width: parentRect.width ?? 0,
|
||||
height: parentRect.height ?? 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const parentRect = ref.current.getBoundingClientRect();
|
||||
setSize({
|
||||
width: parentRect.width ?? 0,
|
||||
height: parentRect.height ?? 0,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const clusteredMarkers = useMemo(() => {
|
||||
return clusterCoordinates(markers, 150, {
|
||||
zoom: debouncedZoom,
|
||||
adaptiveRadius: true,
|
||||
});
|
||||
}, [markers, debouncedZoom]);
|
||||
|
||||
const invScale = Number.isNaN(1 / currentZoom) ? 1 : 1 / currentZoom;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full" ref={ref}>
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/10 bg-gradient-to-t from-def-100 to-transparent" />
|
||||
{size !== null && (
|
||||
<ComposableMap
|
||||
height={size.height}
|
||||
projection="geoMercator"
|
||||
width={size.width}
|
||||
>
|
||||
<ZoomableGroup
|
||||
center={[long, lat]}
|
||||
// key={`${long}-${lat}-${initialZoom}`}
|
||||
maxZoom={20}
|
||||
minZoom={1}
|
||||
onMove={(position: ZoomMovePosition) => {
|
||||
dispatch(closeMapBadgeDetails());
|
||||
if (currentZoom !== position.zoom) {
|
||||
setCurrentZoom(position.zoom);
|
||||
updateDebouncedZoom(position.zoom);
|
||||
}
|
||||
}}
|
||||
onMoveEnd={(position: ZoomMoveEndPosition) => {
|
||||
setViewCenter(position.coordinates);
|
||||
|
||||
if (currentZoom !== position.zoom) {
|
||||
setCurrentZoom(position.zoom);
|
||||
updateDebouncedZoom(position.zoom);
|
||||
}
|
||||
}}
|
||||
zoom={initialZoom}
|
||||
>
|
||||
<Geographies geography={GEO_MAP_URL}>
|
||||
{({
|
||||
geographies,
|
||||
projection,
|
||||
}: {
|
||||
geographies: GeographyFeature[];
|
||||
projection: MapProjection;
|
||||
}) => {
|
||||
const cachedDisplayMarkers = displayMarkersCacheRef.current;
|
||||
const cacheMatches =
|
||||
cachedDisplayMarkers.markers === clusteredMarkers &&
|
||||
cachedDisplayMarkers.projection === projection &&
|
||||
cachedDisplayMarkers.viewportCenter[0] === viewCenter[0] &&
|
||||
cachedDisplayMarkers.viewportCenter[1] === viewCenter[1] &&
|
||||
cachedDisplayMarkers.zoom === debouncedZoom &&
|
||||
cachedDisplayMarkers.size?.width === size.width &&
|
||||
cachedDisplayMarkers.size?.height === size.height;
|
||||
|
||||
const displayMarkers = cacheMatches
|
||||
? cachedDisplayMarkers.result
|
||||
: createDisplayMarkers({
|
||||
markers: clusteredMarkers,
|
||||
projection,
|
||||
viewportCenter: viewCenter,
|
||||
zoom: debouncedZoom,
|
||||
labelZoom: debouncedZoom,
|
||||
size,
|
||||
});
|
||||
|
||||
if (!cacheMatches) {
|
||||
displayMarkersCacheRef.current = {
|
||||
markers: clusteredMarkers,
|
||||
projection,
|
||||
viewportCenter: viewCenter,
|
||||
zoom: debouncedZoom,
|
||||
size,
|
||||
result: displayMarkers,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{geographies
|
||||
.filter(
|
||||
(geo: GeographyFeature) =>
|
||||
geo.properties.name !== 'Antarctica'
|
||||
)
|
||||
.map((geo: GeographyFeature) => (
|
||||
<Geography
|
||||
fill={theme.theme === 'dark' ? '#000' : '#f0f0f0'}
|
||||
geography={geo}
|
||||
key={geo.rsmKey}
|
||||
pointerEvents="none"
|
||||
stroke={theme.theme === 'dark' ? '#333' : '#999'}
|
||||
strokeWidth={0.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
|
||||
{markers.filter(isValidCoordinate).map((marker, index) => (
|
||||
<Marker
|
||||
coordinates={[marker.long, marker.lat]}
|
||||
key={`point-${index}-${marker.long}-${marker.lat}`}
|
||||
>
|
||||
<g transform={`scale(${invScale})`}>
|
||||
<circle
|
||||
fill="var(--primary)"
|
||||
fillOpacity={0.9}
|
||||
pointerEvents="none"
|
||||
r={ANCHOR_R}
|
||||
/>
|
||||
</g>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{displayMarkers.map((marker, index) => {
|
||||
const coordinates: [number, number] = [
|
||||
marker.center.long,
|
||||
marker.center.lat,
|
||||
];
|
||||
|
||||
return (
|
||||
<Marker
|
||||
coordinates={coordinates}
|
||||
key={`cluster-${index}-${marker.center.long}-${marker.center.lat}-${marker.mergedVisualClusters}`}
|
||||
>
|
||||
<g transform={`scale(${invScale})`}>
|
||||
<foreignObject
|
||||
height={PILL_H}
|
||||
overflow="visible"
|
||||
width={PILL_W}
|
||||
x={-PILL_W / 2}
|
||||
y={-(PILL_H + ANCHOR_R + PILL_GAP)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<MapMarkerPill
|
||||
marker={marker}
|
||||
onClick={() => {
|
||||
dispatch(
|
||||
openMapBadgeDetails({
|
||||
marker,
|
||||
projectId,
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Geographies>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
309
apps/start/src/components/realtime/map/map-display-markers.ts
Normal file
309
apps/start/src/components/realtime/map/map-display-markers.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import type { Coordinate, CoordinateCluster } from './coordinates';
|
||||
import {
|
||||
getAverageCenter,
|
||||
getClusterDetailLevel,
|
||||
haversineDistance,
|
||||
} from './coordinates';
|
||||
import type {
|
||||
ContinentBucket,
|
||||
DisplayMarker,
|
||||
MapProjection,
|
||||
} from './map-types';
|
||||
import {
|
||||
ANCHOR_R,
|
||||
createDisplayLabel,
|
||||
createMergedDisplayLabel,
|
||||
getDetailQueryScope,
|
||||
getDisplayMarkerId,
|
||||
getMergedDetailQueryScope,
|
||||
getWeightedScreenPoint,
|
||||
isValidCoordinate,
|
||||
normalizeLocationValue,
|
||||
PILL_GAP,
|
||||
PILL_H,
|
||||
PILL_W,
|
||||
} from './map-utils';
|
||||
|
||||
function projectToScreen(
|
||||
projection: MapProjection,
|
||||
coordinate: Coordinate,
|
||||
viewportCenter: [number, number],
|
||||
zoom: number,
|
||||
size: { width: number; height: number }
|
||||
) {
|
||||
const projectedPoint = projection([coordinate.long, coordinate.lat]);
|
||||
const projectedCenter = projection(viewportCenter);
|
||||
|
||||
if (!(projectedPoint && projectedCenter)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: (projectedPoint[0] - projectedCenter[0]) * zoom + size.width / 2,
|
||||
y: (projectedPoint[1] - projectedCenter[1]) * zoom + size.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function isOffscreen(
|
||||
point: { x: number; y: number },
|
||||
size: { width: number; height: number }
|
||||
) {
|
||||
const margin = PILL_W;
|
||||
|
||||
return (
|
||||
point.x < -margin ||
|
||||
point.x > size.width + margin ||
|
||||
point.y < -margin ||
|
||||
point.y > size.height + margin
|
||||
);
|
||||
}
|
||||
|
||||
function doPillsOverlap(
|
||||
left: { x: number; y: number },
|
||||
right: { x: number; y: number },
|
||||
padding: number
|
||||
) {
|
||||
const leftBox = {
|
||||
left: left.x - PILL_W / 2 - padding,
|
||||
right: left.x + PILL_W / 2 + padding,
|
||||
top: left.y - (PILL_H + ANCHOR_R + PILL_GAP) - padding,
|
||||
};
|
||||
const rightBox = {
|
||||
left: right.x - PILL_W / 2 - padding,
|
||||
right: right.x + PILL_W / 2 + padding,
|
||||
top: right.y - (PILL_H + ANCHOR_R + PILL_GAP) - padding,
|
||||
};
|
||||
|
||||
const leftBottom = leftBox.top + PILL_H + padding * 2;
|
||||
const rightBottom = rightBox.top + PILL_H + padding * 2;
|
||||
|
||||
return !(
|
||||
leftBox.right < rightBox.left ||
|
||||
leftBox.left > rightBox.right ||
|
||||
leftBottom < rightBox.top ||
|
||||
leftBox.top > rightBottom
|
||||
);
|
||||
}
|
||||
|
||||
function getVisualMergePadding(zoom: number) {
|
||||
const detailLevel = getClusterDetailLevel(zoom);
|
||||
|
||||
if (detailLevel === 'country') {
|
||||
return 8;
|
||||
}
|
||||
|
||||
if (detailLevel === 'city') {
|
||||
return 4;
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
function getContinentBucket(coordinate: Coordinate): ContinentBucket {
|
||||
const { lat, long } = coordinate;
|
||||
|
||||
if (lat >= 15 && long >= -170 && long <= -20) {
|
||||
return 'north-america';
|
||||
}
|
||||
|
||||
if (lat < 15 && lat >= -60 && long >= -95 && long <= -30) {
|
||||
return 'south-america';
|
||||
}
|
||||
|
||||
if (lat >= 35 && long >= -25 && long <= 45) {
|
||||
return 'europe';
|
||||
}
|
||||
|
||||
if (lat >= -40 && lat <= 38 && long >= -20 && long <= 55) {
|
||||
return 'africa';
|
||||
}
|
||||
|
||||
if (lat >= -10 && long >= 110 && long <= 180) {
|
||||
return 'oceania';
|
||||
}
|
||||
|
||||
if (lat >= -10 && long >= 55 && long <= 180) {
|
||||
return 'asia';
|
||||
}
|
||||
|
||||
if (lat >= 0 && long >= 45 && long <= 180) {
|
||||
return 'asia';
|
||||
}
|
||||
|
||||
if (lat >= -10 && long >= 30 && long < 55) {
|
||||
return 'asia';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function getMaxVisualMergeDistanceKm(zoom: number) {
|
||||
const detailLevel = getClusterDetailLevel(zoom);
|
||||
|
||||
if (detailLevel === 'country') {
|
||||
return 2200;
|
||||
}
|
||||
|
||||
if (detailLevel === 'city') {
|
||||
return 900;
|
||||
}
|
||||
|
||||
return 500;
|
||||
}
|
||||
|
||||
function canVisuallyMergeMarkers(
|
||||
left: CoordinateCluster,
|
||||
right: CoordinateCluster,
|
||||
zoom: number
|
||||
) {
|
||||
const sameContinent =
|
||||
getContinentBucket(left.center) === getContinentBucket(right.center);
|
||||
|
||||
if (!sameContinent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
haversineDistance(left.center, right.center) <=
|
||||
getMaxVisualMergeDistanceKm(zoom)
|
||||
);
|
||||
}
|
||||
|
||||
export function createDisplayMarkers({
|
||||
markers,
|
||||
projection,
|
||||
viewportCenter,
|
||||
zoom,
|
||||
labelZoom,
|
||||
size,
|
||||
}: {
|
||||
markers: CoordinateCluster[];
|
||||
projection: MapProjection;
|
||||
viewportCenter: [number, number];
|
||||
zoom: number;
|
||||
labelZoom: number;
|
||||
size: { width: number; height: number };
|
||||
}): DisplayMarker[] {
|
||||
const positionedMarkers = markers
|
||||
.map((marker) => {
|
||||
if (!isValidCoordinate(marker.center)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const point = projectToScreen(
|
||||
projection,
|
||||
marker.center,
|
||||
viewportCenter,
|
||||
zoom,
|
||||
size
|
||||
);
|
||||
|
||||
if (!point || isOffscreen(point, size)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { marker, point };
|
||||
})
|
||||
.filter((entry) => entry !== null);
|
||||
|
||||
const entries = positionedMarkers.sort(
|
||||
(left, right) => right.marker.count - left.marker.count
|
||||
);
|
||||
const consumed = new Set<number>();
|
||||
const mergedMarkers: DisplayMarker[] = [];
|
||||
const overlapPadding = getVisualMergePadding(labelZoom);
|
||||
|
||||
for (let index = 0; index < entries.length; index++) {
|
||||
if (consumed.has(index)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queue = [index];
|
||||
const componentIndices: number[] = [];
|
||||
consumed.add(index);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentIndex = queue.shift()!;
|
||||
componentIndices.push(currentIndex);
|
||||
|
||||
for (
|
||||
let candidateIndex = currentIndex + 1;
|
||||
candidateIndex < entries.length;
|
||||
candidateIndex++
|
||||
) {
|
||||
if (consumed.has(candidateIndex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
doPillsOverlap(
|
||||
entries[currentIndex]!.point,
|
||||
entries[candidateIndex]!.point,
|
||||
overlapPadding
|
||||
) &&
|
||||
canVisuallyMergeMarkers(
|
||||
entries[currentIndex]!.marker,
|
||||
entries[candidateIndex]!.marker,
|
||||
labelZoom
|
||||
)
|
||||
) {
|
||||
consumed.add(candidateIndex);
|
||||
queue.push(candidateIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const componentEntries = componentIndices.map(
|
||||
(componentIndex) => entries[componentIndex]!
|
||||
);
|
||||
const componentMarkers = componentEntries.map((entry) => entry.marker);
|
||||
|
||||
if (componentMarkers.length === 1) {
|
||||
const marker = componentMarkers[0]!;
|
||||
mergedMarkers.push({
|
||||
...marker,
|
||||
detailScope: getDetailQueryScope(marker, labelZoom),
|
||||
id: getDisplayMarkerId(marker.members),
|
||||
label: createDisplayLabel(marker, labelZoom),
|
||||
mergedVisualClusters: 1,
|
||||
screenPoint: entries[index]!.point,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const members = componentMarkers.flatMap((marker) => marker.members);
|
||||
const center = getAverageCenter(members);
|
||||
const representativeCountry = normalizeLocationValue(
|
||||
componentMarkers[0]?.location.country
|
||||
);
|
||||
const representativeCity = normalizeLocationValue(
|
||||
componentMarkers[0]?.location.city
|
||||
);
|
||||
|
||||
const mergedMarker: CoordinateCluster = {
|
||||
center,
|
||||
count: componentMarkers.reduce((sum, marker) => sum + marker.count, 0),
|
||||
members,
|
||||
location: {
|
||||
city: representativeCity,
|
||||
country: representativeCountry,
|
||||
},
|
||||
};
|
||||
|
||||
mergedMarkers.push({
|
||||
...mergedMarker,
|
||||
detailScope: getMergedDetailQueryScope(labelZoom),
|
||||
id: getDisplayMarkerId(mergedMarker.members),
|
||||
label: createMergedDisplayLabel(mergedMarker, labelZoom),
|
||||
mergedVisualClusters: componentMarkers.length,
|
||||
screenPoint: getWeightedScreenPoint(
|
||||
componentEntries.map((entry) => ({
|
||||
count: entry.marker.count,
|
||||
screenPoint: entry.point,
|
||||
}))
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return mergedMarkers;
|
||||
}
|
||||
35
apps/start/src/components/realtime/map/map-marker-pill.tsx
Normal file
35
apps/start/src/components/realtime/map/map-marker-pill.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { DisplayMarker } from './map-types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function MapMarkerPill({
|
||||
marker,
|
||||
onClick,
|
||||
}: {
|
||||
marker: DisplayMarker;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex select-none items-center gap-1.5 whitespace-nowrap rounded-lg border border-border/10 bg-background px-[10px] py-[5px] font-medium text-[11px] text-foreground shadow-[0_4px_16px] shadow-background/20',
|
||||
onClick ? 'cursor-pointer' : 'cursor-default'
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="relative flex size-[7px] shrink-0">
|
||||
<span className="absolute inset-0 animate-ping rounded-full bg-emerald-300 opacity-75" />
|
||||
<span className="relative inline-flex size-[7px] rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
|
||||
<span className="tabular-nums">{marker.count.toLocaleString()}</span>
|
||||
|
||||
{marker.label ? (
|
||||
<>
|
||||
<span className="h-4 w-px shrink-0 bg-foreground/20" />
|
||||
<span className="max-w-[110px] truncate">{marker.label}</span>
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
55
apps/start/src/components/realtime/map/map-types.ts
Normal file
55
apps/start/src/components/realtime/map/map-types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Coordinate, CoordinateCluster } from './coordinates';
|
||||
import type { MapBadgeDisplayMarker } from './realtime-map-badge-slice';
|
||||
|
||||
export type DisplayMarker = MapBadgeDisplayMarker;
|
||||
|
||||
export type ContinentBucket =
|
||||
| 'north-america'
|
||||
| 'south-america'
|
||||
| 'europe'
|
||||
| 'africa'
|
||||
| 'asia'
|
||||
| 'oceania'
|
||||
| 'unknown';
|
||||
|
||||
export type MapProjection = (
|
||||
point: [number, number]
|
||||
) => [number, number] | null;
|
||||
|
||||
export interface ZoomMovePosition {
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export interface ZoomMoveEndPosition {
|
||||
coordinates: [number, number];
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export interface GeographyFeature {
|
||||
rsmKey: string;
|
||||
properties: {
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DisplayMarkerCache {
|
||||
markers: CoordinateCluster[];
|
||||
projection: MapProjection | null;
|
||||
viewportCenter: [number, number];
|
||||
zoom: number;
|
||||
size: { width: number; height: number } | null;
|
||||
result: DisplayMarker[];
|
||||
}
|
||||
|
||||
export interface MapSidebarConfig {
|
||||
width: number;
|
||||
position: 'left' | 'right';
|
||||
}
|
||||
|
||||
export interface RealtimeMapProps {
|
||||
projectId: string;
|
||||
markers: Coordinate[];
|
||||
sidebarConfig?: MapSidebarConfig;
|
||||
}
|
||||
|
||||
export interface MapCanvasProps extends RealtimeMapProps {}
|
||||
298
apps/start/src/components/realtime/map/map-utils.ts
Normal file
298
apps/start/src/components/realtime/map/map-utils.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import type { Coordinate, CoordinateCluster } from './coordinates';
|
||||
import { getClusterDetailLevel } from './coordinates';
|
||||
import type { DisplayMarker } from './map-types';
|
||||
|
||||
export const PILL_W = 220;
|
||||
export const PILL_H = 32;
|
||||
export const ANCHOR_R = 3;
|
||||
export const PILL_GAP = 6;
|
||||
|
||||
const COUNTRY_CODE_PATTERN = /^[A-Z]{2}$/;
|
||||
|
||||
const regionDisplayNames =
|
||||
typeof Intl !== 'undefined'
|
||||
? new Intl.DisplayNames(['en'], { type: 'region' })
|
||||
: null;
|
||||
|
||||
export function normalizeLocationValue(value?: string) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function isValidCoordinate(coordinate: Coordinate) {
|
||||
return Number.isFinite(coordinate.lat) && Number.isFinite(coordinate.long);
|
||||
}
|
||||
|
||||
export function getCoordinateIdentity(coordinate: Coordinate) {
|
||||
return [
|
||||
normalizeLocationValue(coordinate.country) ?? '',
|
||||
normalizeLocationValue(coordinate.city) ?? '',
|
||||
isValidCoordinate(coordinate) ? coordinate.long.toFixed(4) : 'invalid-long',
|
||||
isValidCoordinate(coordinate) ? coordinate.lat.toFixed(4) : 'invalid-lat',
|
||||
].join(':');
|
||||
}
|
||||
|
||||
export function getDisplayMarkerId(members: Coordinate[]) {
|
||||
const validMembers = members.filter(isValidCoordinate);
|
||||
|
||||
if (validMembers.length === 0) {
|
||||
return 'invalid-cluster';
|
||||
}
|
||||
|
||||
return validMembers.map(getCoordinateIdentity).sort().join('|');
|
||||
}
|
||||
|
||||
export function getWeightedScreenPoint(
|
||||
markers: Array<{
|
||||
count: number;
|
||||
screenPoint: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}>
|
||||
) {
|
||||
let weightedX = 0;
|
||||
let weightedY = 0;
|
||||
let totalWeight = 0;
|
||||
|
||||
for (const marker of markers) {
|
||||
weightedX += marker.screenPoint.x * marker.count;
|
||||
weightedY += marker.screenPoint.y * marker.count;
|
||||
totalWeight += marker.count;
|
||||
}
|
||||
|
||||
return {
|
||||
x: weightedX / totalWeight,
|
||||
y: weightedY / totalWeight,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatCountryLabel(country?: string) {
|
||||
const normalized = normalizeLocationValue(country);
|
||||
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!COUNTRY_CODE_PATTERN.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return regionDisplayNames?.of(normalized) ?? normalized;
|
||||
}
|
||||
|
||||
export function summarizeLocation(members: Coordinate[]) {
|
||||
const cities = new Set<string>();
|
||||
const countries = new Set<string>();
|
||||
|
||||
for (const member of members) {
|
||||
const city = normalizeLocationValue(member.city);
|
||||
const country = normalizeLocationValue(member.country);
|
||||
|
||||
if (city) {
|
||||
cities.add(city);
|
||||
}
|
||||
|
||||
if (country) {
|
||||
countries.add(country);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cityCount: cities.size,
|
||||
countryCount: countries.size,
|
||||
firstCity: [...cities][0],
|
||||
firstCountry: [...countries][0],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDisplayLabel(
|
||||
marker: CoordinateCluster,
|
||||
zoom: number
|
||||
): string {
|
||||
const detailLevel = getClusterDetailLevel(zoom);
|
||||
|
||||
if (detailLevel === 'country') {
|
||||
return (
|
||||
formatCountryLabel(marker.location.country) ?? marker.location.city ?? '?'
|
||||
);
|
||||
}
|
||||
|
||||
if (detailLevel === 'city') {
|
||||
return (
|
||||
marker.location.city ?? formatCountryLabel(marker.location.country) ?? '?'
|
||||
);
|
||||
}
|
||||
|
||||
const cityMember = marker.members.find((member) => member.city?.trim());
|
||||
return (
|
||||
cityMember?.city?.trim() ??
|
||||
formatCountryLabel(marker.location.country) ??
|
||||
'?'
|
||||
);
|
||||
}
|
||||
|
||||
export function getDetailQueryScope(
|
||||
marker: CoordinateCluster,
|
||||
zoom: number
|
||||
): DisplayMarker['detailScope'] {
|
||||
const detailLevel = getClusterDetailLevel(zoom);
|
||||
|
||||
if (detailLevel === 'country') {
|
||||
return 'country';
|
||||
}
|
||||
|
||||
if (detailLevel === 'city') {
|
||||
return marker.location.city ? 'city' : 'country';
|
||||
}
|
||||
|
||||
return 'coordinate';
|
||||
}
|
||||
|
||||
export function getMergedDetailQueryScope(
|
||||
zoom: number
|
||||
): DisplayMarker['detailScope'] {
|
||||
const detailLevel = getClusterDetailLevel(zoom);
|
||||
|
||||
return detailLevel === 'country' ? 'country' : 'city';
|
||||
}
|
||||
|
||||
export function createMergedDisplayLabel(
|
||||
marker: CoordinateCluster,
|
||||
zoom: number
|
||||
): string {
|
||||
const detailLevel = getClusterDetailLevel(zoom);
|
||||
const summary = summarizeLocation(marker.members);
|
||||
|
||||
if (detailLevel === 'country') {
|
||||
if (summary.countryCount <= 1) {
|
||||
return (
|
||||
formatCountryLabel(summary.firstCountry) ?? summary.firstCity ?? '?'
|
||||
);
|
||||
}
|
||||
|
||||
return `${summary.countryCount} countries`;
|
||||
}
|
||||
|
||||
if (detailLevel === 'city') {
|
||||
if (summary.cityCount === 1 && summary.firstCity) {
|
||||
return summary.firstCity;
|
||||
}
|
||||
|
||||
if (summary.countryCount === 1) {
|
||||
const country = formatCountryLabel(summary.firstCountry);
|
||||
|
||||
if (country && summary.cityCount > 1) {
|
||||
return `${country}, ${summary.cityCount} cities`;
|
||||
}
|
||||
|
||||
return country ?? `${summary.cityCount} places`;
|
||||
}
|
||||
|
||||
if (summary.countryCount > 1) {
|
||||
return `${summary.countryCount} countries`;
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.cityCount === 1 && summary.firstCity) {
|
||||
return summary.firstCity;
|
||||
}
|
||||
|
||||
if (summary.countryCount === 1) {
|
||||
const country = formatCountryLabel(summary.firstCountry);
|
||||
|
||||
if (country && summary.cityCount > 1) {
|
||||
return `${country}, ${summary.cityCount} places`;
|
||||
}
|
||||
|
||||
return country ?? `${marker.members.length} places`;
|
||||
}
|
||||
|
||||
return `${Math.max(summary.countryCount, summary.cityCount, 2)} places`;
|
||||
}
|
||||
|
||||
export function getBadgeOverlayPosition(
|
||||
marker: DisplayMarker,
|
||||
size: { width: number; height: number }
|
||||
) {
|
||||
const overlayWidth = Math.min(380, size.width - 24);
|
||||
const preferredLeft = marker.screenPoint.x - overlayWidth / 2;
|
||||
const left = Math.max(
|
||||
12,
|
||||
Math.min(preferredLeft, size.width - overlayWidth - 12)
|
||||
);
|
||||
const top = Math.max(
|
||||
12,
|
||||
Math.min(marker.screenPoint.y + 16, size.height - 340)
|
||||
);
|
||||
|
||||
return { left, overlayWidth, top };
|
||||
}
|
||||
|
||||
export function getProfileDisplayName(profile: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
id: string;
|
||||
}) {
|
||||
const name = [profile.firstName, profile.lastName].filter(Boolean).join(' ');
|
||||
return name || profile.email || profile.id;
|
||||
}
|
||||
|
||||
export function getUniqueCoordinateDetailLocations(members: Coordinate[]) {
|
||||
const locationsByKey: Record<
|
||||
string,
|
||||
{
|
||||
city?: string;
|
||||
country?: string;
|
||||
lat: number;
|
||||
long: number;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const member of members) {
|
||||
if (!isValidCoordinate(member)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = [
|
||||
normalizeLocationValue(member.country) ?? '',
|
||||
normalizeLocationValue(member.city) ?? '',
|
||||
member.long.toFixed(4),
|
||||
member.lat.toFixed(4),
|
||||
].join(':');
|
||||
|
||||
locationsByKey[key] = {
|
||||
city: member.city,
|
||||
country: member.country,
|
||||
lat: member.lat,
|
||||
long: member.long,
|
||||
};
|
||||
}
|
||||
|
||||
return Object.values(locationsByKey);
|
||||
}
|
||||
|
||||
export function getUniquePlaceDetailLocations(members: Coordinate[]) {
|
||||
const locationsByKey: Record<
|
||||
string,
|
||||
{
|
||||
city?: string;
|
||||
country?: string;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const member of members) {
|
||||
const key = [
|
||||
normalizeLocationValue(member.country) ?? '',
|
||||
normalizeLocationValue(member.city) ?? '',
|
||||
].join(':');
|
||||
|
||||
locationsByKey[key] = {
|
||||
city: member.city,
|
||||
country: member.country,
|
||||
};
|
||||
}
|
||||
|
||||
return Object.values(locationsByKey);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useZoomPan } from 'react-simple-maps';
|
||||
|
||||
import type { Coordinate } from './coordinates';
|
||||
|
||||
export const GEO_MAP_URL =
|
||||
@@ -49,7 +48,7 @@ export const getBoundingBox = (coordinates: Coordinate[]) => {
|
||||
|
||||
export const determineZoom = (
|
||||
bbox: ReturnType<typeof getBoundingBox>,
|
||||
aspectRatio = 1.0,
|
||||
aspectRatio = 1.0
|
||||
): number => {
|
||||
const latDiff = bbox.maxLat - bbox.minLat;
|
||||
const longDiff = bbox.maxLong - bbox.minLong;
|
||||
@@ -80,7 +79,7 @@ export function CustomZoomableGroup({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { mapRef, transformString } = useZoomPan({
|
||||
center: center,
|
||||
center,
|
||||
zoom,
|
||||
filterZoomEvent: () => false,
|
||||
});
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { CoordinateCluster } from './coordinates';
|
||||
|
||||
/** Serializable marker payload for the realtime map badge detail panel */
|
||||
export interface MapBadgeDisplayMarker extends CoordinateCluster {
|
||||
detailScope: 'city' | 'coordinate' | 'country' | 'merged';
|
||||
id: string;
|
||||
label: string;
|
||||
mergedVisualClusters: number;
|
||||
screenPoint: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface RealtimeMapBadgeState {
|
||||
open: boolean;
|
||||
marker: MapBadgeDisplayMarker | null;
|
||||
projectId: string | null;
|
||||
}
|
||||
|
||||
const initialState: RealtimeMapBadgeState = {
|
||||
open: false,
|
||||
marker: null,
|
||||
projectId: null,
|
||||
};
|
||||
|
||||
const realtimeMapBadgeSlice = createSlice({
|
||||
name: 'realtimeMapBadge',
|
||||
initialState,
|
||||
reducers: {
|
||||
openMapBadgeDetails(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
marker: MapBadgeDisplayMarker;
|
||||
projectId: string;
|
||||
}>
|
||||
) {
|
||||
state.open = true;
|
||||
state.marker = action.payload.marker;
|
||||
state.projectId = action.payload.projectId;
|
||||
},
|
||||
closeMapBadgeDetails(state) {
|
||||
if (!state.open) {
|
||||
return;
|
||||
}
|
||||
state.open = false;
|
||||
state.marker = null;
|
||||
state.projectId = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { openMapBadgeDetails, closeMapBadgeDetails } =
|
||||
realtimeMapBadgeSlice.actions;
|
||||
|
||||
export default realtimeMapBadgeSlice.reducer;
|
||||
@@ -3,16 +3,19 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ProjectLink } from '../links';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { formatTimeAgoOrDateTime } from '@/utils/date';
|
||||
|
||||
interface RealtimeActiveSessionsProps {
|
||||
projectId: string;
|
||||
limit?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RealtimeActiveSessions({
|
||||
projectId,
|
||||
limit = 10,
|
||||
className,
|
||||
}: RealtimeActiveSessionsProps) {
|
||||
const trpc = useTRPC();
|
||||
const { data: sessions = [] } = useQuery(
|
||||
@@ -23,7 +26,7 @@ export function RealtimeActiveSessions({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="col card h-full max-md:hidden">
|
||||
<div className={cn('col card h-full', className)}>
|
||||
<div className="hide-scrollbar h-full overflow-y-auto">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
<div className="col divide-y">
|
||||
@@ -45,7 +48,7 @@ export function RealtimeActiveSessions({
|
||||
{session.origin}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-medium text-sm leading-normal">
|
||||
<span className="truncate font-medium text-sm leading-normal">
|
||||
{session.name === 'screen_view'
|
||||
? session.path
|
||||
: session.name}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import * as Portal from '@radix-ui/react-portal';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import throttle from 'lodash.throttle';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
@@ -17,6 +13,9 @@ import {
|
||||
} from 'recharts';
|
||||
import { AnimatedNumber } from '../animated-number';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
interface RealtimeLiveHistogramProps {
|
||||
projectId: string;
|
||||
@@ -26,10 +25,11 @@ export function RealtimeLiveHistogram({
|
||||
projectId,
|
||||
}: RealtimeLiveHistogramProps) {
|
||||
const trpc = useTRPC();
|
||||
const number = useNumber();
|
||||
|
||||
// Use the same liveData endpoint as overview
|
||||
const { data: liveData, isLoading } = useQuery(
|
||||
trpc.overview.liveData.queryOptions({ projectId }),
|
||||
trpc.overview.liveData.queryOptions({ projectId })
|
||||
);
|
||||
|
||||
const chartData = liveData?.minuteCounts ?? [];
|
||||
@@ -40,7 +40,7 @@ export function RealtimeLiveHistogram({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Wrapper count={0}>
|
||||
<div className="h-full w-full animate-pulse bg-def-200 rounded" />
|
||||
<div className="h-full w-full animate-pulse rounded bg-def-200" />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -55,23 +55,23 @@ export function RealtimeLiveHistogram({
|
||||
return (
|
||||
<Wrapper
|
||||
count={totalVisitors}
|
||||
icons={
|
||||
liveData.referrers && liveData.referrers.length > 0 ? (
|
||||
<div className="row gap-2 shrink-0">
|
||||
{liveData.referrers.slice(0, 3).map((ref, index) => (
|
||||
<div
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
className="font-bold text-xs row gap-1 items-center"
|
||||
// icons={
|
||||
// liveData.referrers && liveData.referrers.length > 0 ? (
|
||||
// <div className="row shrink-0 gap-2">
|
||||
// {liveData.referrers.slice(0, 3).map((ref, index) => (
|
||||
// <div
|
||||
// className="row items-center gap-1 font-bold text-xs"
|
||||
// key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
// >
|
||||
// <SerieIcon name={ref.referrer} />
|
||||
// <span>{number.short(ref.count)}</span>
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// ) : null
|
||||
// }
|
||||
>
|
||||
<SerieIcon name={ref.referrer} />
|
||||
<span>{ref.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
@@ -82,11 +82,11 @@ export function RealtimeLiveHistogram({
|
||||
fill: 'var(--def-200)',
|
||||
}}
|
||||
/>
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} hide />
|
||||
<YAxis hide domain={[0, maxDomain]} />
|
||||
<XAxis axisLine={false} dataKey="time" hide tickLine={false} />
|
||||
<YAxis domain={[0, maxDomain]} hide />
|
||||
<Bar
|
||||
dataKey="visitorCount"
|
||||
className="fill-chart-0"
|
||||
dataKey="visitorCount"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
@@ -104,19 +104,18 @@ interface WrapperProps {
|
||||
function Wrapper({ children, count, icons }: WrapperProps) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="row gap-2 justify-between mb-2">
|
||||
<div className="relative text-sm font-medium text-muted-foreground leading-normal">
|
||||
Unique visitors {icons ? <br /> : null}
|
||||
last 30 min
|
||||
<div className="row justify-between gap-2">
|
||||
<div className="relative font-medium text-muted-foreground text-sm leading-normal">
|
||||
Unique visitors last 30 min
|
||||
</div>
|
||||
<div>{icons}</div>
|
||||
</div>
|
||||
<div className="col gap-2 mb-4">
|
||||
<div className="font-mono text-6xl font-bold">
|
||||
<div className="col -mt-1 gap-2">
|
||||
<div className="font-bold font-mono text-6xl">
|
||||
<AnimatedNumber value={count} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative aspect-[6/1] w-full">{children}</div>
|
||||
<div className="relative -mt-2 aspect-[6/1] w-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -125,10 +124,10 @@ function Wrapper({ children, count, icons }: WrapperProps) {
|
||||
const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
const number = useNumber();
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
const inactive = !active || !payload?.length;
|
||||
const inactive = !(active && payload?.length);
|
||||
useEffect(() => {
|
||||
const setPositionThrottled = throttle(setPosition, 50);
|
||||
const unsubMouseMove = bind(window, {
|
||||
@@ -156,7 +155,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!active || !payload || !payload.length) {
|
||||
if (!(active && payload && payload.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -179,6 +178,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
|
||||
return (
|
||||
<Portal.Portal
|
||||
className="rounded-md border bg-background/80 p-3 shadow-xl backdrop-blur-sm"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position?.y,
|
||||
@@ -186,7 +186,6 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
zIndex: 1000,
|
||||
width: tooltipWidth,
|
||||
}}
|
||||
className="bg-background/80 p-3 rounded-md border shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
<div>{data.time}</div>
|
||||
@@ -199,7 +198,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">Active users</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data.visitorCount)}
|
||||
</div>
|
||||
@@ -207,18 +206,18 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
</div>
|
||||
</div>
|
||||
{data.referrers && data.referrers.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border">
|
||||
<div className="text-xs text-muted-foreground mb-2">Referrers:</div>
|
||||
<div className="mt-2 border-border border-t pt-2">
|
||||
<div className="mb-2 text-muted-foreground text-xs">Referrers:</div>
|
||||
<div className="space-y-1">
|
||||
{data.referrers.slice(0, 3).map((ref: any, index: number) => (
|
||||
<div
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
className="row items-center justify-between text-xs"
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
>
|
||||
<div className="row items-center gap-1">
|
||||
<SerieIcon name={ref.referrer} />
|
||||
<span
|
||||
className="truncate max-w-[120px]"
|
||||
className="max-w-[120px] truncate"
|
||||
title={ref.referrer}
|
||||
>
|
||||
{ref.referrer}
|
||||
@@ -228,7 +227,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
</div>
|
||||
))}
|
||||
{data.referrers.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
+{data.referrers.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,17 +14,11 @@ const RealtimeReloader = ({ projectId }: Props) => {
|
||||
`/live/events/${projectId}`,
|
||||
() => {
|
||||
if (!document.hidden) {
|
||||
// pathFilter() covers all realtime.* queries for this project
|
||||
client.refetchQueries(trpc.realtime.pathFilter());
|
||||
client.refetchQueries(
|
||||
trpc.overview.liveData.queryFilter({ projectId }),
|
||||
);
|
||||
client.refetchQueries(
|
||||
trpc.realtime.activeSessions.queryFilter({ projectId }),
|
||||
);
|
||||
client.refetchQueries(
|
||||
trpc.realtime.referrals.queryFilter({ projectId }),
|
||||
);
|
||||
client.refetchQueries(trpc.realtime.paths.queryFilter({ projectId }));
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import reportSlice from '@/components/report/reportSlice';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import type { TypedUseSelectorHook } from 'react-redux';
|
||||
import {
|
||||
useDispatch as useBaseDispatch,
|
||||
useSelector as useBaseSelector,
|
||||
} from 'react-redux';
|
||||
import type { TypedUseSelectorHook } from 'react-redux';
|
||||
import realtimeMapBadgeReducer from '@/components/realtime/map/realtime-map-badge-slice';
|
||||
import reportSlice from '@/components/report/reportSlice';
|
||||
|
||||
const makeStore = () =>
|
||||
configureStore({
|
||||
reducer: {
|
||||
report: reportSlice,
|
||||
realtimeMapBadge: realtimeMapBadgeReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -41,15 +41,45 @@ function Component() {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Fullscreen>
|
||||
<FullscreenClose />
|
||||
<RealtimeReloader projectId={projectId} />
|
||||
|
||||
<div className="row relative">
|
||||
<div className="aspect-[4/2] w-full overflow-hidden">
|
||||
<div className="flex flex-col gap-4 p-4 md:hidden">
|
||||
<div className="card bg-background/90 p-4">
|
||||
<RealtimeLiveHistogram projectId={projectId} />
|
||||
</div>
|
||||
|
||||
<div className="-mx-4 aspect-square">
|
||||
<RealtimeMap
|
||||
markers={coordinatesQuery.data ?? []}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[320px]">
|
||||
<RealtimeActiveSessions projectId={projectId} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<RealtimeGeo projectId={projectId} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<RealtimeReferrals projectId={projectId} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<RealtimePaths projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<div className="row relative">
|
||||
<div className="aspect-[4/2] w-full">
|
||||
<RealtimeMap
|
||||
markers={coordinatesQuery.data ?? []}
|
||||
projectId={projectId}
|
||||
sidebarConfig={{
|
||||
width: 280, // w-96 = 384px
|
||||
position: 'left',
|
||||
@@ -61,7 +91,10 @@ function Component() {
|
||||
<RealtimeLiveHistogram projectId={projectId} />
|
||||
</div>
|
||||
<div className="relative min-h-0 w-72 flex-1">
|
||||
<RealtimeActiveSessions projectId={projectId} />
|
||||
<RealtimeActiveSessions
|
||||
className="max-md:hidden"
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +110,7 @@ function Component() {
|
||||
<RealtimePaths projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Fullscreen>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
|
||||
@@ -73,18 +73,20 @@ export class Query<T = any> {
|
||||
};
|
||||
private _transform?: Record<string, (item: T) => any>;
|
||||
private _union?: Query;
|
||||
private _dateRegex = /\d{4}-\d{2}-\d{2}([\s\:\d\.]+)?/g;
|
||||
private _dateRegex = /\d{4}-\d{2}-\d{2}([\s:\d.]+)?/g;
|
||||
constructor(
|
||||
private client: ClickHouseClient,
|
||||
private timezone: string,
|
||||
private timezone: string
|
||||
) {}
|
||||
|
||||
// Select methods
|
||||
select<U>(
|
||||
columns: (string | Expression | null | undefined | false)[],
|
||||
type: 'merge' | 'replace' = 'replace',
|
||||
type: 'merge' | 'replace' = 'replace'
|
||||
): Query<U> {
|
||||
if (this._skipNext) return this as unknown as Query<U>;
|
||||
if (this._skipNext) {
|
||||
return this as unknown as Query<U>;
|
||||
}
|
||||
if (type === 'merge') {
|
||||
this._select = [
|
||||
...this._select,
|
||||
@@ -92,7 +94,7 @@ export class Query<T = any> {
|
||||
];
|
||||
} else {
|
||||
this._select = columns.filter((col): col is string | Expression =>
|
||||
Boolean(col),
|
||||
Boolean(col)
|
||||
);
|
||||
}
|
||||
return this as unknown as Query<U>;
|
||||
@@ -122,8 +124,12 @@ export class Query<T = any> {
|
||||
|
||||
// Where methods
|
||||
private escapeValue(value: SqlParam): string {
|
||||
if (value === null) return 'NULL';
|
||||
if (value instanceof Expression) return `(${value.toString()})`;
|
||||
if (value === null) {
|
||||
return 'NULL';
|
||||
}
|
||||
if (value instanceof Expression) {
|
||||
return `(${value.toString()})`;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `(${value.map((v) => this.escapeValue(v)).join(', ')})`;
|
||||
}
|
||||
@@ -139,7 +145,9 @@ export class Query<T = any> {
|
||||
}
|
||||
|
||||
where(column: string, operator: Operator, value?: SqlParam): this {
|
||||
if (this._skipNext) return this;
|
||||
if (this._skipNext) {
|
||||
return this;
|
||||
}
|
||||
const condition = this.buildCondition(column, operator, value);
|
||||
this._where.push({ condition, operator: 'AND' });
|
||||
return this;
|
||||
@@ -148,7 +156,7 @@ export class Query<T = any> {
|
||||
public buildCondition(
|
||||
column: string,
|
||||
operator: Operator,
|
||||
value?: SqlParam,
|
||||
value?: SqlParam
|
||||
): string {
|
||||
switch (operator) {
|
||||
case 'IS NULL':
|
||||
@@ -162,7 +170,7 @@ export class Query<T = any> {
|
||||
throw new Error('BETWEEN operator requires an array of two values');
|
||||
case 'IN':
|
||||
case 'NOT IN':
|
||||
if (!Array.isArray(value) && !(value instanceof Expression)) {
|
||||
if (!(Array.isArray(value) || value instanceof Expression)) {
|
||||
throw new Error(`${operator} operator requires an array value`);
|
||||
}
|
||||
return `${column} ${operator} ${this.escapeValue(value)}`;
|
||||
@@ -224,7 +232,9 @@ export class Query<T = any> {
|
||||
|
||||
// Order by methods
|
||||
orderBy(column: string, direction: 'ASC' | 'DESC' = 'ASC'): this {
|
||||
if (this._skipNext) return this;
|
||||
if (this._skipNext) {
|
||||
return this;
|
||||
}
|
||||
this._orderBy.push({ column, direction });
|
||||
return this;
|
||||
}
|
||||
@@ -259,7 +269,7 @@ export class Query<T = any> {
|
||||
fill(
|
||||
from: string | Date | Expression,
|
||||
to: string | Date | Expression,
|
||||
step: string | Expression,
|
||||
step: string | Expression
|
||||
): this {
|
||||
this._fill = {
|
||||
from:
|
||||
@@ -288,7 +298,7 @@ export class Query<T = any> {
|
||||
innerJoin(
|
||||
table: string | Expression,
|
||||
condition: string,
|
||||
alias?: string,
|
||||
alias?: string
|
||||
): this {
|
||||
return this.joinWithType('INNER', table, condition, alias);
|
||||
}
|
||||
@@ -296,7 +306,7 @@ export class Query<T = any> {
|
||||
leftJoin(
|
||||
table: string | Expression | Query,
|
||||
condition: string,
|
||||
alias?: string,
|
||||
alias?: string
|
||||
): this {
|
||||
return this.joinWithType('LEFT', table, condition, alias);
|
||||
}
|
||||
@@ -304,7 +314,7 @@ export class Query<T = any> {
|
||||
leftAnyJoin(
|
||||
table: string | Expression | Query,
|
||||
condition: string,
|
||||
alias?: string,
|
||||
alias?: string
|
||||
): this {
|
||||
return this.joinWithType('LEFT ANY', table, condition, alias);
|
||||
}
|
||||
@@ -312,7 +322,7 @@ export class Query<T = any> {
|
||||
rightJoin(
|
||||
table: string | Expression,
|
||||
condition: string,
|
||||
alias?: string,
|
||||
alias?: string
|
||||
): this {
|
||||
return this.joinWithType('RIGHT', table, condition, alias);
|
||||
}
|
||||
@@ -320,7 +330,7 @@ export class Query<T = any> {
|
||||
fullJoin(
|
||||
table: string | Expression,
|
||||
condition: string,
|
||||
alias?: string,
|
||||
alias?: string
|
||||
): this {
|
||||
return this.joinWithType('FULL', table, condition, alias);
|
||||
}
|
||||
@@ -333,9 +343,11 @@ export class Query<T = any> {
|
||||
type: JoinType,
|
||||
table: string | Expression | Query,
|
||||
condition: string,
|
||||
alias?: string,
|
||||
alias?: string
|
||||
): this {
|
||||
if (this._skipNext) return this;
|
||||
if (this._skipNext) {
|
||||
return this;
|
||||
}
|
||||
this._joins.push({
|
||||
type,
|
||||
table,
|
||||
@@ -386,9 +398,9 @@ export class Query<T = any> {
|
||||
// on them, otherwise any embedded date strings get double-escaped
|
||||
// (e.g. ''2025-12-16 23:59:59'') which ClickHouse rejects.
|
||||
.map((col) =>
|
||||
col instanceof Expression ? col.toString() : this.escapeDate(col),
|
||||
col instanceof Expression ? col.toString() : this.escapeDate(col)
|
||||
)
|
||||
.join(', '),
|
||||
.join(', ')
|
||||
);
|
||||
} else {
|
||||
parts.push('SELECT *');
|
||||
@@ -411,7 +423,7 @@ export class Query<T = any> {
|
||||
const aliasClause = join.alias ? ` ${join.alias} ` : ' ';
|
||||
const conditionStr = join.condition ? `ON ${join.condition}` : '';
|
||||
parts.push(
|
||||
`${join.type} JOIN ${join.table instanceof Query ? `(${join.table.toSQL()})` : join.table instanceof Expression ? `(${join.table.toString()})` : join.table}${aliasClause}${conditionStr}`,
|
||||
`${join.type} JOIN ${join.table instanceof Query ? `(${join.table.toSQL()})` : join.table instanceof Expression ? `(${join.table.toString()})` : join.table}${aliasClause}${conditionStr}`
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -524,10 +536,10 @@ export class Query<T = any> {
|
||||
// Execution methods
|
||||
async execute(): Promise<T[]> {
|
||||
const query = this.buildQuery();
|
||||
console.log(
|
||||
'query',
|
||||
`${query.replaceAll('\n', ' ').replaceAll('\t', ' ').replaceAll('\r', ' ')} SETTINGS session_timezone = '${this.timezone}'`,
|
||||
);
|
||||
// console.log(
|
||||
// 'query',
|
||||
// `${query.replaceAll('\n', ' ').replaceAll('\t', ' ').replaceAll('\r', ' ')} SETTINGS session_timezone = '${this.timezone}'`,
|
||||
// );
|
||||
|
||||
const result = await this.client.query({
|
||||
query,
|
||||
@@ -574,7 +586,9 @@ export class Query<T = any> {
|
||||
|
||||
// Add merge method
|
||||
merge(query: Query): this {
|
||||
if (this._skipNext) return this;
|
||||
if (this._skipNext) {
|
||||
return this;
|
||||
}
|
||||
|
||||
this._from = query._from;
|
||||
|
||||
@@ -621,7 +635,7 @@ export class WhereGroupBuilder {
|
||||
|
||||
constructor(
|
||||
private query: Query,
|
||||
private groupOperator: 'AND' | 'OR',
|
||||
private groupOperator: 'AND' | 'OR'
|
||||
) {}
|
||||
|
||||
where(column: string, operator: Operator, value?: SqlParam): this {
|
||||
@@ -706,7 +720,7 @@ clix.toStartOf = (node: string, interval: IInterval, timezone?: string) => {
|
||||
clix.toStartOfInterval = (
|
||||
node: string,
|
||||
interval: IInterval,
|
||||
origin: string | Date,
|
||||
origin: string | Date
|
||||
) => {
|
||||
switch (interval) {
|
||||
case 'minute': {
|
||||
|
||||
@@ -2,7 +2,9 @@ import {
|
||||
ch,
|
||||
chQuery,
|
||||
clix,
|
||||
convertClickhouseDateToJs,
|
||||
formatClickhouseDate,
|
||||
getProfiles,
|
||||
type IClickhouseEvent,
|
||||
TABLE_NAMES,
|
||||
transformEvent,
|
||||
@@ -12,6 +14,98 @@ import sqlstring from 'sqlstring';
|
||||
import { z } from 'zod';
|
||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||
|
||||
const realtimeLocationSchema = z.object({
|
||||
country: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
lat: z.number().optional(),
|
||||
long: z.number().optional(),
|
||||
});
|
||||
|
||||
const realtimeBadgeDetailScopeSchema = z.enum([
|
||||
'country',
|
||||
'city',
|
||||
'coordinate',
|
||||
'merged',
|
||||
]);
|
||||
|
||||
function buildRealtimeLocationFilter(
|
||||
locations: z.infer<typeof realtimeLocationSchema>[]
|
||||
) {
|
||||
const tuples = locations
|
||||
.filter(
|
||||
(
|
||||
location
|
||||
): location is z.infer<typeof realtimeLocationSchema> & {
|
||||
lat: number;
|
||||
long: number;
|
||||
} => typeof location.lat === 'number' && typeof location.long === 'number'
|
||||
)
|
||||
.map(
|
||||
(location) =>
|
||||
`(${sqlstring.escape(location.country ?? '')}, ${sqlstring.escape(
|
||||
location.city ?? ''
|
||||
)}, toDecimal64(${location.long.toFixed(4)}, 4), toDecimal64(${location.lat.toFixed(4)}, 4))`
|
||||
);
|
||||
|
||||
if (tuples.length === 0) {
|
||||
return buildRealtimeCityFilter(locations);
|
||||
}
|
||||
|
||||
return `(coalesce(country, ''), coalesce(city, ''), toDecimal64(longitude, 4), toDecimal64(latitude, 4)) IN (${tuples.join(', ')})`;
|
||||
}
|
||||
|
||||
function buildRealtimeCountryFilter(
|
||||
locations: z.infer<typeof realtimeLocationSchema>[]
|
||||
) {
|
||||
const countries = [
|
||||
...new Set(locations.map((location) => location.country ?? '')),
|
||||
];
|
||||
|
||||
return `coalesce(country, '') IN (${countries
|
||||
.map((country) => sqlstring.escape(country))
|
||||
.join(', ')})`;
|
||||
}
|
||||
|
||||
function buildRealtimeCityFilter(
|
||||
locations: z.infer<typeof realtimeLocationSchema>[]
|
||||
) {
|
||||
const tuples = [
|
||||
...new Set(
|
||||
locations.map(
|
||||
(location) =>
|
||||
`(${sqlstring.escape(location.country ?? '')}, ${sqlstring.escape(
|
||||
location.city ?? ''
|
||||
)})`
|
||||
)
|
||||
),
|
||||
];
|
||||
|
||||
if (tuples.length === 0) {
|
||||
return buildRealtimeCountryFilter(locations);
|
||||
}
|
||||
|
||||
return `(coalesce(country, ''), coalesce(city, '')) IN (${tuples.join(', ')})`;
|
||||
}
|
||||
|
||||
function buildRealtimeBadgeDetailsFilter(input: {
|
||||
detailScope: z.infer<typeof realtimeBadgeDetailScopeSchema>;
|
||||
locations: z.infer<typeof realtimeLocationSchema>[];
|
||||
}) {
|
||||
if (input.detailScope === 'country') {
|
||||
return buildRealtimeCountryFilter(input.locations);
|
||||
}
|
||||
|
||||
if (input.detailScope === 'city') {
|
||||
return buildRealtimeCityFilter(input.locations);
|
||||
}
|
||||
|
||||
if (input.detailScope === 'merged') {
|
||||
return buildRealtimeCityFilter(input.locations);
|
||||
}
|
||||
|
||||
return buildRealtimeLocationFilter(input.locations);
|
||||
}
|
||||
|
||||
export const realtimeRouter = createTRPCRouter({
|
||||
coordinates: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
@@ -21,12 +115,195 @@ export const realtimeRouter = createTRPCRouter({
|
||||
country: string;
|
||||
long: number;
|
||||
lat: number;
|
||||
count: number;
|
||||
}>(
|
||||
`SELECT DISTINCT country, city, longitude as long, latitude as lat FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(input.projectId)} AND created_at >= '${formatClickhouseDate(subMinutes(new Date(), 30))}' ORDER BY created_at DESC`
|
||||
`SELECT
|
||||
country,
|
||||
city,
|
||||
longitude as long,
|
||||
latitude as lat,
|
||||
COUNT(DISTINCT session_id) as count
|
||||
FROM ${TABLE_NAMES.events}
|
||||
WHERE project_id = ${sqlstring.escape(input.projectId)}
|
||||
AND created_at >= now() - INTERVAL 30 MINUTE
|
||||
AND longitude IS NOT NULL
|
||||
AND latitude IS NOT NULL
|
||||
GROUP BY country, city, longitude, latitude
|
||||
ORDER BY count DESC`
|
||||
);
|
||||
|
||||
res.forEach((item) => {
|
||||
console.log(item.country, item.city, item.long, item.lat);
|
||||
});
|
||||
return res;
|
||||
}),
|
||||
mapBadgeDetails: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
detailScope: realtimeBadgeDetailScopeSchema,
|
||||
projectId: z.string(),
|
||||
locations: z.array(realtimeLocationSchema).min(1).max(200),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const since = formatClickhouseDate(subMinutes(new Date(), 30));
|
||||
const locationFilter = buildRealtimeBadgeDetailsFilter(input);
|
||||
|
||||
const summaryQuery = clix(ch)
|
||||
.select<{
|
||||
total_sessions: number;
|
||||
total_profiles: number;
|
||||
}>([
|
||||
'COUNT(DISTINCT session_id) as total_sessions',
|
||||
"COUNT(DISTINCT nullIf(profile_id, '')) as total_profiles",
|
||||
])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', input.projectId)
|
||||
.where('created_at', '>=', since)
|
||||
.rawWhere(locationFilter);
|
||||
|
||||
const topReferrersQuery = clix(ch)
|
||||
.select<{
|
||||
referrer_name: string;
|
||||
count: number;
|
||||
}>(['referrer_name', 'COUNT(DISTINCT session_id) as count'])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', input.projectId)
|
||||
.where('created_at', '>=', since)
|
||||
.where('referrer_name', '!=', '')
|
||||
.rawWhere(locationFilter)
|
||||
.groupBy(['referrer_name'])
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(3);
|
||||
|
||||
const topPathsQuery = clix(ch)
|
||||
.select<{
|
||||
origin: string;
|
||||
path: string;
|
||||
count: number;
|
||||
}>(['origin', 'path', 'COUNT(DISTINCT session_id) as count'])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', input.projectId)
|
||||
.where('created_at', '>=', since)
|
||||
.where('path', '!=', '')
|
||||
.rawWhere(locationFilter)
|
||||
.groupBy(['origin', 'path'])
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(3);
|
||||
|
||||
const topEventsQuery = clix(ch)
|
||||
.select<{
|
||||
name: string;
|
||||
count: number;
|
||||
}>(['name', 'COUNT(DISTINCT session_id) as count'])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', input.projectId)
|
||||
.where('created_at', '>=', since)
|
||||
.where('name', 'NOT IN', [
|
||||
'screen_view',
|
||||
'session_start',
|
||||
'session_end',
|
||||
])
|
||||
.rawWhere(locationFilter)
|
||||
.groupBy(['name'])
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(3);
|
||||
|
||||
const [summary, topReferrers, topPaths, topEvents, recentSessions] =
|
||||
await Promise.all([
|
||||
summaryQuery.execute(),
|
||||
topReferrersQuery.execute(),
|
||||
topPathsQuery.execute(),
|
||||
topEventsQuery.execute(),
|
||||
chQuery<{
|
||||
profile_id: string;
|
||||
session_id: string;
|
||||
created_at: string;
|
||||
path: string;
|
||||
name: string;
|
||||
country: string;
|
||||
city: string;
|
||||
}>(
|
||||
`SELECT
|
||||
session_id,
|
||||
profile_id,
|
||||
created_at,
|
||||
path,
|
||||
name,
|
||||
country,
|
||||
city
|
||||
FROM (
|
||||
SELECT
|
||||
session_id,
|
||||
profile_id,
|
||||
created_at,
|
||||
path,
|
||||
name,
|
||||
country,
|
||||
city,
|
||||
row_number() OVER (
|
||||
PARTITION BY session_id ORDER BY created_at DESC
|
||||
) AS rn
|
||||
FROM ${TABLE_NAMES.events}
|
||||
WHERE project_id = ${sqlstring.escape(input.projectId)}
|
||||
AND created_at >= ${sqlstring.escape(since)}
|
||||
AND (${locationFilter})
|
||||
) AS latest_event_per_session
|
||||
WHERE rn = 1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 8`
|
||||
),
|
||||
]);
|
||||
|
||||
const profiles = await getProfiles(
|
||||
recentSessions.map((item) => item.profile_id).filter(Boolean),
|
||||
input.projectId
|
||||
);
|
||||
const profileMap = new Map(
|
||||
profiles.map((profile) => [profile.id, profile])
|
||||
);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalSessions: summary[0]?.total_sessions ?? 0,
|
||||
totalProfiles: summary[0]?.total_profiles ?? 0,
|
||||
totalLocations: input.locations.length,
|
||||
totalCountries: new Set(
|
||||
input.locations.map((location) => location.country).filter(Boolean)
|
||||
).size,
|
||||
totalCities: new Set(
|
||||
input.locations.map((location) => location.city).filter(Boolean)
|
||||
).size,
|
||||
},
|
||||
topReferrers: topReferrers.map((item) => ({
|
||||
referrerName: item.referrer_name,
|
||||
count: item.count,
|
||||
})),
|
||||
topPaths,
|
||||
topEvents,
|
||||
recentProfiles: recentSessions.map((item) => {
|
||||
const profile = profileMap.get(item.profile_id);
|
||||
|
||||
return {
|
||||
id: item.profile_id || item.session_id,
|
||||
profileId:
|
||||
item.profile_id && item.profile_id !== ''
|
||||
? item.profile_id
|
||||
: null,
|
||||
sessionId: item.session_id,
|
||||
createdAt: convertClickhouseDateToJs(item.created_at),
|
||||
latestPath: item.path,
|
||||
latestEvent: item.name,
|
||||
city: profile?.properties.city || item.city,
|
||||
country: profile?.properties.country || item.country,
|
||||
firstName: profile?.firstName ?? '',
|
||||
lastName: profile?.lastName ?? '',
|
||||
email: profile?.email ?? '',
|
||||
avatar: profile?.avatar ?? '',
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
activeSessions: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
@@ -70,7 +347,7 @@ export const realtimeRouter = createTRPCRouter({
|
||||
)
|
||||
.groupBy(['path', 'origin'])
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(100)
|
||||
.limit(50)
|
||||
.execute();
|
||||
|
||||
return res;
|
||||
@@ -100,7 +377,7 @@ export const realtimeRouter = createTRPCRouter({
|
||||
)
|
||||
.groupBy(['referrer_name'])
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(100)
|
||||
.limit(50)
|
||||
.execute();
|
||||
|
||||
return res;
|
||||
@@ -131,7 +408,7 @@ export const realtimeRouter = createTRPCRouter({
|
||||
)
|
||||
.groupBy(['country', 'city'])
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(100)
|
||||
.limit(50)
|
||||
.execute();
|
||||
|
||||
return res;
|
||||
|
||||
Reference in New Issue
Block a user