From 88a2d876ce85bae6ac80462b3b5bc24944f9d3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Fri, 20 Mar 2026 09:52:29 +0100 Subject: [PATCH] fix: realtime improvements --- .../components/realtime/map/coordinates.ts | 250 +++++++++--- .../src/components/realtime/map/index.tsx | 356 +----------------- .../realtime/map/map-badge-detail-card.tsx | 267 +++++++++++++ .../realtime/map/map-badge-details.tsx | 92 +++++ .../components/realtime/map/map-canvas.tsx | 314 +++++++++++++++ .../realtime/map/map-display-markers.ts | 309 +++++++++++++++ .../realtime/map/map-marker-pill.tsx | 35 ++ .../src/components/realtime/map/map-types.ts | 55 +++ .../src/components/realtime/map/map-utils.ts | 298 +++++++++++++++ .../components/realtime/map/map.helpers.tsx | 5 +- .../src/components/realtime/map/markers.ts | 43 --- .../realtime/map/realtime-map-badge-slice.ts | 58 +++ .../realtime/realtime-active-sessions.tsx | 7 +- .../realtime/realtime-live-histogram.tsx | 85 +++-- .../components/realtime/realtime-reloader.tsx | 8 +- apps/start/src/redux/index.ts | 6 +- ...pp.$organizationId.$projectId.realtime.tsx | 49 ++- apps/start/tsconfig.json | 2 +- packages/db/src/clickhouse/query-builder.ts | 72 ++-- packages/trpc/src/routers/realtime.ts | 285 +++++++++++++- 20 files changed, 2060 insertions(+), 536 deletions(-) create mode 100644 apps/start/src/components/realtime/map/map-badge-detail-card.tsx create mode 100644 apps/start/src/components/realtime/map/map-badge-details.tsx create mode 100644 apps/start/src/components/realtime/map/map-canvas.tsx create mode 100644 apps/start/src/components/realtime/map/map-display-markers.ts create mode 100644 apps/start/src/components/realtime/map/map-marker-pill.tsx create mode 100644 apps/start/src/components/realtime/map/map-types.ts create mode 100644 apps/start/src/components/realtime/map/map-utils.ts delete mode 100644 apps/start/src/components/realtime/map/markers.ts create mode 100644 apps/start/src/components/realtime/map/realtime-map-badge-slice.ts diff --git a/apps/start/src/components/realtime/map/coordinates.ts b/apps/start/src/components/realtime/map/coordinates.ts index d103ca15..51baf147 100644 --- a/apps/start/src/components/realtime/map/coordinates.ts +++ b/apps/start/src/components/realtime/map/coordinates.ts @@ -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(); + const countryCounts = new Map(); + + 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) => + [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]; + + return { + city: getTopLocation(cityCounts), + country: getTopLocation(countryCounts), + }; +} + +function getAggregationKey( + member: Coordinate, + detailLevel: Exclude +) { + 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 +): CoordinateCluster[] { + const grouped = new Map(); + 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(); // 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, + clusters: ReturnType ) { - 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 }; diff --git a/apps/start/src/components/realtime/map/index.tsx b/apps/start/src/components/realtime/map/index.tsx index b1079575..7dc74d85 100644 --- a/apps/start/src/components/realtime/map/index.tsx +++ b/apps/start/src/components/realtime/map/index.tsx @@ -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(null); - const [size, setSize] = useState<{ width: number; height: number } | null>( - null, - ); - const [currentZoom, setCurrentZoom] = useState(1); - const [debouncedZoom, setDebouncedZoom] = useState(1); - const zoomTimeoutRef = useRef(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(null); return ( -
-
- {size === null ? ( - <> - ) : ( - <> - - { - if (currentZoom !== event.zoom) { - setCurrentZoom(event.zoom); - updateDebouncedZoom(event.zoom); - } - }} - > - - {({ geographies }) => - geographies - .filter((geo) => { - return geo.properties.name !== 'Antarctica'; - }) - .map((geo) => ( - - )) - } - - {showCenterMarker && ( - - - - )} - {clusteredMarkers.map((marker, index) => { - const size = getMarkerSize(calculateMarkerSize(marker.count)); - const coordinates: [number, number] = [ - marker.center.long, - marker.center.lat, - ]; +
+ - return ( - - {/* Animated ping effect */} - - - - {/* Main marker with tooltip */} - -

- {`${marker.count} visitor${marker.count !== 1 ? 's' : ''}`} -

- - {marker.members - .slice(0, 5) - .filter((item) => item.country || item.city) - .map((item) => ( -
- - {item.city || 'Unknown'} -
- ))} - {marker.members.length > 5 && ( -
- + {marker.members.length - 5} more -
- )} -
- } - > - - - - {marker.count} - - - - - ); - })} -
-
- - )} +
); }; diff --git a/apps/start/src/components/realtime/map/map-badge-detail-card.tsx b/apps/start/src/components/realtime/map/map-badge-detail-card.tsx new file mode 100644 index 00000000..937ac38c --- /dev/null +++ b/apps/start/src/components/realtime/map/map-badge-detail-card.tsx @@ -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; + 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 ( + event.stopPropagation()} + ref={panelRef} + style={{ + left: position.left, + top: position.top, + width: position.overlayWidth, + }} + transition={{ duration: 0.18 }} + > + +
+
+
+ Realtime cluster +
+
+ {marker.label} +
+
+ {query.data?.summary.totalSessions ?? marker.count} sessions + {query.data?.summary.totalProfiles + ? ` • ${query.data.summary.totalProfiles} profiles` + : ''} +
+
+ +
+ +
+
+
Locations
+
+ {query.data?.summary.totalLocations ?? marker.members.length} +
+
+
+
Countries
+
+ {query.data?.summary.totalCountries ?? 0} +
+
+
+
Cities
+
+ {query.data?.summary.totalCities ?? 0} +
+
+
+ +
+ {query.isLoading ? ( +
+
+
+
+
+ ) : query.data ? ( + <> +
+
+
Top referrers
+
+ {query.data.topReferrers.length > 0 ? ( + query.data.topReferrers.map((item) => ( +
+
+ + + {item.referrerName + .replaceAll('https://', '') + .replaceAll('http://', '') + .replaceAll('www.', '') || '(Not set)'} + +
+ {item.count} +
+ )) + ) : ( +
+ No data +
+ )} +
+
+ +
+
Top events
+
+ {query.data.topEvents.length > 0 ? ( + query.data.topEvents.map((item) => ( +
+ {item.name} + {item.count} +
+ )) + ) : ( +
+ No data +
+ )} +
+
+ +
+
Top paths
+
+ {query.data.topPaths.length > 0 ? ( + query.data.topPaths.map((item) => ( +
+ + {item.path || '(Not set)'} + + {item.count} +
+ )) + ) : ( +
+ No data +
+ )} +
+
+
+ +
+
Recent sessions
+
+ {query.data.recentProfiles.length > 0 ? ( + query.data.recentProfiles.map((profile) => { + const href = profile.profileId + ? `/profiles/${encodeURIComponent(profile.profileId)}` + : `/sessions/${encodeURIComponent(profile.sessionId)}`; + return ( + + +
+
+ {getProfileDisplayName(profile)} +
+
+ {profile.latestPath || profile.latestEvent} +
+
+
+
+ {[profile.city, profile.country] + .filter(Boolean) + .join(', ') || 'Unknown'} +
+
+
+ ); + }) + ) : ( +
+ No recent sessions +
+ )} +
+
+ + ) : ( +
+ Could not load badge details. +
+ )} +
+ + + ); +} diff --git a/apps/start/src/components/realtime/map/map-badge-details.tsx b/apps/start/src/components/realtime/map/map-badge-details.tsx new file mode 100644 index 00000000..81258310 --- /dev/null +++ b/apps/start/src/components/realtime/map/map-badge-details.tsx @@ -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; +}) { + const dispatch = useDispatch(); + const panelRef = useRef(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 ( + + dispatch(closeMapBadgeDetails())} + type="button" + /> + dispatch(closeMapBadgeDetails())} + panelRef={panelRef} + projectId={projectId} + size={overlaySize} + /> + + ); +} diff --git a/apps/start/src/components/realtime/map/map-canvas.tsx b/apps/start/src/components/realtime/map/map-canvas.tsx new file mode 100644 index 00000000..50657e49 --- /dev/null +++ b/apps/start/src/components/realtime/map/map-canvas.tsx @@ -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(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(null); + const displayMarkersCacheRef = useRef({ + 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 ( +
+
+ {size !== null && ( + + { + 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, + 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) => ( + + ))} + + {markers.filter(isValidCoordinate).map((marker, index) => ( + + + + + + ))} + + {displayMarkers.map((marker, index) => { + const coordinates: [number, number] = [ + marker.center.long, + marker.center.lat, + ]; + + return ( + + + +
+ { + dispatch( + openMapBadgeDetails({ + marker, + projectId, + }) + ); + }} + /> +
+
+
+
+ ); + })} + + ); + }} +
+
+
+ )} +
+ ); +}); diff --git a/apps/start/src/components/realtime/map/map-display-markers.ts b/apps/start/src/components/realtime/map/map-display-markers.ts new file mode 100644 index 00000000..18ba1762 --- /dev/null +++ b/apps/start/src/components/realtime/map/map-display-markers.ts @@ -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(); + 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; +} diff --git a/apps/start/src/components/realtime/map/map-marker-pill.tsx b/apps/start/src/components/realtime/map/map-marker-pill.tsx new file mode 100644 index 00000000..3c921d86 --- /dev/null +++ b/apps/start/src/components/realtime/map/map-marker-pill.tsx @@ -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 ( + + ); +} diff --git a/apps/start/src/components/realtime/map/map-types.ts b/apps/start/src/components/realtime/map/map-types.ts new file mode 100644 index 00000000..b78e68db --- /dev/null +++ b/apps/start/src/components/realtime/map/map-types.ts @@ -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 {} diff --git a/apps/start/src/components/realtime/map/map-utils.ts b/apps/start/src/components/realtime/map/map-utils.ts new file mode 100644 index 00000000..5b044e45 --- /dev/null +++ b/apps/start/src/components/realtime/map/map-utils.ts @@ -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(); + const countries = new Set(); + + 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); +} diff --git a/apps/start/src/components/realtime/map/map.helpers.tsx b/apps/start/src/components/realtime/map/map.helpers.tsx index 33c46f0e..da3c6ad4 100644 --- a/apps/start/src/components/realtime/map/map.helpers.tsx +++ b/apps/start/src/components/realtime/map/map.helpers.tsx @@ -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, - 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, }); diff --git a/apps/start/src/components/realtime/map/markers.ts b/apps/start/src/components/realtime/map/markers.ts deleted file mode 100644 index a52ec8ac..00000000 --- a/apps/start/src/components/realtime/map/markers.ts +++ /dev/null @@ -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)); -} diff --git a/apps/start/src/components/realtime/map/realtime-map-badge-slice.ts b/apps/start/src/components/realtime/map/realtime-map-badge-slice.ts new file mode 100644 index 00000000..102101e3 --- /dev/null +++ b/apps/start/src/components/realtime/map/realtime-map-badge-slice.ts @@ -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; diff --git a/apps/start/src/components/realtime/realtime-active-sessions.tsx b/apps/start/src/components/realtime/realtime-active-sessions.tsx index 797f1d2f..3ac51895 100644 --- a/apps/start/src/components/realtime/realtime-active-sessions.tsx +++ b/apps/start/src/components/realtime/realtime-active-sessions.tsx @@ -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 ( -
+
@@ -45,7 +48,7 @@ export function RealtimeActiveSessions({ {session.origin} )} - + {session.name === 'screen_view' ? session.path : session.name} diff --git a/apps/start/src/components/realtime/realtime-live-histogram.tsx b/apps/start/src/components/realtime/realtime-live-histogram.tsx index 8783e668..20c44600 100644 --- a/apps/start/src/components/realtime/realtime-live-histogram.tsx +++ b/apps/start/src/components/realtime/realtime-live-histogram.tsx @@ -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 ( -
+
); } @@ -55,23 +55,23 @@ export function RealtimeLiveHistogram({ return ( 0 ? ( -
- {liveData.referrers.slice(0, 3).map((ref, index) => ( -
- - {ref.count} -
- ))} -
- ) : null - } + // icons={ + // liveData.referrers && liveData.referrers.length > 0 ? ( + //
+ // {liveData.referrers.slice(0, 3).map((ref, index) => ( + //
+ // + // {number.short(ref.count)} + //
+ // ))} + //
+ // ) : null + // } > - + - - + + @@ -104,19 +104,18 @@ interface WrapperProps { function Wrapper({ children, count, icons }: WrapperProps) { return (
-
-
- Unique visitors {icons ?
: null} - last 30 min +
+
+ Unique visitors last 30 min
{icons}
-
-
+
+
-
{children}
+
{children}
); } @@ -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 ( { zIndex: 1000, width: tooltipWidth, }} - className="bg-background/80 p-3 rounded-md border shadow-xl backdrop-blur-sm" >
{data.time}
@@ -199,7 +198,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => { />
Active users
-
+
{number.formatWithUnit(data.visitorCount)}
@@ -207,18 +206,18 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
{data.referrers && data.referrers.length > 0 && ( -
-
Referrers:
+
+
Referrers:
{data.referrers.slice(0, 3).map((ref: any, index: number) => (
{ref.referrer} @@ -228,7 +227,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
))} {data.referrers.length > 3 && ( -
+
+{data.referrers.length - 3} more
)} diff --git a/apps/start/src/components/realtime/realtime-reloader.tsx b/apps/start/src/components/realtime/realtime-reloader.tsx index d4db49d4..564850d3 100644 --- a/apps/start/src/components/realtime/realtime-reloader.tsx +++ b/apps/start/src/components/realtime/realtime-reloader.tsx @@ -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 })); } }, { diff --git a/apps/start/src/redux/index.ts b/apps/start/src/redux/index.ts index 47aff75d..e55035cd 100644 --- a/apps/start/src/redux/index.ts +++ b/apps/start/src/redux/index.ts @@ -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, }, }); diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.realtime.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.realtime.tsx index 4fa81b4c..2d5df9d7 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.realtime.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.realtime.tsx @@ -41,15 +41,45 @@ function Component() { ); return ( - <> - - - + + + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
-
+
- +
@@ -77,7 +110,7 @@ function Component() {
- - +
+ ); } diff --git a/apps/start/tsconfig.json b/apps/start/tsconfig.json index a61bbee4..31516330 100644 --- a/apps/start/tsconfig.json +++ b/apps/start/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["**/*.ts", "**/*.tsx"], + "include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"], "exclude": ["node_modules", "dist"], "compilerOptions": { "target": "ES2022", diff --git a/packages/db/src/clickhouse/query-builder.ts b/packages/db/src/clickhouse/query-builder.ts index cdbb23da..c4dbf57f 100644 --- a/packages/db/src/clickhouse/query-builder.ts +++ b/packages/db/src/clickhouse/query-builder.ts @@ -73,18 +73,20 @@ export class Query { }; private _transform?: Record 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( columns: (string | Expression | null | undefined | false)[], - type: 'merge' | 'replace' = 'replace', + type: 'merge' | 'replace' = 'replace' ): Query { - if (this._skipNext) return this as unknown as Query; + if (this._skipNext) { + return this as unknown as Query; + } if (type === 'merge') { this._select = [ ...this._select, @@ -92,7 +94,7 @@ export class Query { ]; } else { this._select = columns.filter((col): col is string | Expression => - Boolean(col), + Boolean(col) ); } return this as unknown as Query; @@ -122,8 +124,12 @@ export class Query { // 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 { } 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 { public buildCondition( column: string, operator: Operator, - value?: SqlParam, + value?: SqlParam ): string { switch (operator) { case 'IS NULL': @@ -162,7 +170,7 @@ export class Query { 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 { // 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // 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 { 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 { // Execution methods async execute(): Promise { 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 { // 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': { diff --git a/packages/trpc/src/routers/realtime.ts b/packages/trpc/src/routers/realtime.ts index 8922f4d4..c6ff228d 100644 --- a/packages/trpc/src/routers/realtime.ts +++ b/packages/trpc/src/routers/realtime.ts @@ -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[] +) { + const tuples = locations + .filter( + ( + location + ): location is z.infer & { + 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[] +) { + 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[] +) { + 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; + locations: z.infer[]; +}) { + 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;