feat: dashboard v2, esm, upgrades (#211)

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-16 12:27:44 +02:00
committed by GitHub
parent 436e81ecc9
commit 81a7e5d62e
741 changed files with 32695 additions and 16996 deletions

View File

@@ -0,0 +1,397 @@
export type Coordinate = {
lat: number;
long: number;
city?: string;
country?: string;
};
export function haversineDistance(
coord1: Coordinate,
coord2: Coordinate,
): number {
const R = 6371; // Earth's radius in kilometers
const lat1Rad = coord1.lat * (Math.PI / 180);
const lat2Rad = coord2.lat * (Math.PI / 180);
const deltaLatRad = (coord2.lat - coord1.lat) * (Math.PI / 180);
const deltaLonRad = (coord2.long - coord1.long) * (Math.PI / 180);
const a =
Math.sin(deltaLatRad / 2) * Math.sin(deltaLatRad / 2) +
Math.cos(lat1Rad) *
Math.cos(lat2Rad) *
Math.sin(deltaLonRad / 2) *
Math.sin(deltaLonRad / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in kilometers
}
export function findFarthestPoints(
coordinates: Coordinate[],
): [Coordinate, Coordinate] {
if (coordinates.length < 2) {
throw new Error('At least two coordinates are required');
}
let maxDistance = 0;
let point1: Coordinate = coordinates[0]!;
let point2: Coordinate = coordinates[1]!;
for (let i = 0; i < coordinates.length; i++) {
for (let j = i + 1; j < coordinates.length; j++) {
const distance = haversineDistance(coordinates[i]!, coordinates[j]!);
if (distance > maxDistance) {
maxDistance = distance;
point1 = coordinates[i]!;
point2 = coordinates[j]!;
}
}
}
return [point1, point2];
}
export function getAverageCenter(coordinates: Coordinate[]): Coordinate {
if (coordinates.length === 0) {
return { long: 0, lat: 20 };
}
let sumLong = 0;
let sumLat = 0;
for (const coord of coordinates) {
sumLong += coord.long;
sumLat += coord.lat;
}
const avgLat = sumLat / coordinates.length;
const avgLong = sumLong / coordinates.length;
return { long: avgLong, lat: avgLat };
}
function sortCoordinates(a: Coordinate, b: Coordinate): number {
return a.long === b.long ? a.lat - b.lat : a.long - b.long;
}
function cross(o: Coordinate, a: Coordinate, b: Coordinate): number {
return (
(a.long - o.long) * (b.lat - o.lat) - (a.lat - o.lat) * (b.long - o.long)
);
}
// convex hull
export function getOuterMarkers(coordinates: Coordinate[]): Coordinate[] {
const sorted = coordinates.sort(sortCoordinates);
if (sorted.length <= 3) return sorted;
const lower: Coordinate[] = [];
for (const coord of sorted) {
while (
lower.length >= 2 &&
cross(lower[lower.length - 2]!, lower[lower.length - 1]!, coord) <= 0
) {
lower.pop();
}
lower.push(coord);
}
const upper: Coordinate[] = [];
for (let i = coordinates.length - 1; i >= 0; i--) {
while (
upper.length >= 2 &&
cross(upper[upper.length - 2]!, upper[upper.length - 1]!, sorted[i]!) <= 0
) {
upper.pop();
}
upper.push(sorted[i]!);
}
upper.pop();
lower.pop();
return lower.concat(upper);
}
export function calculateCentroid(polygon: Coordinate[]): Coordinate {
if (polygon.length < 3) {
throw new Error('At least three points are required to form a polygon.');
}
let area = 0;
let centroidLat = 0;
let centroidLong = 0;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const x0 = polygon[j]!.long;
const y0 = polygon[j]!.lat;
const x1 = polygon[i]!.long;
const y1 = polygon[i]!.lat;
const a = x0 * y1 - x1 * y0;
area += a;
centroidLong += (x0 + x1) * a;
centroidLat += (y0 + y1) * a;
}
area = area / 2;
if (area === 0) {
// This should not happen for a proper convex hull
throw new Error('Area of the polygon is zero, check the coordinates.');
}
centroidLat /= 6 * area;
centroidLong /= 6 * area;
return { lat: centroidLat, long: centroidLong };
}
export function calculateGeographicMidpoint(
coordinate: Coordinate[],
): Coordinate {
let minLat = Number.POSITIVE_INFINITY;
let maxLat = Number.NEGATIVE_INFINITY;
let minLong = Number.POSITIVE_INFINITY;
let maxLong = Number.NEGATIVE_INFINITY;
for (const { lat, long } of coordinate) {
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (long < minLong) minLong = long;
if (long > maxLong) maxLong = long;
}
// Handling the wrap around the international date line
let midLong: number;
if (maxLong > minLong) {
midLong = (maxLong + minLong) / 2;
} else {
// Adjust calculation when spanning the dateline
midLong = ((maxLong + 360 + minLong) / 2) % 360;
}
const midLat = (maxLat + minLat) / 2;
return { lat: midLat, long: midLong };
}
export function clusterCoordinates(
coordinates: Coordinate[],
radius = 25,
options: {
useOptimizedClustering?: boolean;
zoom?: number;
stability?: boolean;
adaptiveRadius?: boolean;
viewport?: {
center: Coordinate;
bounds: {
minLat: number;
maxLat: number;
minLong: number;
maxLong: number;
};
};
} = {},
) {
const { zoom = 1, adaptiveRadius = true, viewport } = options;
// Calculate adaptive radius based on zoom level and coordinate density
let adjustedRadius = radius;
if (adaptiveRadius) {
// Much more aggressive clustering at lower zoom levels
// At zoom 1: use 3x larger radius for very aggressive clustering
// At zoom 2: use 2x radius
// At zoom 4: use 1x radius
// At zoom 8: use 0.2x radius for precision
const zoomFactor = Math.max(0.1, 4 / (zoom + 1));
adjustedRadius = radius * zoomFactor;
// Further adjust based on coordinate density if viewport is provided
if (viewport && coordinates.length > 0) {
const viewportCoords = coordinates.filter(
(coord) =>
coord.lat >= viewport.bounds.minLat &&
coord.lat <= viewport.bounds.maxLat &&
coord.long >= viewport.bounds.minLong &&
coord.long <= viewport.bounds.maxLong,
);
if (viewportCoords.length > 0) {
const viewportArea =
(viewport.bounds.maxLat - viewport.bounds.minLat) *
(viewport.bounds.maxLong - viewport.bounds.minLong);
const density = viewportCoords.length / viewportArea;
// Adjust radius based on density - higher density = larger radius for more aggressive clustering
const densityFactor = Math.max(
0.5,
Math.min(5, Math.sqrt(density * 1000) + 1),
);
adjustedRadius *= densityFactor;
}
}
} else {
// Simple zoom-based adjustment when adaptive is disabled
adjustedRadius = radius * Math.max(0.5, 1 / Math.sqrt(zoom));
}
// Always use basic clustering for now to ensure it works correctly
// TODO: Re-enable optimized clustering after thorough testing
const result = basicClusterCoordinates(coordinates, adjustedRadius);
// Debug: Log clustering results
if (coordinates.length > 0) {
console.log(
`Clustering ${coordinates.length} coordinates with radius ${adjustedRadius.toFixed(2)}km resulted in ${result.length} clusters`,
);
}
return result;
}
// Aggressive clustering algorithm with iterative expansion
function basicClusterCoordinates(coordinates: Coordinate[], radius: number) {
if (coordinates.length === 0) return [];
const clusters: {
center: Coordinate;
count: number;
members: Coordinate[];
}[] = [];
const visited = new Set<number>();
// Sort coordinates by density (coordinates near others first)
const coordinatesWithDensity = coordinates
.map((coord, idx) => {
const nearbyCount = coordinates.filter(
(other) => haversineDistance(coord, other) <= radius * 0.5,
).length;
return { ...coord, originalIdx: idx, nearbyCount };
})
.sort((a, b) => b.nearbyCount - a.nearbyCount);
coordinatesWithDensity.forEach(
({ lat, long, city, country, originalIdx }) => {
if (!visited.has(originalIdx)) {
const cluster = {
members: [{ lat, long, city, country }],
center: { lat, long },
count: 1,
};
// Mark the initial coordinate as visited
visited.add(originalIdx);
// Iteratively expand the cluster to include nearby points
let expandedInLastIteration = true;
while (expandedInLastIteration) {
expandedInLastIteration = false;
// For each existing cluster member, find nearby unvisited coordinates
for (const member of [...cluster.members]) {
coordinatesWithDensity.forEach(
({
lat: otherLat,
long: otherLong,
city: otherCity,
country: otherCountry,
originalIdx: otherIdx,
}) => {
if (!visited.has(otherIdx)) {
const distance = haversineDistance(member, {
lat: otherLat,
long: otherLong,
});
if (distance <= radius) {
cluster.members.push({
lat: otherLat,
long: otherLong,
city: otherCity,
country: otherCountry,
});
visited.add(otherIdx);
cluster.count++;
expandedInLastIteration = true;
}
}
},
);
}
}
// Calculate the proper center for the cluster
cluster.center = calculateClusterCenter(cluster.members);
clusters.push(cluster);
}
},
);
return clusters;
}
// Note: Optimized clustering algorithm was removed temporarily
// TODO: Re-implement optimized clustering after basic algorithm is working well
// Utility function to get clustering statistics for debugging
export function getClusteringStats(
coordinates: Coordinate[],
clusters: ReturnType<typeof clusterCoordinates>,
) {
const totalPoints = coordinates.length;
const totalClusters = clusters.length;
const singletonClusters = clusters.filter((c) => c.count === 1).length;
const avgClusterSize = totalPoints > 0 ? totalPoints / totalClusters : 0;
const maxClusterSize = Math.max(...clusters.map((c) => c.count));
const compressionRatio = totalClusters > 0 ? totalPoints / totalClusters : 1;
return {
totalPoints,
totalClusters,
singletonClusters,
avgClusterSize: Math.round(avgClusterSize * 100) / 100,
maxClusterSize,
compressionRatio: Math.round(compressionRatio * 100) / 100,
};
}
// Helper function to calculate cluster center with longitude wrapping
function calculateClusterCenter(members: Coordinate[]): Coordinate {
if (members.length === 1) {
return { ...members[0]! };
}
// Check if we need to handle longitude wrapping around the dateline
const longitudes = members.map((m) => m.long);
const minLong = Math.min(...longitudes);
const maxLong = Math.max(...longitudes);
let avgLat = 0;
let avgLong = 0;
if (maxLong - minLong > 180) {
// Handle dateline crossing
let adjustedLongSum = 0;
for (const member of members) {
avgLat += member.lat;
const adjustedLong = member.long < 0 ? member.long + 360 : member.long;
adjustedLongSum += adjustedLong;
}
avgLat /= members.length;
avgLong = (adjustedLongSum / members.length) % 360;
if (avgLong > 180) avgLong -= 360;
} else {
// Normal case - no dateline crossing
for (const member of members) {
avgLat += member.lat;
avgLong += member.long;
}
avgLat /= members.length;
avgLong /= members.length;
}
return { lat: avgLat, long: avgLong };
}

View File

@@ -0,0 +1,352 @@
import { Tooltiper } from '@/components/ui/tooltip';
import { bind } from 'bind-event-listener';
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
ComposableMap,
Geographies,
Geography,
Marker,
ZoomableGroup,
} from 'react-simple-maps';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { useTheme } from '@/hooks/use-theme';
import type { Coordinate } from './coordinates';
// Interpolate function similar to React Native Reanimated
const interpolate = (
value: number,
inputRange: [number, number],
outputRange: [number, number],
extrapolate?: 'clamp' | 'extend' | 'identity',
): number => {
const [inputMin, inputMax] = inputRange;
const [outputMin, outputMax] = outputRange;
// Handle edge cases
if (inputMin === inputMax) return outputMin;
const progress = (value - inputMin) / (inputMax - inputMin);
// Apply extrapolation
if (extrapolate === 'clamp') {
const clampedProgress = Math.max(0, Math.min(1, progress));
return outputMin + clampedProgress * (outputMax - outputMin);
}
return outputMin + progress * (outputMax - outputMin);
};
import {
calculateGeographicMidpoint,
clusterCoordinates,
getAverageCenter,
getOuterMarkers,
} from './coordinates';
import { GEO_MAP_URL, determineZoom, getBoundingBox } from './map.helpers';
import { calculateMarkerSize } from './markers';
type Props = {
markers: Coordinate[];
sidebarConfig?: {
width: number;
position: 'left' | 'right';
};
};
const Map = ({ markers, sidebarConfig }: Props) => {
const showCenterMarker = false;
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState<{ width: number; height: number } | null>(
null,
);
const [currentZoom, setCurrentZoom] = useState(1);
const [debouncedZoom, setDebouncedZoom] = useState(1);
const zoomTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Memoize expensive calculations
const { hull, center, initialZoom } = useMemo(() => {
const hull = getOuterMarkers(markers);
const center =
hull.length < 2
? getAverageCenter(markers)
: calculateGeographicMidpoint(hull);
// Calculate initial zoom based on markers distribution
const boundingBox = getBoundingBox(hull.length > 0 ? hull : markers);
const minZoom = 1;
const maxZoom = 20;
const aspectRatio = size ? size.width / size.height : 1;
const autoZoom = Math.max(
minZoom,
Math.min(maxZoom, determineZoom(boundingBox, aspectRatio) * 0.4),
);
// Use calculated zoom if we have markers, otherwise default to 1
const initialZoom = markers.length > 0 ? autoZoom : 1;
return { hull, center, initialZoom };
}, [markers, size]);
// Update current zoom when initial zoom changes (when new markers are loaded)
useEffect(() => {
setCurrentZoom(initialZoom);
setDebouncedZoom(initialZoom);
}, [initialZoom]);
// Debounced zoom update for marker clustering
const updateDebouncedZoom = useCallback((newZoom: number) => {
if (zoomTimeoutRef.current) {
clearTimeout(zoomTimeoutRef.current);
}
zoomTimeoutRef.current = setTimeout(() => {
setDebouncedZoom(newZoom);
}, 100); // 100ms debounce delay
}, []);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (zoomTimeoutRef.current) {
clearTimeout(zoomTimeoutRef.current);
}
};
}, []);
// Memoize center coordinates adjustment for sidebar
const { long, lat } = useMemo(() => {
let adjustedLong = center.long;
if (sidebarConfig && size) {
// Calculate how much to shift the map to center content in visible area
const sidebarOffset =
sidebarConfig.position === 'left'
? sidebarConfig.width / 2
: -sidebarConfig.width / 2;
// Convert pixel offset to longitude degrees
// This is a rough approximation - degrees per pixel at current zoom
const longitudePerPixel = 360 / (size.width * initialZoom);
const longitudeOffset = sidebarOffset * longitudePerPixel;
adjustedLong = center.long - longitudeOffset; // Subtract to shift map right for left sidebar
}
return { long: adjustedLong, lat: center.lat };
}, [center.long, center.lat, sidebarConfig, size, initialZoom]);
const minZoom = 1;
const maxZoom = 20;
useEffect(() => {
return bind(window, {
type: 'resize',
listener() {
if (ref.current) {
const parentRect = ref.current.parentElement?.getBoundingClientRect();
setSize({
width: parentRect?.width ?? 0,
height: parentRect?.height ?? 0,
});
}
},
});
}, []);
useEffect(() => {
if (ref.current) {
const parentRect = ref.current.parentElement?.getBoundingClientRect();
setSize({
width: parentRect?.width ?? 0,
height: parentRect?.height ?? 0,
});
}
}, []);
// Dynamic marker size based on zoom level - balanced scaling for new size range
const getMarkerSize = useCallback(
(baseSize: number) => {
// Interpolate the adjustment value from zoom 1 to 20
// At zoom 1: adjustThisValue = 1
// At zoom 20: adjustThisValue = 0.5
const adjustThisValue = interpolate(
currentZoom,
[1, 20],
[1.5, 0.6],
'clamp',
);
const scaleFactor = (1 / Math.sqrt(currentZoom)) * adjustThisValue;
// Ensure minimum size for visibility, but allow smaller sizes for precision
const minSize = baseSize * 0.05;
const scaledSize = baseSize * scaleFactor;
return Math.max(minSize, scaledSize);
},
[currentZoom],
);
const getBorderWidth = useCallback(() => {
const map = {
0.1: [15, 20],
0.15: [10, 15],
0.25: [5, 10],
0.5: [0, 5],
};
const found = Object.entries(map).find(([, value]) => {
if (currentZoom >= value[0] && currentZoom <= value[1]) {
return true;
}
});
return found ? Number.parseFloat(found[0]) : 0.1;
}, [currentZoom]);
const theme = useTheme();
// Memoize clustered markers
const clusteredMarkers = useMemo(() => {
return clusterCoordinates(markers, 150, {
zoom: debouncedZoom,
adaptiveRadius: true,
});
}, [markers, debouncedZoom]);
return (
<div ref={ref} className="relative">
<div className="bg-gradient-to-t from-def-100 to-transparent h-1/10 absolute bottom-0 left-0 right-0" />
{size === null ? (
<></>
) : (
<>
<ComposableMap
projection="geoMercator"
width={size?.width || 800}
height={size?.height || 400}
>
<ZoomableGroup
center={[long, lat]}
zoom={initialZoom}
minZoom={minZoom}
maxZoom={maxZoom}
onMove={(event) => {
if (currentZoom !== event.zoom) {
setCurrentZoom(event.zoom);
updateDebouncedZoom(event.zoom);
}
}}
>
<Geographies geography={GEO_MAP_URL}>
{({ geographies }) =>
geographies
.filter((geo) => {
return geo.properties.name !== 'Antarctica';
})
.map((geo) => (
<Geography
key={geo.rsmKey}
geography={geo}
fill={theme.theme === 'dark' ? '#000' : '#f0f0f0'}
stroke={theme.theme === 'dark' ? '#333' : '#999'}
strokeWidth={getBorderWidth()}
pointerEvents={'none'}
/>
))
}
</Geographies>
{showCenterMarker && (
<Marker coordinates={[center.long, center.lat]}>
<circle r={getMarkerSize(10)} fill="green" stroke="#fff" />
</Marker>
)}
{clusteredMarkers.map((marker, index) => {
const size = getMarkerSize(calculateMarkerSize(marker.count));
const coordinates: [number, number] = [
marker.center.long,
marker.center.lat,
];
return (
<Fragment
key={`cluster-${index}-${marker.center.long}-${marker.center.lat}`}
>
{/* Animated ping effect */}
<Marker coordinates={coordinates}>
<circle
r={size}
fill={theme.theme === 'dark' ? '#3d79ff' : '#2266ec'}
className="animate-ping opacity-20"
/>
</Marker>
{/* Main marker with tooltip */}
<Tooltiper
asChild
content={
<div className="flex min-w-[200px] flex-col gap-2">
<h3 className="font-semibold capitalize">
{`${marker.count} visitor${marker.count !== 1 ? 's' : ''}`}
</h3>
{marker.members
.slice(0, 5)
.filter((item) => item.country || item.city)
.map((item) => (
<div
className="row items-center gap-2"
key={`${item.long}-${item.lat}`}
>
<SerieIcon
name={
item.country || `${item.lat}, ${item.long}`
}
/>
{item.city || 'Unknown'}
</div>
))}
{marker.members.length > 5 && (
<div className="text-sm text-gray-500">
+ {marker.members.length - 5} more
</div>
)}
</div>
}
>
<Marker coordinates={coordinates}>
<circle
r={size}
fill={theme.theme === 'dark' ? '#3d79ff' : '#2266ec'}
fillOpacity={0.8}
stroke="#fff"
strokeWidth={getBorderWidth() * 0.5}
/>
<text
x={0}
y={0}
fill="#fff"
textAnchor="middle"
dominantBaseline="middle"
fontSize={size * 0.6}
fontWeight="bold"
>
{marker.count}
</text>
</Marker>
</Tooltiper>
</Fragment>
);
})}
</ZoomableGroup>
</ComposableMap>
</>
)}
</div>
);
};
export default Map;

