added realtime view

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-05-12 19:53:48 +02:00
parent 0975a20e17
commit ee83a6db6f
17 changed files with 1359 additions and 35 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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