From ee83a6db6f1b9fecaf296d28aedaa2679462cf69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Sun, 12 May 2024 19:53:48 +0200 Subject: [PATCH] added realtime view --- apps/api/src/controllers/live.controller.ts | 21 +- apps/dashboard/package.json | 2 + .../[projectId]/layout-menu.tsx | 6 + .../[projectId]/realtime/map/coordinates.ts | 226 +++++++++++++++++ .../[projectId]/realtime/map/index.tsx | 20 ++ .../[projectId]/realtime/map/map.helpers.tsx | 93 +++++++ .../[projectId]/realtime/map/map.tsx | 170 +++++++++++++ .../[projectId]/realtime/map/markers.ts | 34 +++ .../[projectId]/realtime/page.tsx | 136 ++++++++++ .../realtime/realtime-live-events/index.tsx | 21 ++ .../realtime-live-events/live-events.tsx | 49 ++++ .../realtime/realtime-live-histogram.tsx | 166 ++++++++++++ .../realtime/realtime-reloader.tsx | 25 ++ .../report/chart/ReportBarChart.tsx | 59 +++-- .../dashboard/src/types/react-simple-map.d.ts | 240 ++++++++++++++++++ packages/db/clickhouse_tables.sql | 4 +- pnpm-lock.yaml | 122 ++++++++- 17 files changed, 1359 insertions(+), 35 deletions(-) create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/coordinates.ts create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/index.tsx create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/map.helpers.tsx create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/map.tsx create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/markers.ts create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/page.tsx create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-events/index.tsx create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-events/live-events.tsx create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-histogram.tsx create mode 100644 apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-reloader.tsx create mode 100644 apps/dashboard/src/types/react-simple-map.d.ts diff --git a/apps/api/src/controllers/live.controller.ts b/apps/api/src/controllers/live.controller.ts index 3eeb2f45..a213fadf 100644 --- a/apps/api/src/controllers/live.controller.ts +++ b/apps/api/src/controllers/live.controller.ts @@ -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(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); }); } diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 07601ff6..2200a804 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -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", diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx index 057326ad..4c84d9fe 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx @@ -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`} /> + 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(); + + 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, + })); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/index.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/index.tsx new file mode 100644 index 00000000..23fb1872 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/index.tsx @@ -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( + `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 ; +}; + +export default RealtimeMap; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/map.helpers.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/map.helpers.tsx new file mode 100644 index 00000000..fb688db3 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/map.helpers.tsx @@ -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(); + 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, + 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 ( + + {children} + + ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/map.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/map.tsx new file mode 100644 index 00000000..2d724d63 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/map.tsx @@ -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(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 ( +
+ {size === null ? ( + <> + ) : ( + <> + + + + {({ geographies }) => + geographies.map((geo) => ( + + )) + } + + {showCenterMarker && ( + + + + )} + {clusterCoordinates(markers).map((marker) => { + const size = adjustSizeBasedOnZoom( + calculateMarkerSize(marker.count) + ); + const coordinates: [number, number] = [ + marker.center.long, + marker.center.lat, + ]; + return ( + + + + + + + + + + + ); + })} + + + + )} + {/* */} +
+ ); +}; + +export default Map; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/markers.ts b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/markers.ts new file mode 100644 index 00000000..453d6392 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/map/markers.ts @@ -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)); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/page.tsx new file mode 100644 index 00000000..27b50165 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/page.tsx @@ -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 ( +
+ + + + + +
+
+
+ +
+
+ +
+
+
+
+
+
Pages
+
+ +
+
+
+
Cities
+
+ +
+
+
+
Referrers
+
+ +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-events/index.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-events/index.tsx new file mode 100644 index 00000000..60d5b803 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-events/index.tsx @@ -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 ; +}; + +export default RealtimeLiveEventsServer; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-events/live-events.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-events/live-events.tsx new file mode 100644 index 00000000..6fd86cd6 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-events/live-events.tsx @@ -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( + projectId ? `/live/events/${projectId}` : '/live/events', + (event) => { + setState((p) => [event, ...p].slice(0, limit)); + } + ); + return ( + +
+ {state.map((event) => ( + +
+ +
+
+ ))} +
+
+ ); +}; + +export default RealtimeLiveEvents; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-histogram.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-histogram.tsx new file mode 100644 index 00000000..fec5593d --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-histogram.tsx @@ -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 ( + + {staticArray.map((percent, i) => ( +
+ ))} + + ); + } + + if (!res.isSuccess && !countRes.isSuccess) { + return null; + } + + return ( + + {minutes.map((minute) => { + return ( + + +
+ + +
{minute.count} active users
+
@ {new Date(minute.date).toLocaleTimeString()}
+
+ + ); + })} + + ); +} + +interface WrapperProps { + children: React.ReactNode; + count: number; +} + +const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), { + ssr: false, + loading: () =>
0
, +}); + +function Wrapper({ children, count }: WrapperProps) { + return ( +
+
+
+ Unique vistors last 30 minutes +
+
+ ({ + type: 'spring', + duration: index + 0.3, + damping: 10, + stiffness: 200, + })} + animateToNumber={count} + locale="en" + /> +
+
+
+ {children} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-reloader.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-reloader.tsx new file mode 100644 index 00000000..775b3f8b --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-reloader.tsx @@ -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(`/live/visitors/${projectId}`, (value) => { + router.refresh(); + client.refetchQueries({ + type: 'active', + }); + }); + + return null; +}; + +export default RealtimeReloader; diff --git a/apps/dashboard/src/components/report/chart/ReportBarChart.tsx b/apps/dashboard/src/components/report/chart/ReportBarChart.tsx index 7bb5b958..3e616539 100644 --- a/apps/dashboard/src/components/report/chart/ReportBarChart.tsx +++ b/apps/dashboard/src/components/report/chart/ReportBarChart.tsx @@ -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 (
- {series.map((serie, index) => { + {series.map((serie) => { const isClickable = serie.name !== NOT_SET_VALUE && onClick; return (
onClick(serie) } : {})} > -
- - {serie.name} -
-
- - {serie.metrics.previous[metric]?.value} -
- {number.format(serie.metrics.sum)} +
+
+
+ + {serie.name} +
+
+ + {serie.metrics.previous[metric]?.value} +
+ {number.format( + round((serie.metrics.sum / data.metrics.sum) * 100, 2) + )} + % +
+
+ {number.format(serie.metrics.sum)} +
-
); diff --git a/apps/dashboard/src/types/react-simple-map.d.ts b/apps/dashboard/src/types/react-simple-map.d.ts new file mode 100644 index 00000000..0320ef8b --- /dev/null +++ b/apps/dashboard/src/types/react-simple-map.d.ts @@ -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 { + 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 { + 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) => void) + | undefined; + onMove?: + | (( + position: { x: number; y: number; zoom: number; dragging: WheelEvent }, + event: D3ZoomEvent, + ) => void) + | undefined; + onMoveEnd?: + | ((position: { coordinates: [number, number]; zoom: number }, event: D3ZoomEvent) => 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, "children"> { + parseGeographies?: ((features: Feature[]) => Feature[]) | undefined; + geography?: string | Record | string[] | undefined; + children?: ((data: GeographiesChildrenArgument) => void) | undefined; + } + + export interface GeographyProps + extends Pick, Exclude, "style">> + { + geography?: any; + style?: { + default?: React.CSSProperties | undefined; + hover?: React.CSSProperties | undefined; + pressed?: React.CSSProperties | undefined; + } | undefined; + onMouseEnter?: ((event: React.MouseEvent) => void) | undefined; + onMouseLeave?: ((event: React.MouseEvent) => void) | undefined; + onMouseDown?: ((event: React.MouseEvent) => void) | undefined; + onMouseUp?: ((event: React.MouseEvent) => void) | undefined; + onFocus?: ((event: React.FocusEvent) => void) | undefined; + onBlur?: ((event: React.FocusEvent) => void) | undefined; + } + + export interface MarkerProps + extends Pick, Exclude, "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) => void) | undefined; + onMouseLeave?: ((event: React.MouseEvent) => void) | undefined; + onMouseDown?: ((event: React.MouseEvent) => void) | undefined; + onMouseUp?: ((event: React.MouseEvent) => void) | undefined; + onFocus?: ((event: React.FocusEvent) => void) | undefined; + onBlur?: ((event: React.FocusEvent) => void) | undefined; + } + + export interface AnnotationProps extends React.SVGProps { + children?: React.ReactNode; + subject?: Point | undefined; + connectorProps: React.SVGProps; + /** + * @default 30 + */ + dx?: number | string | undefined; + /** + * @default 30 + */ + dy?: number | string | undefined; + /** + * @default 0 + */ + curve?: number | undefined; + } + + export interface GraticuleProps extends React.SVGProps { + /** + * @default [10, 10] + */ + step?: Point | undefined; + /** + * @default "currentcolor" + */ + stroke?: string | undefined; + /** + * @default "transparent" + */ + fill?: string | undefined; + } + + export interface LineProps + extends Pick, Exclude, "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 { + /** + * @default "rsm-sphere" + */ + id: string; + /** + * @default "transparent" + */ + fill: string; + /** + * @default "currentcolor" + */ + stroke: string; + /** + * @default 0.5 + */ + strokeWidth: number; + } + + declare const ComposableMap: React.FunctionComponent; + declare const ZoomableGroup: React.FunctionComponent; + declare const Geographies: React.FunctionComponent; + declare const Geography: React.FunctionComponent; + declare const Marker: React.FunctionComponent; + declare const Annotation: React.FunctionComponent; + declare const Graticule: React.FunctionComponent; + declare const Line: React.FunctionComponent; + declare const Sphere: React.FunctionComponent; + + declare const useZoomPan: (options: { + center: Point, + zoom: number + filterZoomEvent?: () => boolean + }) => { + mapRef: React.MutableRefObject, + transformString: string, + } + + export { useZoomPan, Annotation, ComposableMap, Geographies, Geography, Graticule, Line, Marker, Sphere, ZoomableGroup }; +} diff --git a/packages/db/clickhouse_tables.sql b/packages/db/clickhouse_tables.sql index d8933c83..4cf6ee49 100644 --- a/packages/db/clickhouse_tables.sql +++ b/packages/db/clickhouse_tables.sql @@ -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) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d7d8e06..ec526a2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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