View File

@@ -0,0 +1,93 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useZoomPan } from 'react-simple-maps';
import type { Coordinate } from './coordinates';
export const GEO_MAP_URL =
'https://unpkg.com/world-atlas@2.0.2/countries-50m.json';
export function useAnimatedState(initialValue: number) {
const [value, setValue] = useState(initialValue);
const [target, setTarget] = useState(initialValue);
const ref = useRef<number>(0);
const animate = useCallback(() => {
ref.current = requestAnimationFrame(() => {
setValue((prevValue) => {
const diff = target - prevValue;
if (Math.abs(diff) < 0.01) {
return target; // Stop animating when close enough
}
// Adjust this factor (e.g., 0.02) to control the speed of the animation
return prevValue + diff * 0.05;
});
animate(); // Loop the animation frame
});
}, [target]);
useEffect(() => {
animate(); // Start the animation
return () => cancelAnimationFrame(ref.current!); // Cleanup the animation on unmount
}, [animate]);
useEffect(() => {
setTarget(initialValue);
}, [initialValue]);
return [value, setTarget] as const;
}
export const getBoundingBox = (coordinates: Coordinate[]) => {
const longitudes = coordinates.map((coord) => coord.long);
const latitudes = coordinates.map((coord) => coord.lat);
const minLat = Math.min(...latitudes);
const maxLat = Math.max(...latitudes);
const minLong = Math.min(...longitudes);
const maxLong = Math.max(...longitudes);
return { minLat, maxLat, minLong, maxLong };
};
export const determineZoom = (
bbox: ReturnType<typeof getBoundingBox>,
aspectRatio = 1.0,
): number => {
const latDiff = bbox.maxLat - bbox.minLat;
const longDiff = bbox.maxLong - bbox.minLong;
// Normalize longitudinal span based on latitude to correct for increasing distortion
// towards the poles in a Mercator projection.
const avgLat = (bbox.maxLat + bbox.minLat) / 2;
const longDiffAdjusted = longDiff * Math.cos((avgLat * Math.PI) / 180);
// Adjust calculations depending on the aspect ratio.
const maxDiff =
aspectRatio > 1
? Math.max(latDiff / aspectRatio, longDiffAdjusted) // Wider than tall
: Math.max(latDiff, longDiffAdjusted * aspectRatio); // Taller than wide
// Adjust zoom level scaling factor based on application or testing.
const zoom = Math.max(1, Math.min(20, 200 / maxDiff));
return zoom;
};
export function CustomZoomableGroup({
zoom,
center,
children,
}: {
zoom: number;
center: [number, number];
children: React.ReactNode;
}) {
const { mapRef, transformString } = useZoomPan({
center: center,
zoom,
filterZoomEvent: () => false,
});
return (
<g ref={mapRef}>
<g transform={transformString}>{children}</g>
</g>
);
}

