fix: realtime improvements

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-20 09:52:29 +01:00
parent d1b39c4c93
commit 88a2d876ce
20 changed files with 2060 additions and 536 deletions

View File

@@ -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 };

View File

@@ -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>
);
};

View 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>
);
}

View 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>
);
}

View 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>
);
});

View 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;
}

View 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>
);
}

View 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 {}

View 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);
}

View File

@@ -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,
});

View File

@@ -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));
}

View File

@@ -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;

View File

@@ -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}

View File

@@ -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>
)}

View File

@@ -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 }));
}
},
{

View File

@@ -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,
},
});

View File

@@ -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>
</>
);
}

View File

@@ -1,5 +1,5 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"target": "ES2022",

View File

@@ -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': {

View File

@@ -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;