added realtime view
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { getAuth } from '@clerk/fastify';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { escape } from 'sqlstring';
|
||||
import superjson from 'superjson';
|
||||
@@ -8,6 +9,7 @@ import type { IServiceCreateEventPayload } from '@openpanel/db';
|
||||
import {
|
||||
getEvents,
|
||||
getLiveVisitors,
|
||||
getProfileById,
|
||||
transformMinimalEvent,
|
||||
} from '@openpanel/db';
|
||||
import { redis, redisPub, redisSub } from '@openpanel/redis';
|
||||
@@ -115,20 +117,31 @@ export function wsProjectEvents(
|
||||
}>
|
||||
) {
|
||||
const { params } = req;
|
||||
const auth = getAuth(req);
|
||||
|
||||
redisSub.subscribe('event');
|
||||
|
||||
const message = (channel: string, message: string) => {
|
||||
const message = async (channel: string, message: string) => {
|
||||
const event = getSuperJson<IServiceCreateEventPayload>(message);
|
||||
if (event?.projectId === params.projectId) {
|
||||
connection.socket.send(superjson.stringify(transformMinimalEvent(event)));
|
||||
const profile = await getProfileById(event.profileId, event.projectId);
|
||||
connection.socket.send(
|
||||
superjson.stringify(
|
||||
auth.userId
|
||||
? {
|
||||
...event,
|
||||
profile,
|
||||
}
|
||||
: transformMinimalEvent(event)
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
redisSub.on('message', message);
|
||||
redisSub.on('message', message as any);
|
||||
|
||||
connection.socket.on('close', () => {
|
||||
redisSub.unsubscribe('event');
|
||||
redisSub.off('message', message);
|
||||
redisSub.off('message', message as any);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"react-in-viewport": "1.0.0-alpha.30",
|
||||
"react-redux": "^8.1.3",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-simple-maps": "3.0.0",
|
||||
"react-svg-worldmap": "2.0.0-alpha.16",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-use-websocket": "^4.7.0",
|
||||
@@ -118,6 +119,7 @@
|
||||
"@types/ramda": "^0.29.10",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/react-simple-maps": "^3.0.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.11",
|
||||
"@types/sqlstring": "^2.3.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
BuildingIcon,
|
||||
CogIcon,
|
||||
GanttChartIcon,
|
||||
Globe2Icon,
|
||||
KeySquareIcon,
|
||||
LayoutPanelTopIcon,
|
||||
UserIcon,
|
||||
@@ -91,6 +92,11 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
|
||||
label="Dashboards"
|
||||
href={`/${params.organizationSlug}/${projectId}/dashboards`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={Globe2Icon}
|
||||
label="Realtime"
|
||||
href={`/${params.organizationSlug}/${projectId}/realtime`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={GanttChartIcon}
|
||||
label="Events"
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
export type Coordinate = {
|
||||
lat: number;
|
||||
long: number;
|
||||
};
|
||||
|
||||
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[] {
|
||||
coordinates = coordinates.sort(sortCoordinates);
|
||||
|
||||
if (coordinates.length <= 3) return coordinates;
|
||||
|
||||
const lower: Coordinate[] = [];
|
||||
for (const coord of coordinates) {
|
||||
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]!,
|
||||
coordinates[i]!
|
||||
) <= 0
|
||||
) {
|
||||
upper.pop();
|
||||
}
|
||||
upper.push(coordinates[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 = Infinity,
|
||||
maxLat = -Infinity,
|
||||
minLong = Infinity,
|
||||
maxLong = -Infinity;
|
||||
|
||||
coordinate.forEach(({ lat, long }) => {
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (long < minLong) minLong = long;
|
||||
if (long > maxLong) maxLong = long;
|
||||
});
|
||||
|
||||
// Handling the wrap around the international date line
|
||||
let midLong;
|
||||
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) {
|
||||
const clusters: {
|
||||
center: Coordinate;
|
||||
count: number;
|
||||
members: Coordinate[];
|
||||
}[] = [];
|
||||
const visited = new Set<number>();
|
||||
|
||||
coordinates.forEach((coord, idx) => {
|
||||
if (!visited.has(idx)) {
|
||||
const cluster = {
|
||||
members: [coord],
|
||||
center: { lat: coord.lat, long: coord.long },
|
||||
count: 0,
|
||||
};
|
||||
|
||||
coordinates.forEach((otherCoord, otherIdx) => {
|
||||
if (
|
||||
!visited.has(otherIdx) &&
|
||||
haversineDistance(coord, otherCoord) <= radius
|
||||
) {
|
||||
cluster.members.push(otherCoord);
|
||||
visited.add(otherIdx);
|
||||
cluster.count++;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate geographic center for the cluster
|
||||
cluster.center = cluster.members.reduce(
|
||||
(center, cur) => {
|
||||
return {
|
||||
lat: center.lat + cur.lat / cluster.members.length,
|
||||
long: center.long + cur.long / cluster.members.length,
|
||||
};
|
||||
},
|
||||
{ lat: 0, long: 0 }
|
||||
);
|
||||
|
||||
clusters.push(cluster);
|
||||
}
|
||||
});
|
||||
|
||||
return clusters.map((cluster) => ({
|
||||
center: cluster.center,
|
||||
count: cluster.count,
|
||||
members: cluster.members,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { subMinutes } from 'date-fns';
|
||||
import { escape } from 'sqlstring';
|
||||
|
||||
import { chQuery, formatClickhouseDate } from '@openpanel/db';
|
||||
|
||||
import type { Coordinate } from './coordinates';
|
||||
import Map from './map';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
};
|
||||
const RealtimeMap = async ({ projectId }: Props) => {
|
||||
const res = await chQuery<Coordinate>(
|
||||
`SELECT DISTINCT city, longitude as long, latitude as lat FROM events WHERE project_id = ${escape(projectId)} AND created_at >= '${formatClickhouseDate(subMinutes(new Date(), 30))}' ORDER BY created_at DESC`
|
||||
);
|
||||
|
||||
return <Map markers={res} />;
|
||||
};
|
||||
|
||||
export default RealtimeMap;
|
||||
@@ -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>();
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import React, { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import {
|
||||
ComposableMap,
|
||||
Geographies,
|
||||
Geography,
|
||||
Marker,
|
||||
} from 'react-simple-maps';
|
||||
|
||||
import type { Coordinate } from './coordinates';
|
||||
import {
|
||||
calculateGeographicMidpoint,
|
||||
clusterCoordinates,
|
||||
getAverageCenter,
|
||||
getOuterMarkers,
|
||||
} from './coordinates';
|
||||
import {
|
||||
CustomZoomableGroup,
|
||||
determineZoom,
|
||||
GEO_MAP_URL,
|
||||
getBoundingBox,
|
||||
useAnimatedState,
|
||||
} from './map.helpers';
|
||||
import useActiveMarkers, { calculateMarkerSize } from './markers';
|
||||
|
||||
type Props = {
|
||||
markers: Coordinate[];
|
||||
};
|
||||
const Map = ({ markers }: Props) => {
|
||||
const showCenterMarker = false;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [size, setSize] = useState<{ width: number; height: number } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// const { markers, toggle } = useActiveMarkers(_m);
|
||||
const hull = getOuterMarkers(markers);
|
||||
const center =
|
||||
hull.length < 2
|
||||
? getAverageCenter(markers)
|
||||
: calculateGeographicMidpoint(hull);
|
||||
const boundingBox = getBoundingBox(hull);
|
||||
const [zoom] = useAnimatedState(
|
||||
markers.length === 1
|
||||
? 20
|
||||
: determineZoom(boundingBox, size ? size?.height / size?.width : 1)
|
||||
);
|
||||
|
||||
const [long] = useAnimatedState(center.long);
|
||||
const [lat] = useAnimatedState(center.lat);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
setSize({
|
||||
width: ref.current.clientWidth,
|
||||
height: ref.current.clientHeight,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// useEffect(() => {
|
||||
// return bind(window, {
|
||||
// type: 'resize',
|
||||
// listener() {
|
||||
// if (ref.current) {
|
||||
// setSize({
|
||||
// width: ref.current.clientWidth,
|
||||
// height: ref.current.clientHeight,
|
||||
// });
|
||||
// }
|
||||
// },
|
||||
// });
|
||||
// }, []);
|
||||
|
||||
const adjustSizeBasedOnZoom = (size: number) => {
|
||||
const minMultiplier = 1;
|
||||
const maxMultiplier = 3;
|
||||
|
||||
// Linearly interpolate the multiplier based on the zoom level
|
||||
const multiplier =
|
||||
maxMultiplier - ((zoom - 1) * (maxMultiplier - minMultiplier)) / (20 - 1);
|
||||
|
||||
return size * multiplier;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 top-16 lg:left-72" ref={ref}>
|
||||
{size === null ? (
|
||||
<></>
|
||||
) : (
|
||||
<>
|
||||
<ComposableMap
|
||||
width={size?.width}
|
||||
height={size?.height}
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
rotate: [0, 0, 0],
|
||||
scale: 100 * 20,
|
||||
}}
|
||||
>
|
||||
<CustomZoomableGroup zoom={zoom * 0.05} center={[long, lat]}>
|
||||
<Geographies geography={GEO_MAP_URL}>
|
||||
{({ geographies }) =>
|
||||
geographies.map((geo) => (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill="#EAEAEC"
|
||||
stroke="black"
|
||||
pointerEvents={'none'}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Geographies>
|
||||
{showCenterMarker && (
|
||||
<Marker coordinates={[center.long, center.lat]}>
|
||||
<circle
|
||||
r={adjustSizeBasedOnZoom(30)}
|
||||
fill="green"
|
||||
stroke="#fff"
|
||||
strokeWidth={adjustSizeBasedOnZoom(2)}
|
||||
/>
|
||||
</Marker>
|
||||
)}
|
||||
{clusterCoordinates(markers).map((marker) => {
|
||||
const size = adjustSizeBasedOnZoom(
|
||||
calculateMarkerSize(marker.count)
|
||||
);
|
||||
const coordinates: [number, number] = [
|
||||
marker.center.long,
|
||||
marker.center.lat,
|
||||
];
|
||||
return (
|
||||
<Fragment key={coordinates.join('-')}>
|
||||
<Marker coordinates={coordinates}>
|
||||
<circle
|
||||
r={size}
|
||||
fill="#2463EB"
|
||||
className="animate-ping opacity-20"
|
||||
/>
|
||||
</Marker>
|
||||
<Tooltiper asChild content={`${marker.count} visitors`}>
|
||||
<Marker coordinates={coordinates}>
|
||||
<circle r={size} fill="#2463EB" fillOpacity={0.5} />
|
||||
</Marker>
|
||||
</Tooltiper>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</CustomZoomableGroup>
|
||||
</ComposableMap>
|
||||
</>
|
||||
)}
|
||||
{/* <Button
|
||||
className="fixed bottom-[100px] left-[320px] z-50 opacity-0"
|
||||
onClick={() => {
|
||||
toggle();
|
||||
}}
|
||||
>
|
||||
Toogle
|
||||
</Button> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Map;
|
||||
@@ -0,0 +1,34 @@
|
||||
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 = 8; // Minimum size of the marker
|
||||
const maxSize = 40; // Maximum size allowed for the marker
|
||||
|
||||
if (count <= 1) return minSize; // Ensure that we handle count=0 or 1 gracefully
|
||||
const scaledSize =
|
||||
minSize + (Math.log(count) / Math.log(1000)) * (maxSize - minSize);
|
||||
|
||||
// Ensure size does not exceed maxSize or fall below minSize
|
||||
return Math.max(minSize, Math.min(scaledSize, maxSize));
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Suspense } from 'react';
|
||||
import { LazyChart } from '@/components/report/chart/LazyChart';
|
||||
|
||||
import PageLayout from '../page-layout';
|
||||
import RealtimeMap from './map';
|
||||
import RealtimeLiveEventsServer from './realtime-live-events';
|
||||
import { RealtimeLiveHistogram } from './realtime-live-histogram';
|
||||
import RealtimeReloader from './realtime-reloader';
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
organizationSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
};
|
||||
export default function Page({
|
||||
params: { projectId, organizationSlug },
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="">
|
||||
<RealtimeReloader projectId={projectId} />
|
||||
<PageLayout title="Realtime" {...{ projectId, organizationSlug }} />
|
||||
<Suspense>
|
||||
<RealtimeMap projectId={projectId} />
|
||||
</Suspense>
|
||||
<div className="pointer-events-none relative z-10 w-full overflow-hidden">
|
||||
<div className="pointer-events-none grid min-h-[calc(100vh-theme(spacing.16))] items-start gap-4 p-8 md:grid-cols-3">
|
||||
<div className="card bg-background/80 p-4">
|
||||
<RealtimeLiveHistogram projectId={projectId} />
|
||||
</div>
|
||||
<div className="pointer-events-auto col-span-2">
|
||||
<RealtimeLiveEventsServer projectId={projectId} limit={5} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-32 grid gap-4 p-8 md:grid-cols-3">
|
||||
<div className="card p-4">
|
||||
<div className="mb-6">
|
||||
<div className="font-bold">Pages</div>
|
||||
</div>
|
||||
<LazyChart
|
||||
hideID
|
||||
{...{
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
filters: [],
|
||||
segment: 'event',
|
||||
id: 'A',
|
||||
name: 'screen_view',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'path',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
lineType: 'monotone',
|
||||
interval: 'minute',
|
||||
name: 'Top sources',
|
||||
range: '30min',
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="mb-6">
|
||||
<div className="font-bold">Cities</div>
|
||||
</div>
|
||||
<LazyChart
|
||||
hideID
|
||||
{...{
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
id: 'A',
|
||||
name: 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'city',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
lineType: 'monotone',
|
||||
interval: 'minute',
|
||||
name: 'Top sources',
|
||||
range: '30min',
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="mb-6">
|
||||
<div className="font-bold">Referrers</div>
|
||||
</div>
|
||||
<LazyChart
|
||||
hideID
|
||||
{...{
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
id: 'A',
|
||||
name: 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'referrer_name',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
lineType: 'monotone',
|
||||
interval: 'minute',
|
||||
name: 'Top sources',
|
||||
range: '30min',
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { escape } from 'sqlstring';
|
||||
|
||||
import { getEvents } from '@openpanel/db';
|
||||
|
||||
import LiveEvents from './live-events';
|
||||
|
||||
type Props = {
|
||||
projectId?: string;
|
||||
limit?: number;
|
||||
};
|
||||
const RealtimeLiveEventsServer = async ({ projectId, limit = 30 }: Props) => {
|
||||
const events = await getEvents(
|
||||
`SELECT * FROM events WHERE project_id = ${escape(projectId)} ORDER BY created_at DESC LIMIT ${limit}`,
|
||||
{
|
||||
profile: true,
|
||||
}
|
||||
);
|
||||
return <LiveEvents events={events} projectId={projectId} limit={limit} />;
|
||||
};
|
||||
|
||||
export default RealtimeLiveEventsServer;
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { EventListItem } from '@/app/(app)/[organizationSlug]/[projectId]/events/event-list-item';
|
||||
import useWS from '@/hooks/useWS';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import type {
|
||||
IServiceCreateEventPayload,
|
||||
IServiceEventMinimal,
|
||||
} from '@openpanel/db';
|
||||
|
||||
type Props = {
|
||||
events: (IServiceEventMinimal | IServiceCreateEventPayload)[];
|
||||
projectId?: string;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
const RealtimeLiveEvents = ({ events, projectId, limit }: Props) => {
|
||||
const [state, setState] = useState(events ?? []);
|
||||
useWS<IServiceEventMinimal | IServiceCreateEventPayload>(
|
||||
projectId ? `/live/events/${projectId}` : '/live/events',
|
||||
(event) => {
|
||||
setState((p) => [event, ...p].slice(0, limit));
|
||||
}
|
||||
);
|
||||
return (
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<div className="flex gap-4">
|
||||
{state.map((event) => (
|
||||
<motion.div
|
||||
key={event.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: -200, x: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, y: 0, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 0, x: 200, scale: 1.2 }}
|
||||
transition={{ duration: 0.6, type: 'spring' }}
|
||||
>
|
||||
<div className="w-[380px]">
|
||||
<EventListItem {...event} />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default RealtimeLiveEvents;
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { api } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import type { IChartInput } from '@openpanel/validation';
|
||||
|
||||
interface RealtimeLiveHistogramProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function RealtimeLiveHistogram({
|
||||
projectId,
|
||||
}: RealtimeLiveHistogramProps) {
|
||||
const report: IChartInput = {
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: ['screen_view', 'session_start'],
|
||||
},
|
||||
],
|
||||
id: 'A',
|
||||
name: '*',
|
||||
displayName: 'Active users',
|
||||
},
|
||||
],
|
||||
chartType: 'histogram',
|
||||
interval: 'minute',
|
||||
range: '30min',
|
||||
name: '',
|
||||
metric: 'sum',
|
||||
breakdowns: [],
|
||||
lineType: 'monotone',
|
||||
previous: false,
|
||||
};
|
||||
const countReport: IChartInput = {
|
||||
name: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
filters: [],
|
||||
id: 'A',
|
||||
name: 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [],
|
||||
chartType: 'metric',
|
||||
lineType: 'monotone',
|
||||
interval: 'minute',
|
||||
range: '30min',
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
};
|
||||
|
||||
const res = api.chart.chart.useQuery(report);
|
||||
const countRes = api.chart.chart.useQuery(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) {
|
||||
// prettier-ignore
|
||||
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}
|
||||
className="flex-1 animate-pulse rounded-md bg-slate-200 dark:bg-slate-800"
|
||||
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-md transition-all ease-in-out hover:scale-110',
|
||||
minute.count === 0 ? 'bg-slate-200' : 'bg-blue-600'
|
||||
)}
|
||||
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;
|
||||
}
|
||||
|
||||
const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
|
||||
ssr: false,
|
||||
loading: () => <div>0</div>,
|
||||
});
|
||||
|
||||
function Wrapper({ children, count }: WrapperProps) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="p-4">
|
||||
<div className="font-medium text-muted-foreground">
|
||||
Unique vistors last 30 minutes
|
||||
</div>
|
||||
<div className="text-6xl font-bold">
|
||||
<AnimatedNumbers
|
||||
includeComma
|
||||
transitions={(index) => ({
|
||||
type: 'spring',
|
||||
duration: index + 0.3,
|
||||
damping: 10,
|
||||
stiffness: 200,
|
||||
})}
|
||||
animateToNumber={count}
|
||||
locale="en"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex aspect-[6/1] w-full flex-1 items-end gap-0.5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import useWS from '@/hooks/useWS';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
const RealtimeReloader = ({ projectId }: Props) => {
|
||||
const client = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
useWS<number>(`/live/visitors/${projectId}`, (value) => {
|
||||
router.refresh();
|
||||
client.refetchQueries({
|
||||
type: 'active',
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default RealtimeReloader;
|
||||
@@ -1,12 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
import { round } from '@openpanel/common';
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
|
||||
import { PreviousDiffIndicatorText } from '../PreviousDiffIndicator';
|
||||
@@ -29,41 +28,45 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'-mx-2 flex w-full flex-col text-xs',
|
||||
editMode && 'card p-4 text-base'
|
||||
'flex flex-col text-xs',
|
||||
editMode ? 'card gap-2 p-4 text-base' : '-m-3 gap-1'
|
||||
)}
|
||||
>
|
||||
{series.map((serie, index) => {
|
||||
{series.map((serie) => {
|
||||
const isClickable = serie.name !== NOT_SET_VALUE && onClick;
|
||||
return (
|
||||
<div
|
||||
key={serie.name}
|
||||
className={cn(
|
||||
'relative flex w-full flex-1 items-center gap-4 overflow-hidden rounded px-2 py-3 even:bg-slate-50 dark:even:bg-slate-100',
|
||||
'[&_[role=progressbar]]:shadow-sm [&_[role=progressbar]]:even:bg-background',
|
||||
isClickable &&
|
||||
'cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-50'
|
||||
)}
|
||||
className={cn('relative', isClickable && 'cursor-pointer')}
|
||||
{...(isClickable ? { onClick: () => onClick(serie) } : {})}
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2 break-all font-medium">
|
||||
<SerieIcon name={serie.name} />
|
||||
{serie.name}
|
||||
</div>
|
||||
<div className="flex w-1/4 flex-shrink-0 items-center justify-end gap-4">
|
||||
<PreviousDiffIndicatorText
|
||||
{...serie.metrics.previous[metric]}
|
||||
className="text-xs font-medium"
|
||||
/>
|
||||
{serie.metrics.previous[metric]?.value}
|
||||
<div className="font-bold">
|
||||
{number.format(serie.metrics.sum)}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-slate-100"
|
||||
style={{
|
||||
width: `${(serie.metrics.sum / maxCount) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 flex w-full flex-1 items-center gap-4 overflow-hidden px-3 py-2">
|
||||
<div className="flex flex-1 items-center gap-2 break-all font-medium">
|
||||
<SerieIcon name={serie.name} />
|
||||
{serie.name}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center justify-end gap-4">
|
||||
<PreviousDiffIndicatorText
|
||||
{...serie.metrics.previous[metric]}
|
||||
className="text-xs font-medium"
|
||||
/>
|
||||
{serie.metrics.previous[metric]?.value}
|
||||
<div className="text-muted-foreground">
|
||||
{number.format(
|
||||
round((serie.metrics.sum / data.metrics.sum) * 100, 2)
|
||||
)}
|
||||
%
|
||||
</div>
|
||||
<div className="font-bold">
|
||||
{number.format(serie.metrics.sum)}
|
||||
</div>
|
||||
</div>
|
||||
<Progress
|
||||
color={getChartColor(index)}
|
||||
value={(serie.metrics.sum / maxCount) * 100}
|
||||
size={editMode ? 'lg' : 'sm'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
240
apps/dashboard/src/types/react-simple-map.d.ts
vendored
Normal file
240
apps/dashboard/src/types/react-simple-map.d.ts
vendored
Normal file
@@ -0,0 +1,240 @@
|
||||
import type { GeoPath, GeoProjection } from "d3-geo";
|
||||
import type { D3ZoomEvent } from "d3-zoom";
|
||||
import type { Feature } from "geojson";
|
||||
import type * as React from "react";
|
||||
|
||||
declare module 'react-simple-maps' {
|
||||
export type Point = [number, number];
|
||||
|
||||
export interface ProjectionConfig {
|
||||
scale?: number | undefined;
|
||||
center?: [number, number] | undefined;
|
||||
parallels?: [number, number] | undefined;
|
||||
rotate?: [number, number, number] | undefined;
|
||||
}
|
||||
export type ProjectionFunction = (width: number, height: number, config: ProjectionConfig) => GeoProjection;
|
||||
|
||||
export interface ComposableMapProps extends React.SVGAttributes<SVGSVGElement> {
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* @default 800
|
||||
*/
|
||||
width?: number | undefined;
|
||||
/**
|
||||
* @default 600
|
||||
*/
|
||||
height?: number | undefined;
|
||||
/**
|
||||
* @default "geoEqualEarth"
|
||||
*/
|
||||
projection?: string | ProjectionFunction | undefined;
|
||||
/**
|
||||
* @default {}
|
||||
*/
|
||||
projectionConfig?: ProjectionConfig | undefined;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
last: Point;
|
||||
zoom: number;
|
||||
dragging: boolean;
|
||||
zooming: boolean;
|
||||
}
|
||||
|
||||
export interface ZoomableGroupProps extends React.SVGAttributes<SVGGElement> {
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* @default [0, 0]
|
||||
*/
|
||||
center?: Point | undefined;
|
||||
/**
|
||||
* @default 1
|
||||
*/
|
||||
zoom?: number | undefined;
|
||||
/**
|
||||
* @default 1
|
||||
*/
|
||||
minZoom?: number | undefined;
|
||||
/**
|
||||
* @default 5
|
||||
*/
|
||||
maxZoom?: number | undefined;
|
||||
/**
|
||||
* @default 0.025
|
||||
*/
|
||||
zoomSensitivity?: number | undefined;
|
||||
/**
|
||||
* @default false
|
||||
*/
|
||||
disablePanning?: boolean | undefined;
|
||||
/**
|
||||
* @default false
|
||||
*/
|
||||
disableZooming?: boolean | undefined;
|
||||
onMoveStart?:
|
||||
| ((position: { coordinates: [number, number]; zoom: number }, event: D3ZoomEvent<SVGElement, any>) => void)
|
||||
| undefined;
|
||||
onMove?:
|
||||
| ((
|
||||
position: { x: number; y: number; zoom: number; dragging: WheelEvent },
|
||||
event: D3ZoomEvent<SVGElement, any>,
|
||||
) => void)
|
||||
| undefined;
|
||||
onMoveEnd?:
|
||||
| ((position: { coordinates: [number, number]; zoom: number }, event: D3ZoomEvent<SVGElement, any>) => void)
|
||||
| undefined;
|
||||
filterZoomEvent?: ((element: SVGElement) => boolean) | undefined;
|
||||
translateExtent?: [[number, number], [number, number]] | undefined;
|
||||
}
|
||||
|
||||
interface GeographiesChildrenArgument {
|
||||
geographies: any[];
|
||||
path: GeoPath;
|
||||
projection: GeoProjection;
|
||||
}
|
||||
|
||||
export interface GeographiesProps extends Omit<React.SVGAttributes<SVGGElement>, "children"> {
|
||||
parseGeographies?: ((features: Feature<any, any>[]) => Feature<any, any>[]) | undefined;
|
||||
geography?: string | Record<string, any> | string[] | undefined;
|
||||
children?: ((data: GeographiesChildrenArgument) => void) | undefined;
|
||||
}
|
||||
|
||||
export interface GeographyProps
|
||||
extends Pick<React.SVGProps<SVGPathElement>, Exclude<keyof React.SVGProps<SVGPathElement>, "style">>
|
||||
{
|
||||
geography?: any;
|
||||
style?: {
|
||||
default?: React.CSSProperties | undefined;
|
||||
hover?: React.CSSProperties | undefined;
|
||||
pressed?: React.CSSProperties | undefined;
|
||||
} | undefined;
|
||||
onMouseEnter?: ((event: React.MouseEvent<SVGPathElement, MouseEvent>) => void) | undefined;
|
||||
onMouseLeave?: ((event: React.MouseEvent<SVGPathElement, MouseEvent>) => void) | undefined;
|
||||
onMouseDown?: ((event: React.MouseEvent<SVGPathElement, MouseEvent>) => void) | undefined;
|
||||
onMouseUp?: ((event: React.MouseEvent<SVGPathElement, MouseEvent>) => void) | undefined;
|
||||
onFocus?: ((event: React.FocusEvent<SVGPathElement>) => void) | undefined;
|
||||
onBlur?: ((event: React.FocusEvent<SVGPathElement>) => void) | undefined;
|
||||
}
|
||||
|
||||
export interface MarkerProps
|
||||
extends Pick<React.SVGProps<SVGPathElement>, Exclude<keyof React.SVGProps<SVGPathElement>, "style">>
|
||||
{
|
||||
children?: React.ReactNode;
|
||||
coordinates?: Point | undefined;
|
||||
style?: {
|
||||
default?: React.CSSProperties | undefined;
|
||||
hover?: React.CSSProperties | undefined;
|
||||
pressed?: React.CSSProperties | undefined;
|
||||
} | undefined;
|
||||
onMouseEnter?: ((event: React.MouseEvent<SVGPathElement, MouseEvent>) => void) | undefined;
|
||||
onMouseLeave?: ((event: React.MouseEvent<SVGPathElement, MouseEvent>) => void) | undefined;
|
||||
onMouseDown?: ((event: React.MouseEvent<SVGPathElement, MouseEvent>) => void) | undefined;
|
||||
onMouseUp?: ((event: React.MouseEvent<SVGPathElement, MouseEvent>) => void) | undefined;
|
||||
onFocus?: ((event: React.FocusEvent<SVGPathElement>) => void) | undefined;
|
||||
onBlur?: ((event: React.FocusEvent<SVGPathElement>) => void) | undefined;
|
||||
}
|
||||
|
||||
export interface AnnotationProps extends React.SVGProps<SVGGElement> {
|
||||
children?: React.ReactNode;
|
||||
subject?: Point | undefined;
|
||||
connectorProps: React.SVGProps<SVGPathElement>;
|
||||
/**
|
||||
* @default 30
|
||||
*/
|
||||
dx?: number | string | undefined;
|
||||
/**
|
||||
* @default 30
|
||||
*/
|
||||
dy?: number | string | undefined;
|
||||
/**
|
||||
* @default 0
|
||||
*/
|
||||
curve?: number | undefined;
|
||||
}
|
||||
|
||||
export interface GraticuleProps extends React.SVGProps<SVGPathElement> {
|
||||
/**
|
||||
* @default [10, 10]
|
||||
*/
|
||||
step?: Point | undefined;
|
||||
/**
|
||||
* @default "currentcolor"
|
||||
*/
|
||||
stroke?: string | undefined;
|
||||
/**
|
||||
* @default "transparent"
|
||||
*/
|
||||
fill?: string | undefined;
|
||||
}
|
||||
|
||||
export interface LineProps
|
||||
extends Pick<React.SVGProps<SVGPathElement>, Exclude<keyof React.SVGProps<SVGPathElement>, "from" | "to">>
|
||||
{
|
||||
/**
|
||||
* @default [0, 0]
|
||||
*/
|
||||
from?: Point | undefined;
|
||||
/**
|
||||
* @default [0, 0]
|
||||
*/
|
||||
to?: Point | undefined;
|
||||
/**
|
||||
* @default [[0, 0], [0, 0]]
|
||||
*/
|
||||
coordinates?: Point[] | undefined;
|
||||
/**
|
||||
* @default "currentcolor"
|
||||
*/
|
||||
stroke?: string | undefined;
|
||||
/**
|
||||
* @default 3
|
||||
*/
|
||||
strokeWidth?: number | string | undefined;
|
||||
/**
|
||||
* @default "transparent"
|
||||
*/
|
||||
fill?: string | undefined;
|
||||
}
|
||||
|
||||
interface SphereProps extends React.SVGProps<SVGPathElement> {
|
||||
/**
|
||||
* @default "rsm-sphere"
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @default "transparent"
|
||||
*/
|
||||
fill: string;
|
||||
/**
|
||||
* @default "currentcolor"
|
||||
*/
|
||||
stroke: string;
|
||||
/**
|
||||
* @default 0.5
|
||||
*/
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
declare const ComposableMap: React.FunctionComponent<ComposableMapProps>;
|
||||
declare const ZoomableGroup: React.FunctionComponent<ZoomableGroupProps>;
|
||||
declare const Geographies: React.FunctionComponent<GeographiesProps>;
|
||||
declare const Geography: React.FunctionComponent<GeographyProps>;
|
||||
declare const Marker: React.FunctionComponent<MarkerProps>;
|
||||
declare const Annotation: React.FunctionComponent<AnnotationProps>;
|
||||
declare const Graticule: React.FunctionComponent<GraticuleProps>;
|
||||
declare const Line: React.FunctionComponent<LineProps>;
|
||||
declare const Sphere: React.FunctionComponent<SphereProps>;
|
||||
|
||||
declare const useZoomPan: (options: {
|
||||
center: Point,
|
||||
zoom: number
|
||||
filterZoomEvent?: () => boolean
|
||||
}) => {
|
||||
mapRef: React.MutableRefObject<SVGGElement | null>,
|
||||
transformString: string,
|
||||
}
|
||||
|
||||
export { useZoomPan, Annotation, ComposableMap, Geographies, Geography, Graticule, Line, Marker, Sphere, ZoomableGroup };
|
||||
}
|
||||
@@ -81,12 +81,12 @@ ADD
|
||||
ALTER TABLE
|
||||
events
|
||||
ADD
|
||||
COLUMN longitude Nullable(Int16);
|
||||
COLUMN longitude Nullable(Float32);
|
||||
|
||||
ALTER TABLE
|
||||
events
|
||||
ADD
|
||||
COLUMN latitude Nullable(Int16);
|
||||
COLUMN latitude Nullable(Float32);
|
||||
|
||||
--- Materialized views (DAU)
|
||||
CREATE MATERIALIZED VIEW dau_mv ENGINE = AggregatingMergeTree() PARTITION BY toYYYYMMDD(date)
|
||||
|
||||
122
pnpm-lock.yaml
generated
122
pnpm-lock.yaml
generated
@@ -372,6 +372,9 @@ importers:
|
||||
react-responsive:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2(react@18.2.0)
|
||||
react-simple-maps:
|
||||
specifier: 3.0.0
|
||||
version: 3.0.0(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0)
|
||||
react-svg-worldmap:
|
||||
specifier: 2.0.0-alpha.16
|
||||
version: 2.0.0-alpha.16(react-dom@18.2.0)(react@18.2.0)
|
||||
@@ -451,6 +454,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^18.2.7
|
||||
version: 18.2.19
|
||||
'@types/react-simple-maps':
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4
|
||||
'@types/react-syntax-highlighter':
|
||||
specifier: ^15.5.11
|
||||
version: 15.5.11
|
||||
@@ -7160,6 +7166,10 @@ packages:
|
||||
resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-color@2.0.6:
|
||||
resolution: {integrity: sha512-tbaFGDmJWHqnenvk3QGSvD3RVwr631BjKRD7Sc7VLRgrdX5mk5hTyoeBL6rXZaeoXzmZwIl1D2HPogEdt1rHBg==}
|
||||
dev: true
|
||||
|
||||
/@types/d3-color@3.1.3:
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||
dev: false
|
||||
@@ -7207,6 +7217,12 @@ packages:
|
||||
resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-geo@2.0.7:
|
||||
resolution: {integrity: sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==}
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.14
|
||||
dev: true
|
||||
|
||||
/@types/d3-geo@3.1.0:
|
||||
resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==}
|
||||
dependencies:
|
||||
@@ -7217,6 +7233,12 @@ packages:
|
||||
resolution: {integrity: sha512-qlmD/8aMk5xGorUvTUWHCiumvgaUXYldYjNVOWtYoTYY/L+WwIEAmJxUmTgr9LoGNG0PPAOmqMDJVDPc7DOpPw==}
|
||||
dev: false
|
||||
|
||||
/@types/d3-interpolate@2.0.5:
|
||||
resolution: {integrity: sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==}
|
||||
dependencies:
|
||||
'@types/d3-color': 2.0.6
|
||||
dev: true
|
||||
|
||||
/@types/d3-interpolate@3.0.4:
|
||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||
dependencies:
|
||||
@@ -7249,6 +7271,10 @@ packages:
|
||||
'@types/d3-time': 3.0.3
|
||||
dev: false
|
||||
|
||||
/@types/d3-selection@2.0.4:
|
||||
resolution: {integrity: sha512-5a21DF7avVPmiUau8KTsv5r76yGqbMgq4QtByoCBPXUrVFWFkd3Ob4OOhmePNRbQqfUCNFjgB4sO7sUURnKcBg==}
|
||||
dev: true
|
||||
|
||||
/@types/d3-selection@3.0.10:
|
||||
resolution: {integrity: sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==}
|
||||
dev: false
|
||||
@@ -7277,6 +7303,13 @@ packages:
|
||||
'@types/d3-selection': 3.0.10
|
||||
dev: false
|
||||
|
||||
/@types/d3-zoom@2.0.7:
|
||||
resolution: {integrity: sha512-JWke4E8ZyrKUQ68ESTWSK16fVb0OYnaiJ+WXJRYxKLn4aXU0o4CLYxMWBEiouUfO3TTCoyroOrGPcBG6u1aAxA==}
|
||||
dependencies:
|
||||
'@types/d3-interpolate': 2.0.5
|
||||
'@types/d3-selection': 2.0.4
|
||||
dev: true
|
||||
|
||||
/@types/d3-zoom@3.0.8:
|
||||
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
|
||||
dependencies:
|
||||
@@ -7360,7 +7393,6 @@ packages:
|
||||
|
||||
/@types/geojson@7946.0.14:
|
||||
resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==}
|
||||
dev: false
|
||||
|
||||
/@types/hast@2.3.10:
|
||||
resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==}
|
||||
@@ -7491,6 +7523,15 @@ packages:
|
||||
dependencies:
|
||||
'@types/react': 18.2.56
|
||||
|
||||
/@types/react-simple-maps@3.0.4:
|
||||
resolution: {integrity: sha512-U9qnX0wVhxldrTpsase44fIoLpyO1OT/hgNMRoJTixj1qjpMRdSRIfih93mR3D/Tss/8CmM7dPwKMjtaGkDpmw==}
|
||||
dependencies:
|
||||
'@types/d3-geo': 2.0.7
|
||||
'@types/d3-zoom': 2.0.7
|
||||
'@types/geojson': 7946.0.14
|
||||
'@types/react': 18.2.56
|
||||
dev: true
|
||||
|
||||
/@types/react-syntax-highlighter@15.5.11:
|
||||
resolution: {integrity: sha512-ZqIJl+Pg8kD+47kxUjvrlElrraSUrYa4h0dauY/U/FTUuprSCqvUj+9PNQNQzVc6AJgIWUUxn87/gqsMHNbRjw==}
|
||||
dependencies:
|
||||
@@ -9247,6 +9288,10 @@ packages:
|
||||
d3-path: 3.1.0
|
||||
dev: false
|
||||
|
||||
/d3-color@2.0.0:
|
||||
resolution: {integrity: sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==}
|
||||
dev: false
|
||||
|
||||
/d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -9266,11 +9311,22 @@ packages:
|
||||
delaunator: 5.0.1
|
||||
dev: false
|
||||
|
||||
/d3-dispatch@2.0.0:
|
||||
resolution: {integrity: sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==}
|
||||
dev: false
|
||||
|
||||
/d3-dispatch@3.0.1:
|
||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/d3-drag@2.0.0:
|
||||
resolution: {integrity: sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==}
|
||||
dependencies:
|
||||
d3-dispatch: 2.0.0
|
||||
d3-selection: 2.0.0
|
||||
dev: false
|
||||
|
||||
/d3-drag@3.0.0:
|
||||
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -9289,6 +9345,10 @@ packages:
|
||||
rw: 1.3.3
|
||||
dev: false
|
||||
|
||||
/d3-ease@2.0.0:
|
||||
resolution: {integrity: sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==}
|
||||
dev: false
|
||||
|
||||
/d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -9333,6 +9393,12 @@ packages:
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/d3-interpolate@2.0.1:
|
||||
resolution: {integrity: sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==}
|
||||
dependencies:
|
||||
d3-color: 2.0.0
|
||||
dev: false
|
||||
|
||||
/d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -9390,6 +9456,10 @@ packages:
|
||||
d3-time-format: 4.1.0
|
||||
dev: false
|
||||
|
||||
/d3-selection@2.0.0:
|
||||
resolution: {integrity: sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==}
|
||||
dev: false
|
||||
|
||||
/d3-selection@3.0.0:
|
||||
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -9422,11 +9492,28 @@ packages:
|
||||
d3-array: 3.2.4
|
||||
dev: false
|
||||
|
||||
/d3-timer@2.0.0:
|
||||
resolution: {integrity: sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==}
|
||||
dev: false
|
||||
|
||||
/d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/d3-transition@2.0.0(d3-selection@2.0.0):
|
||||
resolution: {integrity: sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==}
|
||||
peerDependencies:
|
||||
d3-selection: '2'
|
||||
dependencies:
|
||||
d3-color: 2.0.0
|
||||
d3-dispatch: 2.0.0
|
||||
d3-ease: 2.0.0
|
||||
d3-interpolate: 2.0.1
|
||||
d3-selection: 2.0.0
|
||||
d3-timer: 2.0.0
|
||||
dev: false
|
||||
|
||||
/d3-transition@3.0.1(d3-selection@3.0.0):
|
||||
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -9441,6 +9528,16 @@ packages:
|
||||
d3-timer: 3.0.1
|
||||
dev: false
|
||||
|
||||
/d3-zoom@2.0.0:
|
||||
resolution: {integrity: sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==}
|
||||
dependencies:
|
||||
d3-dispatch: 2.0.0
|
||||
d3-drag: 2.0.0
|
||||
d3-interpolate: 2.0.1
|
||||
d3-selection: 2.0.0
|
||||
d3-transition: 2.0.0(d3-selection@2.0.0)
|
||||
dev: false
|
||||
|
||||
/d3-zoom@3.0.0:
|
||||
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -15737,6 +15834,22 @@ packages:
|
||||
react-is: 18.2.0
|
||||
dev: false
|
||||
|
||||
/react-simple-maps@3.0.0(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==}
|
||||
peerDependencies:
|
||||
prop-types: ^15.7.2
|
||||
react: ^16.8.0 || 17.x || 18.x
|
||||
react-dom: ^16.8.0 || 17.x || 18.x
|
||||
dependencies:
|
||||
d3-geo: 2.0.2
|
||||
d3-selection: 2.0.0
|
||||
d3-zoom: 2.0.0
|
||||
prop-types: 15.8.1
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
topojson-client: 3.1.0
|
||||
dev: false
|
||||
|
||||
/react-smooth@4.0.0(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-2NMXOBY1uVUQx1jBeENGA497HK20y6CPGYL1ZnJLeoQ8rrc3UfmOM82sRxtzpcoCkUMy4CS0RGylfuVhuFjBgg==}
|
||||
peerDependencies:
|
||||
@@ -17268,6 +17381,13 @@ packages:
|
||||
engines: {node: '>=0.6'}
|
||||
dev: false
|
||||
|
||||
/topojson-client@3.1.0:
|
||||
resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
commander: 2.20.3
|
||||
dev: false
|
||||
|
||||
/tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
dev: false
|
||||
|
||||
Reference in New Issue
Block a user