View File

@@ -0,0 +1,43 @@
import { useCallback, useState } from 'react';
import type { Coordinate } from './coordinates';
const useActiveMarkers = (initialMarkers: Coordinate[]) => {
const [activeMarkers, setActiveMarkers] = useState(initialMarkers);
const toggleActiveMarkers = useCallback(() => {
// Shuffle array function
const shuffled = [...initialMarkers].sort(() => 0.5 - Math.random());
// Cut the array in half randomly to simulate changes in active markers
const selected = shuffled.slice(
0,
Math.floor(Math.random() * shuffled.length) + 1,
);
setActiveMarkers(selected);
}, [activeMarkers]);
return { markers: activeMarkers, toggle: toggleActiveMarkers };
};
export default useActiveMarkers;
export function calculateMarkerSize(count: number) {
const minSize = 3; // Minimum size for single visitor (reduced from 4)
const maxSize = 14; // Maximum size for very large clusters (reduced from 20)
if (count <= 1) return minSize;
// Use square root scaling for better visual differentiation
// This creates more noticeable size differences for common visitor counts
// Examples:
// 1 visitor: 3px
// 2 visitors: ~5px
// 5 visitors: ~7px
// 10 visitors: ~9px
// 25 visitors: ~12px
// 50+ visitors: ~14px (max)
const scaledSize = minSize + Math.sqrt(count - 1) * 1.8;
// Ensure size does not exceed maxSize or fall below minSize
return Math.max(minSize, Math.min(scaledSize, maxSize));
}

