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