fix: timezone issue + improvements for funnel and conversion charts

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-30 11:04:41 +01:00
parent ddc99e9850
commit 931188a8ab
15 changed files with 128 additions and 103 deletions

View File

@@ -163,6 +163,7 @@
"@types/ramda": "^0.31.0", "@types/ramda": "^0.31.0",
"@types/react": "catalog:", "@types/react": "catalog:",
"@types/react-dom": "catalog:", "@types/react-dom": "catalog:",
"@types/react-grid-layout": "^1.3.5",
"@types/react-simple-maps": "^3.0.4", "@types/react-simple-maps": "^3.0.4",
"@types/react-syntax-highlighter": "^15.5.11", "@types/react-syntax-highlighter": "^15.5.11",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",

View File

@@ -3,13 +3,14 @@ import { cn } from '@/utils/cn';
type ColorSquareProps = HtmlProps<HTMLDivElement>; type ColorSquareProps = HtmlProps<HTMLDivElement>;
export function ColorSquare({ children, className }: ColorSquareProps) { export function ColorSquare({ children, className, color }: ColorSquareProps) {
return ( return (
<div <div
className={cn( className={cn(
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-blue-600 text-sm font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem] font-mono', 'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-blue-600 text-sm font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem] font-mono',
className, className,
)} )}
style={{ backgroundColor: color }}
> >
{children} {children}
</div> </div>

View File

@@ -92,11 +92,10 @@ export const ProfileMetrics = ({ data }: Props) => {
label={metric.title} label={metric.title}
metric={{ metric={{
current: current:
metric.unit === 'timeAgo' && metric.unit === 'timeAgo'
typeof data[metric.key] === 'string' ? new Date(data[metric.key]).getTime()
? new Date(data[metric.key] as string).getTime()
: (data[metric.key] as number) || 0, : (data[metric.key] as number) || 0,
previous: null, // Profile metrics don't have previous period comparison previous: null,
}} }}
unit={metric.unit} unit={metric.unit}
data={[]} data={[]}

View File

@@ -191,14 +191,16 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
<span>{item.total}</span> <span>{item.total}</span>
</div> </div>
<div className="col gap-1"> {!!prevItem && (
<PreviousDiffIndicatorPure <div className="col gap-1">
{...getPreviousMetric(item.rate, prevItem?.rate)} <PreviousDiffIndicatorPure
/> {...getPreviousMetric(item.rate, prevItem?.rate)}
<span className="text-muted-foreground"> />
({prevItem?.total}) <span className="text-muted-foreground">
</span> ({prevItem?.total})
</div> </span>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,15 +6,17 @@ import { ChevronRightIcon, InfoIcon } from 'lucide-react';
import { alphabetIds } from '@openpanel/constants'; import { alphabetIds } from '@openpanel/constants';
import { createChartTooltip } from '@/components/charts/chart-tooltip'; import { createChartTooltip } from '@/components/charts/chart-tooltip';
import { BarShapeBlue } from '@/components/charts/common-bar';
import { Tooltiper } from '@/components/ui/tooltip'; import { Tooltiper } from '@/components/ui/tooltip';
import { WidgetTable } from '@/components/widget-table'; import { WidgetTable } from '@/components/widget-table';
import { useNumber } from '@/hooks/use-numer-formatter'; import { useNumber } from '@/hooks/use-numer-formatter';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { getPreviousMetric } from '@openpanel/common'; import { getPreviousMetric } from '@openpanel/common';
import { import {
Bar,
BarChart,
CartesianGrid, CartesianGrid,
Line, Cell,
LineChart,
ResponsiveContainer, ResponsiveContainer,
XAxis, XAxis,
YAxis, YAxis,
@@ -205,8 +207,10 @@ export function Tables({
{ {
name: 'Event', name: 'Event',
render: (item, index) => ( render: (item, index) => (
<div className="row items-center gap-2 row min-w-0 relative"> <div className="row items-center gap-2 min-w-0 relative">
<ColorSquare>{alphabetIds[index]}</ColorSquare> <ColorSquare color={getChartColor(index)}>
{alphabetIds[index]}
</ColorSquare>
<span className="truncate">{item.event.displayName}</span> <span className="truncate">{item.event.displayName}</span>
</div> </div>
), ),
@@ -265,6 +269,7 @@ const useRechartData = ({
return ( return (
firstFunnel?.steps.map((step, stepIndex) => { firstFunnel?.steps.map((step, stepIndex) => {
return { return {
id: step?.event.id ?? '',
name: step?.event.displayName ?? '', name: step?.event.displayName ?? '',
...current.reduce((acc, item, index) => { ...current.reduce((acc, item, index) => {
const diff = previous?.[index]; const diff = previous?.[index];
@@ -299,7 +304,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
<TooltipProvider data={data.current}> <TooltipProvider data={data.current}>
<div className="aspect-video max-h-[250px] w-full p-4 card pb-1"> <div className="aspect-video max-h-[250px] w-full p-4 card pb-1">
<ResponsiveContainer> <ResponsiveContainer>
<LineChart data={rechartData}> <BarChart data={rechartData}>
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
horizontal={true} horizontal={true}
@@ -308,7 +313,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
/> />
<XAxis <XAxis
{...xAxisProps} {...xAxisProps}
dataKey="name" dataKey="id"
allowDuplicatedCategory={false} allowDuplicatedCategory={false}
type={'category'} type={'category'}
scale="auto" scale="auto"
@@ -316,19 +321,19 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
interval="preserveStartEnd" interval="preserveStartEnd"
tickSize={0} tickSize={0}
tickMargin={4} tickMargin={4}
tickFormatter={(id) =>
data.current[0].steps.find((step) => step.event.id === id)
?.event.displayName ?? ''
}
/> />
<YAxis {...yAxisProps} /> <YAxis {...yAxisProps} />
{data.current.map((item, index) => ( <Bar data={rechartData} dataKey="step:percent:0">
<Line {rechartData.map((item, index) => (
stroke={getChartColor(index)} <Cell key={item.name} fill={getChartColor(index)} />
key={`step:percent:${item.id}`} ))}
dataKey={`step:percent:${index}`} </Bar>
type="linear"
strokeWidth={2}
/>
))}
<Tooltip /> <Tooltip />
</LineChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</TooltipProvider> </TooltipProvider>

View File

@@ -44,7 +44,7 @@ export function ReportFunnelChart() {
const trpc = useTRPC(); const trpc = useTRPC();
const res = useQuery( const res = useQuery(
trpc.chart.funnel.queryOptions(input, { trpc.chart.funnel.queryOptions(input, {
enabled: !isLazyLoading, enabled: !isLazyLoading && input.events.length > 0,
}), }),
); );

View File

@@ -85,12 +85,15 @@ export function WidgetTable<T>({
)} )}
> >
{eachRow?.(item, index)} {eachRow?.(item, index)}
<div className="grid" style={{ gridTemplateColumns }}> <div
className="grid h-8 items-center"
style={{ gridTemplateColumns }}
>
{columns.map((column) => ( {columns.map((column) => (
<div <div
key={column.name?.toString()} key={column.name?.toString()}
className={cn( className={cn(
'p-2 relative cell', 'px-2 relative cell',
columns.length > 1 && column !== columns[0] columns.length > 1 && column !== columns[0]
? 'text-right' ? 'text-right'
: 'text-left', : 'text-left',

View File

@@ -12,7 +12,6 @@ import {
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { createProjectTitle } from '@/utils/title'; import { createProjectTitle } from '@/utils/title';
import { import {
ChevronRight,
LayoutPanelTopIcon, LayoutPanelTopIcon,
MoreHorizontal, MoreHorizontal,
PlusIcon, PlusIcon,
@@ -30,16 +29,13 @@ import { OverviewRange } from '@/components/overview/overview-range';
import { PageContainer } from '@/components/page-container'; import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react'; import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react';
import { showConfirm } from '@/modals';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { createFileRoute, useRouter } from '@tanstack/react-router'; import { createFileRoute, useRouter } from '@tanstack/react-router';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
// @ts-ignore - types will be installed separately
import { Responsive, WidthProvider } from 'react-grid-layout'; import { Responsive, WidthProvider } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css'; import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css'; import 'react-resizable/css/styles.css';
import { ReportChartLoading } from '@/components/report-chart/common/loading';
import { ReportChartProvider } from '@/components/report-chart/context';
import { showConfirm } from '@/modals';
const ResponsiveGridLayout = WidthProvider(Responsive); const ResponsiveGridLayout = WidthProvider(Responsive);

View File

@@ -508,7 +508,10 @@ export class Query<T = any> {
// Execution methods // Execution methods
async execute(): Promise<T[]> { async execute(): Promise<T[]> {
const query = this.buildQuery(); const query = this.buildQuery();
console.log('query', query); console.log(
'query',
`${query} SETTINGS session_timezone = '${this.timezone}'`,
);
const result = await this.client.query({ const result = await this.client.query({
query, query,

View File

@@ -20,7 +20,10 @@ export class ConversionService {
events, events,
breakdowns = [], breakdowns = [],
interval, interval,
}: Omit<IChartInput, 'range' | 'previous' | 'metric' | 'chartType'>) { timezone,
}: Omit<IChartInput, 'range' | 'previous' | 'metric' | 'chartType'> & {
timezone: string;
}) {
const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id'; const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id';
const breakdownColumns = breakdowns.map( const breakdownColumns = breakdowns.map(
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`, (b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
@@ -44,7 +47,7 @@ export class ConversionService {
getEventFiltersWhereClause(eventB.filters), getEventFiltersWhereClause(eventB.filters),
).join(' AND '); ).join(' AND ');
const eventACte = clix(this.client) const eventACte = clix(this.client, timezone)
.select([ .select([
`DISTINCT ${group}`, `DISTINCT ${group}`,
'created_at AS a_time', 'created_at AS a_time',
@@ -56,22 +59,22 @@ export class ConversionService {
.where('name', '=', eventA.name) .where('name', '=', eventA.name)
.rawWhere(whereA) .rawWhere(whereA)
.where('created_at', 'BETWEEN', [ .where('created_at', 'BETWEEN', [
clix.datetime(startDate), clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate), clix.datetime(endDate, 'toDateTime'),
]); ]);
const eventBCte = clix(this.client) const eventBCte = clix(this.client, timezone)
.select([group, 'created_at AS b_time']) .select([group, 'created_at AS b_time'])
.from(TABLE_NAMES.events) .from(TABLE_NAMES.events)
.where('project_id', '=', projectId) .where('project_id', '=', projectId)
.where('name', '=', eventB.name) .where('name', '=', eventB.name)
.rawWhere(whereB) .rawWhere(whereB)
.where('created_at', 'BETWEEN', [ .where('created_at', 'BETWEEN', [
clix.datetime(startDate), clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate), clix.datetime(endDate, 'toDateTime'),
]); ]);
const query = clix(this.client) const query = clix(this.client, timezone)
.with('event_a', eventACte) .with('event_a', eventACte)
.with('event_b', eventBCte) .with('event_b', eventBCte)
.select<{ .select<{

View File

@@ -1,6 +1,6 @@
import { ifNaN } from '@openpanel/common'; import { ifNaN } from '@openpanel/common';
import type { IChartEvent, IChartInput } from '@openpanel/validation'; import type { IChartEvent, IChartInput } from '@openpanel/validation';
import { last, reverse } from 'ramda'; import { last, reverse, uniq } from 'ramda';
import sqlstring from 'sqlstring'; import sqlstring from 'sqlstring';
import { ch } from '../clickhouse/client'; import { ch } from '../clickhouse/client';
import { TABLE_NAMES } from '../clickhouse/client'; import { TABLE_NAMES } from '../clickhouse/client';
@@ -98,6 +98,14 @@ export class FunnelService {
return Object.values(series); return Object.values(series);
} }
getProfileFilters(events: IChartEvent[]) {
return events.flatMap((e) =>
e.filters
?.filter((f) => f.name.startsWith('profile.'))
.map((f) => f.name.replace('profile.', '')),
);
}
async getFunnel({ async getFunnel({
projectId, projectId,
startDate, startDate,
@@ -106,7 +114,8 @@ export class FunnelService {
funnelWindow = 24, funnelWindow = 24,
funnelGroup, funnelGroup,
breakdowns = [], breakdowns = [],
}: IChartInput) { timezone = 'UTC',
}: IChartInput & { timezone: string }) {
if (!startDate || !endDate) { if (!startDate || !endDate) {
throw new Error('startDate and endDate are required'); throw new Error('startDate and endDate are required');
} }
@@ -118,9 +127,14 @@ export class FunnelService {
const funnelWindowSeconds = funnelWindow * 3600; const funnelWindowSeconds = funnelWindow * 3600;
const group = this.getFunnelGroup(funnelGroup); const group = this.getFunnelGroup(funnelGroup);
const funnels = this.getFunnelConditions(events); const funnels = this.getFunnelConditions(events);
const profileFilters = this.getProfileFilters(events);
const anyFilterOnProfile = profileFilters.length > 0;
const anyBreakdownOnProfile = breakdowns.some((b) =>
b.name.startsWith('profile.'),
);
// Create the funnel CTE // Create the funnel CTE
const funnelCte = clix(this.client) const funnelCte = clix(this.client, timezone)
.select([ .select([
`${group[0]} AS ${group[1]}`, `${group[0]} AS ${group[1]}`,
...breakdowns.map( ...breakdowns.map(
@@ -131,8 +145,8 @@ export class FunnelService {
.from(TABLE_NAMES.events, false) .from(TABLE_NAMES.events, false)
.where('project_id', '=', projectId) .where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [ .where('created_at', 'BETWEEN', [
clix.datetime(startDate), clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate), clix.datetime(endDate, 'toDateTime'),
]) ])
.where( .where(
'name', 'name',
@@ -141,21 +155,29 @@ export class FunnelService {
) )
.groupBy([group[1], ...breakdowns.map((b, index) => `b_${index}`)]); .groupBy([group[1], ...breakdowns.map((b, index) => `b_${index}`)]);
if (anyFilterOnProfile || anyBreakdownOnProfile) {
funnelCte.leftJoin(
`(SELECT id, ${uniq(profileFilters.map((f) => f.split('.')[0]))} FROM ${TABLE_NAMES.profiles} FINAL
WHERE project_id = ${sqlstring.escape(projectId)}) as profile`,
'profile.id = profile_id',
);
}
// Create the sessions CTE if needed // Create the sessions CTE if needed
const sessionsCte = const sessionsCte =
group[0] !== 'session_id' group[0] !== 'session_id'
? clix(this.client) ? clix(this.client, timezone)
.select(['profile_id', 'id']) .select(['profile_id', 'id'])
.from(TABLE_NAMES.sessions) .from(TABLE_NAMES.sessions)
.where('project_id', '=', projectId) .where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [ .where('created_at', 'BETWEEN', [
clix.datetime(startDate), clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate), clix.datetime(endDate, 'toDateTime'),
]) ])
: null; : null;
// Base funnel query with CTEs // Base funnel query with CTEs
const funnelQuery = clix(this.client); const funnelQuery = clix(this.client, timezone);
if (sessionsCte) { if (sessionsCte) {
funnelCte.leftJoin('sessions s', 's.id = session_id'); funnelCte.leftJoin('sessions s', 's.id = session_id');
@@ -202,7 +224,7 @@ export class FunnelService {
{ {
event: { event: {
...event, ...event,
displayName: event.displayName ?? event.name, displayName: event.displayName || event.name,
}, },
count: item.count, count: item.count,
percent: (item.count / totalSessions) * 100, percent: (item.count / totalSessions) * 100,

View File

@@ -10,13 +10,14 @@ import {
TABLE_NAMES, TABLE_NAMES,
ch, ch,
chQuery, chQuery,
convertClickhouseDateToJs,
formatClickhouseDate, formatClickhouseDate,
} from '../clickhouse/client'; } from '../clickhouse/client';
import { createSqlBuilder } from '../sql-builder'; import { createSqlBuilder } from '../sql-builder';
export type IProfileMetrics = { export type IProfileMetrics = {
lastSeen: string; lastSeen: Date;
firstSeen: string; firstSeen: Date;
screenViews: number; screenViews: number;
sessions: number; sessions: number;
durationAvg: number; durationAvg: number;
@@ -29,7 +30,12 @@ export type IProfileMetrics = {
avgTimeBetweenSessions: number; avgTimeBetweenSessions: number;
}; };
export function getProfileMetrics(profileId: string, projectId: string) { export function getProfileMetrics(profileId: string, projectId: string) {
return chQuery<IProfileMetrics>(` return chQuery<
Omit<IProfileMetrics, 'lastSeen' | 'firstSeen'> & {
lastSeen: string;
firstSeen: string;
}
>(`
WITH lastSeen AS ( WITH lastSeen AS (
SELECT max(created_at) as lastSeen FROM ${TABLE_NAMES.events} WHERE profile_id = ${sqlstring.escape(profileId)} AND project_id = ${sqlstring.escape(projectId)} SELECT max(created_at) as lastSeen FROM ${TABLE_NAMES.events} WHERE profile_id = ${sqlstring.escape(profileId)} AND project_id = ${sqlstring.escape(projectId)}
), ),
@@ -84,7 +90,15 @@ export function getProfileMetrics(profileId: string, projectId: string) {
(SELECT avgEventsPerSession FROM avgEventsPerSession) as avgEventsPerSession, (SELECT avgEventsPerSession FROM avgEventsPerSession) as avgEventsPerSession,
(SELECT conversionEvents FROM conversionEvents) as conversionEvents, (SELECT conversionEvents FROM conversionEvents) as conversionEvents,
(SELECT avgTimeBetweenSessions FROM avgTimeBetweenSessions) as avgTimeBetweenSessions (SELECT avgTimeBetweenSessions FROM avgTimeBetweenSessions) as avgTimeBetweenSessions
`).then((data) => data[0]!); `)
.then((data) => data[0]!)
.then((data) => {
return {
...data,
lastSeen: convertClickhouseDateToJs(data.lastSeen),
firstSeen: convertClickhouseDateToJs(data.firstSeen),
};
});
} }
export async function getProfileById(id: string, projectId: string) { export async function getProfileById(id: string, projectId: string) {
@@ -259,7 +273,7 @@ export function transformProfile({
lastName: last_name, lastName: last_name,
isExternal: profile.is_external, isExternal: profile.is_external,
properties: toObject(profile.properties), properties: toObject(profile.properties),
createdAt: new Date(created_at), createdAt: convertClickhouseDateToJs(created_at),
projectId: profile.project_id, projectId: profile.project_id,
id: profile.id, id: profile.id,
email: profile.email, email: profile.email,

View File

@@ -1,38 +0,0 @@
import { conversionService } from './src/services/conversion.service';
// 68/37
async function main() {
const conversion = await conversionService.getConversion({
projectId: 'kiddokitchen-app',
startDate: '2025-02-01',
endDate: '2025-03-01',
funnelGroup: 'session_id',
breakdowns: [
{
name: 'os',
},
],
interval: 'day',
events: [
{
segment: 'event',
name: 'screen_view',
filters: [
{
name: 'path',
operator: 'is',
value: ['Start'],
},
],
},
{
segment: 'event',
name: 'sign_up',
filters: [],
},
],
});
console.dir(conversion, { depth: null });
}
main();

View File

@@ -322,9 +322,9 @@ export const chartRouter = createTRPCRouter({
const previousPeriod = getChartPrevStartEndDate(currentPeriod); const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const [current, previous] = await Promise.all([ const [current, previous] = await Promise.all([
funnelService.getFunnel({ ...input, ...currentPeriod }), funnelService.getFunnel({ ...input, ...currentPeriod, timezone }),
input.previous input.previous
? funnelService.getFunnel({ ...input, ...previousPeriod }) ? funnelService.getFunnel({ ...input, ...previousPeriod, timezone })
: Promise.resolve(null), : Promise.resolve(null),
]); ]);
@@ -340,9 +340,13 @@ export const chartRouter = createTRPCRouter({
const previousPeriod = getChartPrevStartEndDate(currentPeriod); const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const [current, previous] = await Promise.all([ const [current, previous] = await Promise.all([
conversionService.getConversion({ ...input, ...currentPeriod }), conversionService.getConversion({ ...input, ...currentPeriod, timezone }),
input.previous input.previous
? conversionService.getConversion({ ...input, ...previousPeriod }) ? conversionService.getConversion({
...input,
...previousPeriod,
timezone,
})
: Promise.resolve(null), : Promise.resolve(null),
]); ]);

10
pnpm-lock.yaml generated
View File

@@ -767,6 +767,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: 'catalog:' specifier: 'catalog:'
version: 19.1.8(@types/react@19.1.11) version: 19.1.8(@types/react@19.1.11)
'@types/react-grid-layout':
specifier: ^1.3.5
version: 1.3.5
'@types/react-simple-maps': '@types/react-simple-maps':
specifier: ^3.0.4 specifier: ^3.0.4
version: 3.0.4 version: 3.0.4
@@ -8617,6 +8620,9 @@ packages:
peerDependencies: peerDependencies:
'@types/react': ^19.0.0 '@types/react': ^19.0.0
'@types/react-grid-layout@1.3.5':
resolution: {integrity: sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==}
'@types/react-simple-maps@3.0.4': '@types/react-simple-maps@3.0.4':
resolution: {integrity: sha512-U9qnX0wVhxldrTpsase44fIoLpyO1OT/hgNMRoJTixj1qjpMRdSRIfih93mR3D/Tss/8CmM7dPwKMjtaGkDpmw==} resolution: {integrity: sha512-U9qnX0wVhxldrTpsase44fIoLpyO1OT/hgNMRoJTixj1qjpMRdSRIfih93mR3D/Tss/8CmM7dPwKMjtaGkDpmw==}
@@ -24161,6 +24167,10 @@ snapshots:
dependencies: dependencies:
'@types/react': 19.1.11 '@types/react': 19.1.11
'@types/react-grid-layout@1.3.5':
dependencies:
'@types/react': 19.1.11
'@types/react-simple-maps@3.0.4': '@types/react-simple-maps@3.0.4':
dependencies: dependencies:
'@types/d3-geo': 2.0.7 '@types/d3-geo': 2.0.7