View File

@@ -0,0 +1,87 @@
'use client';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import useWS from '@/hooks/use-ws';
import type { IServiceEvent } from '@openpanel/db';
import { EventItem } from '../events/table/item';
interface RealtimeActiveSessionsProps {
projectId: string;
limit?: number;
}
export function RealtimeActiveSessions({
projectId,
limit = 10,
}: RealtimeActiveSessionsProps) {
const trpc = useTRPC();
const activeSessionsQuery = useQuery(
trpc.realtime.activeSessions.queryOptions({
projectId,
}),
);
const [state, setState] = useState<IServiceEvent[]>([]);
// Update state when initial data loads
useEffect(() => {
if (activeSessionsQuery.data && state.length === 0) {
setState(activeSessionsQuery.data);
}
}, [activeSessionsQuery.data, state]);
// Set up WebSocket connection for real-time updates
useWS<IServiceEvent>(
`/live/events/${projectId}`,
(session) => {
setState((prev) => {
// Add new session and remove duplicates, keeping most recent
const filtered = prev.filter((s) => s.id !== session.id);
return [session, ...filtered].slice(0, limit);
});
},
{
debounce: {
delay: 1000,
maxWait: 5000,
},
},
);
const sessions = state.length > 0 ? state : (activeSessionsQuery.data ?? []);
return (
<div className="col h-full">
<div className="hide-scrollbar h-full overflow-y-auto pb-10">
<AnimatePresence mode="popLayout" initial={false}>
<div className="col gap-4">
{sessions.map((session) => (
<motion.div
key={session.id}
layout
// initial={{ opacity: 0, x: -200, scale: 0.8 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 200, scale: 0.8 }}
transition={{ duration: 0.4, type: 'spring', stiffness: 300 }}
>
<EventItem
event={session}
viewOptions={{
properties: false,
origin: false,
queryString: false,
}}
className="w-full"
/>
</motion.div>
))}
</div>
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { countries } from '@/translations/countries';
import { useQuery } from '@tanstack/react-query';
import { prop, uniqBy } from 'ramda';
import { OverviewWidgetTable } from '../overview/overview-widget-table';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Tooltiper } from '../ui/tooltip';
interface RealtimeGeoProps {
projectId: string;
}
export function RealtimeGeo({ projectId }: RealtimeGeoProps) {
const trpc = useTRPC();
const query = useQuery(
trpc.realtime.geo.queryOptions({
projectId,
}),
);
const data = query.data ?? [];
const maxCount = Math.max(...data.map((item) => item.count));
const number = useNumber();
// Get unique countries for header icons
const unique = uniqBy(prop('country'), data)
.filter((i) => !!i.country.trim())
.slice(0, 8);
return (
<div className="col h-full card">
<div className="row justify-between items-center p-4 pb-0">
<div className="font-medium text-muted-foreground">Geo</div>
<div className="row gap-1">
{unique.map((item) => (
<Tooltiper
key={item.country}
content={countries[item.country as keyof typeof countries]}
>
<SerieIcon key={item.country} name={item.country} />
</Tooltiper>
))}
</div>
</div>
<OverviewWidgetTable
data={data ?? []}
keyExtractor={(item) => item.country + item.city}
getColumnPercentage={(item) => item.count / maxCount}
columns={[
{
name: 'Country / City',
width: 'w-full',
render(item) {
return (
<Tooltiper
asChild
content={`${item.country} / ${item.city}`}
side="left"
>
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.country} />
{item.city || '(Not set)'}
</div>
</Tooltiper>
);
},
},
{
name: 'Duration',
width: '75px',
render(item) {
return number.shortWithUnit(item.avg_duration, 'min');
},
},
{
name: 'Events',
width: '84px',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.count)}
</span>
</div>
);
},
},
]}
/>
</div>
);
}

View File

@@ -0,0 +1,149 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery } from '@tanstack/react-query';
import type { IChartProps } from '@openpanel/validation';
import { AnimatedNumber } from '../animated-number';
interface RealtimeLiveHistogramProps {
projectId: string;
}
export const getReport = (projectId: string): IChartProps => {
return {
projectId,
events: [
{
segment: 'user',
filters: [],
name: '*',
displayName: 'Active users',
},
],
chartType: 'histogram',
interval: 'minute',
range: '30min',
name: '',
metric: 'sum',
breakdowns: [],
lineType: 'monotone',
previous: false,
};
};
export const getCountReport = (projectId: string): IChartProps => {
return {
name: '',
projectId,
events: [
{
segment: 'user',
filters: [],
id: 'A',
name: 'session_start',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval: 'minute',
range: '30min',
previous: false,
metric: 'sum',
};
};
export function RealtimeLiveHistogram({
projectId,
}: RealtimeLiveHistogramProps) {
const report = getReport(projectId);
const countReport = getCountReport(projectId);
const trpc = useTRPC();
const res = useQuery(trpc.chart.chart.queryOptions(report));
const countRes = useQuery(trpc.chart.chart.queryOptions(countReport));
const metrics = res.data?.series[0]?.metrics;
const minutes = (res.data?.series[0]?.data || []).slice(-30);
const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0;
if (res.isInitialLoading || countRes.isInitialLoading || liveCount === 0) {
const staticArray = [
10, 25, 30, 45, 20, 5, 55, 18, 40, 12, 50, 35, 8, 22, 38, 42, 15, 28, 52,
5, 48, 14, 32, 58, 7, 19, 33, 56, 24, 5,
];
return (
<Wrapper count={0}>
{staticArray.map((percent, i) => (
<div
key={i as number}
className="flex-1 animate-pulse rounded-sm bg-def-200"
style={{ height: `${percent}%` }}
/>
))}
</Wrapper>
);
}
if (!res.isSuccess && !countRes.isSuccess) {
return null;
}
return (
<Wrapper count={liveCount}>
{minutes.map((minute) => {
return (
<Tooltip key={minute.date}>
<TooltipTrigger asChild>
<div
className={cn(
'flex-1 rounded-sm transition-all ease-in-out hover:scale-110',
minute.count === 0 ? 'bg-def-200' : 'bg-highlight',
)}
style={{
height:
minute.count === 0
? '20%'
: `${(minute.count / metrics!.max) * 100}%`,
}}
/>
</TooltipTrigger>
<TooltipContent side="top">
<div>{minute.count} active users</div>
<div>@ {new Date(minute.date).toLocaleTimeString()}</div>
</TooltipContent>
</Tooltip>
);
})}
</Wrapper>
);
}
interface WrapperProps {
children: React.ReactNode;
count: number;
}
function Wrapper({ children, count }: WrapperProps) {
return (
<div className="flex flex-col">
<div className="col gap-2 p-4">
<div className="font-medium text-muted-foreground">
Unique vistors last 30 minutes
</div>
<div className="font-mono text-6xl font-bold">
<AnimatedNumber value={count} />
</div>
</div>
<div className="relative flex aspect-[6/1] w-full flex-1 items-end gap-0.5">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { ExternalLinkIcon } from 'lucide-react';
import { prop, uniqBy } from 'ramda';
import { OverviewWidgetTable } from '../overview/overview-widget-table';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Tooltiper } from '../ui/tooltip';
interface RealtimePathsProps {
projectId: string;
}
export function RealtimePaths({ projectId }: RealtimePathsProps) {
const trpc = useTRPC();
const query = useQuery(
trpc.realtime.paths.queryOptions({
projectId,
}),
);
const data = query.data ?? [];
const maxCount = Math.max(...data.map((item) => item.count));
const number = useNumber();
// Get unique origins for header icons
const unique = uniqBy(prop('origin'), data)
.filter((i) => !!i.origin.trim())
.slice(0, 5);
return (
<div className="col h-full card">
<div className="row justify-between items-center p-4 pb-0">
<div className="font-medium text-muted-foreground">Paths</div>
<div className="row gap-1">
{unique.map((item) => (
<Tooltiper key={item.origin} content={item.origin}>
<SerieIcon key={item.origin} name={item.origin} />
</Tooltiper>
))}
</div>
</div>
<OverviewWidgetTable
data={data ?? []}
keyExtractor={(item) => item.path + item.origin}
getColumnPercentage={(item) => item.count / maxCount}
columns={[
{
name: 'Path',
width: 'w-full',
render(item) {
return (
<Tooltiper
asChild
content={item.origin + item.path}
side="left"
disabled={item.origin === ''}
>
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.origin} />
<span className="truncate">{item.path || '(Not set)'}</span>
{item.origin && (
<a
href={item.origin + item.path}
target="_blank"
rel="noreferrer"
>
<ExternalLinkIcon className="size-3 group-hover/row:opacity-100 opacity-0 transition-opacity" />
</a>
)}
</div>
</Tooltiper>
);
},
},
{
name: 'Duration',
width: '75px',
render(item) {
return number.shortWithUnit(item.avg_duration, 'min');
},
},
{
name: 'Events',
width: '84px',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.count)}
</span>
</div>
);
},
},
]}
/>
</div>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { prop, uniqBy } from 'ramda';
import { OverviewWidgetTable } from '../overview/overview-widget-table';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Tooltiper } from '../ui/tooltip';
interface RealtimeReferralsProps {
projectId: string;
}
export function RealtimeReferrals({ projectId }: RealtimeReferralsProps) {
const trpc = useTRPC();
const query = useQuery(
trpc.realtime.referrals.queryOptions({
projectId,
}),
);
const data = query.data ?? [];
const maxCount = Math.max(...data.map((item) => item.count));
const number = useNumber();
const unique = uniqBy(prop('referrer_name'), data)
.filter((i) => !!i.referrer_name.trim())
.slice(0, 8);
return (
<div className="col h-full card">
<div className="row justify-between items-center p-4 pb-0">
<div className="font-medium text-muted-foreground">Referrals</div>
<div className="row gap-1">
{unique.map((item) => (
<Tooltiper key={item.referrer_name} content={item.referrer_name}>
<SerieIcon key={item.referrer_name} name={item.referrer_name} />
</Tooltiper>
))}
</div>
</div>
<OverviewWidgetTable
data={data ?? []}
keyExtractor={(item) => item.referrer_name}
getColumnPercentage={(item) => item.count / maxCount}
columns={[
{
name: 'Referrer',
width: 'w-full',
render(item) {
return (
<Tooltiper asChild content={item.referrer_name} side="left">
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.referrer_name} />
{item.referrer_name || '(Not set)'}
</div>
</Tooltiper>
);
},
},
{
name: 'Duration',
width: '75px',
render(item) {
return number.shortWithUnit(item.avg_duration, 'min');
},
},
{
name: 'Events',
width: '84px',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.count)}
</span>
</div>
);
},
},
]}
/>
</div>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { useQueryClient } from '@tanstack/react-query';
import { getCountReport, getReport } from './realtime-live-histogram';
type Props = {
projectId: string;
};
const RealtimeReloader = ({ projectId }: Props) => {
const client = useQueryClient();
const trpc = useTRPC();
useWS<number>(
`/live/events/${projectId}`,
() => {
if (!document.hidden) {
client.refetchQueries(trpc.realtime.pathFilter());
client.refetchQueries(
trpc.chart.chart.queryFilter(getReport(projectId)),
);
client.refetchQueries(
trpc.chart.chart.queryFilter(getCountReport(projectId)),
);
}
},
{
debounce: {
delay: 1000,
maxWait: 60000,
},
},
);
return null;
};
export default RealtimeReloader;