>,
+) {
+ const { data } = props;
+ const {
+ serviceName,
+ incomingRequests: {
+ totalRequests: totalIncomingRequestCount,
+ errorPercentage,
+ },
+ source,
+ dateRange,
+ maxErrorPercentage,
+ } = data;
+
+ const { backgroundColor, borderColor } = getNodeColors(
+ errorPercentage,
+ maxErrorPercentage,
+ props.selected,
+ );
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/packages/app/src/components/ServiceMap/ServiceMapTooltip.tsx b/packages/app/src/components/ServiceMap/ServiceMapTooltip.tsx
new file mode 100644
index 00000000..7bb59cac
--- /dev/null
+++ b/packages/app/src/components/ServiceMap/ServiceMapTooltip.tsx
@@ -0,0 +1,70 @@
+import SqlString from 'sqlstring';
+import { TSource } from '@hyperdx/common-utils/dist/types';
+import { UnstyledButton } from '@mantine/core';
+
+import { formatApproximateNumber, navigateToTraceSearch } from './utils';
+
+import styles from './ServiceMap.module.scss';
+
+export default function ServiceMapTooltip({
+ totalRequests,
+ errorPercentage,
+ source,
+ dateRange,
+ serviceName,
+}: {
+ totalRequests: number;
+ errorPercentage: number;
+ source: TSource;
+ dateRange: [Date, Date];
+ serviceName: string;
+}) {
+ return (
+
+
+ navigateToTraceSearch({
+ dateRange,
+ source,
+ where: SqlString.format("? = ? AND ? IN ('Server', 'Consumer')", [
+ SqlString.raw(source.serviceNameExpression ?? 'ServiceName'),
+ serviceName,
+ SqlString.raw(source.spanKindExpression ?? 'SpanKind'),
+ ]),
+ })
+ }
+ className={styles.linkButton}
+ >
+ {formatApproximateNumber(totalRequests)} request
+ {totalRequests !== 1 ? 's' : ''}
+
+ {errorPercentage > 0 ? (
+ <>
+ {', '}
+
+ navigateToTraceSearch({
+ dateRange,
+ source,
+ where: SqlString.format(
+ "? = ? AND ? IN ('Server', 'Consumer') AND ? = 'Error'",
+ [
+ SqlString.raw(
+ source.serviceNameExpression ?? 'ServiceName',
+ ),
+ serviceName,
+ SqlString.raw(source.spanKindExpression ?? 'SpanKind'),
+ SqlString.raw(source.statusCodeExpression ?? 'StatusCode'),
+ ],
+ ),
+ })
+ }
+ className={styles.linkButton}
+ >
+ {errorPercentage.toFixed(2)}% error
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/packages/app/src/components/ServiceMap/__tests__/utils.test.ts b/packages/app/src/components/ServiceMap/__tests__/utils.test.ts
new file mode 100644
index 00000000..3d7ee039
--- /dev/null
+++ b/packages/app/src/components/ServiceMap/__tests__/utils.test.ts
@@ -0,0 +1,340 @@
+import router from 'next/router';
+import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
+
+import {
+ formatApproximateNumber,
+ getNodeColors,
+ navigateToTraceSearch,
+} from '../utils';
+
+// Mock next/router
+jest.mock('next/router', () => ({
+ __esModule: true,
+ default: {
+ push: jest.fn(),
+ },
+}));
+
+describe('navigateToTraceSearch', () => {
+ const mockSource: TSource = {
+ id: 'test-source-id',
+ name: 'Test Source',
+ from: {
+ tableName: 'test_table',
+ databaseName: 'test_db',
+ },
+ timestampValueExpression: 'timestamp',
+ connection: 'test-connection',
+ kind: SourceKind.Trace,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should navigate to search page with correct query parameters', () => {
+ const dateRange: [Date, Date] = [
+ new Date('2024-01-15T10:00:00.000Z'),
+ new Date('2024-01-15T11:00:00.000Z'),
+ ];
+
+ navigateToTraceSearch({
+ dateRange,
+ source: mockSource,
+ where: "service_name = 'my-service'",
+ });
+
+ expect(router.push).toHaveBeenCalledTimes(1);
+
+ const callArg = (router.push as jest.Mock).mock.calls[0][0];
+ expect(callArg).toContain('/search?');
+
+ // Parse query params
+ const url = new URL(callArg, 'http://localhost');
+ const params = url.searchParams;
+
+ expect(params.get('isLive')).toBe('false');
+ expect(params.get('source')).toBe('test-source-id');
+ expect(params.get('where')).toBe("service_name = 'my-service'");
+ expect(params.get('whereLanguage')).toBe('sql');
+ expect(params.get('from')).toBe('1705312800000');
+ expect(params.get('to')).toBe('1705316400000');
+ });
+
+ it('should handle different date ranges', () => {
+ const dateRange: [Date, Date] = [
+ new Date('2023-12-01T00:00:00.000Z'),
+ new Date('2023-12-31T23:59:59.999Z'),
+ ];
+
+ navigateToTraceSearch({
+ dateRange,
+ source: mockSource,
+ where: 'status_code = 500',
+ });
+
+ const callArg = (router.push as jest.Mock).mock.calls[0][0];
+ const url = new URL(callArg, 'http://localhost');
+ const params = url.searchParams;
+
+ expect(params.get('from')).toBe('1701388800000');
+ expect(params.get('to')).toBe('1704067199999');
+ });
+
+ it('should handle complex where clauses', () => {
+ const dateRange: [Date, Date] = [
+ new Date('2024-01-01T00:00:00.000Z'),
+ new Date('2024-01-02T00:00:00.000Z'),
+ ];
+
+ const complexWhere =
+ "service_name = 'my-service' AND status_code >= 400 AND span_kind = 'server'";
+
+ navigateToTraceSearch({
+ dateRange,
+ source: mockSource,
+ where: complexWhere,
+ });
+
+ const callArg = (router.push as jest.Mock).mock.calls[0][0];
+ const url = new URL(callArg, 'http://localhost');
+ const params = url.searchParams;
+
+ expect(params.get('where')).toBe(complexWhere);
+ });
+
+ it('should handle special characters in where clause', () => {
+ const dateRange: [Date, Date] = [
+ new Date('2024-01-01T00:00:00.000Z'),
+ new Date('2024-01-02T00:00:00.000Z'),
+ ];
+
+ const whereWithSpecialChars = "service_name = 'test&service=value'";
+
+ navigateToTraceSearch({
+ dateRange,
+ source: mockSource,
+ where: whereWithSpecialChars,
+ });
+
+ const callArg = (router.push as jest.Mock).mock.calls[0][0];
+ const url = new URL(callArg, 'http://localhost');
+ const params = url.searchParams;
+
+ // URL encoding should preserve the where clause
+ expect(params.get('where')).toBe(whereWithSpecialChars);
+ });
+});
+
+describe('formatApproximateNumber', () => {
+ describe('numbers less than 1000', () => {
+ it('should format zero correctly', () => {
+ expect(formatApproximateNumber(0)).toBe('~0');
+ });
+
+ it('should format small positive numbers correctly', () => {
+ expect(formatApproximateNumber(1)).toBe('~1');
+ expect(formatApproximateNumber(42)).toBe('~42');
+ expect(formatApproximateNumber(999)).toBe('~999');
+ });
+
+ it('should format decimal numbers correctly', () => {
+ expect(formatApproximateNumber(1.5)).toBe('~1.5');
+ expect(formatApproximateNumber(42.7)).toBe('~42.7');
+ expect(formatApproximateNumber(999.99)).toBe('~999.99');
+ });
+ });
+
+ describe('thousands (1K - 999K)', () => {
+ it('should format exact thousands correctly', () => {
+ expect(formatApproximateNumber(1000)).toBe('~1k');
+ expect(formatApproximateNumber(5000)).toBe('~5k');
+ expect(formatApproximateNumber(10000)).toBe('~10k');
+ });
+
+ it('should round to nearest thousand', () => {
+ expect(formatApproximateNumber(1234)).toBe('~1k');
+ expect(formatApproximateNumber(1500)).toBe('~2k');
+ expect(formatApproximateNumber(1499)).toBe('~1k');
+ expect(formatApproximateNumber(9876)).toBe('~10k');
+ });
+
+ it('should handle values near million boundary', () => {
+ expect(formatApproximateNumber(999000)).toBe('~999k');
+ expect(formatApproximateNumber(999499)).toBe('~999k');
+ expect(formatApproximateNumber(999500)).toBe('~1000k');
+ });
+ });
+
+ describe('millions (1M - 999M)', () => {
+ it('should format exact millions correctly', () => {
+ expect(formatApproximateNumber(1_000_000)).toBe('~1M');
+ expect(formatApproximateNumber(5_000_000)).toBe('~5M');
+ expect(formatApproximateNumber(10_000_000)).toBe('~10M');
+ });
+
+ it('should round to nearest million', () => {
+ expect(formatApproximateNumber(1_234_567)).toBe('~1M');
+ expect(formatApproximateNumber(1_500_000)).toBe('~2M');
+ expect(formatApproximateNumber(1_499_999)).toBe('~1M');
+ expect(formatApproximateNumber(9_876_543)).toBe('~10M');
+ });
+
+ it('should handle values near billion boundary', () => {
+ expect(formatApproximateNumber(999_000_000)).toBe('~999M');
+ expect(formatApproximateNumber(999_499_999)).toBe('~999M');
+ expect(formatApproximateNumber(999_500_000)).toBe('~1000M');
+ });
+ });
+
+ describe('billions (1B+)', () => {
+ it('should format exact billions correctly', () => {
+ expect(formatApproximateNumber(1_000_000_000)).toBe('~1B');
+ expect(formatApproximateNumber(5_000_000_000)).toBe('~5B');
+ expect(formatApproximateNumber(10_000_000_000)).toBe('~10B');
+ });
+
+ it('should round to nearest billion', () => {
+ expect(formatApproximateNumber(1_234_567_890)).toBe('~1B');
+ expect(formatApproximateNumber(1_500_000_000)).toBe('~2B');
+ expect(formatApproximateNumber(1_499_999_999)).toBe('~1B');
+ expect(formatApproximateNumber(9_876_543_210)).toBe('~10B');
+ });
+
+ it('should handle very large numbers', () => {
+ expect(formatApproximateNumber(999_000_000_000)).toBe('~999B');
+ expect(formatApproximateNumber(1_000_000_000_000)).toBe('~1000B');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle boundary values precisely', () => {
+ expect(formatApproximateNumber(999.99)).toBe('~999.99');
+ expect(formatApproximateNumber(1000.01)).toBe('~1k');
+ expect(formatApproximateNumber(999_999.99)).toBe('~1000k');
+ expect(formatApproximateNumber(1_000_000.01)).toBe('~1M');
+ expect(formatApproximateNumber(999_999_999.99)).toBe('~1000M');
+ expect(formatApproximateNumber(1_000_000_000.01)).toBe('~1B');
+ });
+ });
+});
+
+describe('getNodeColors', () => {
+ describe('background color calculation', () => {
+ it('should return light background when error percent is 0', () => {
+ const colors = getNodeColors(0, 20, false);
+ expect(colors.backgroundColor).toBe('hsl(0 0% 80%)');
+ });
+
+ it('should calculate background color based on error percentage', () => {
+ const colors = getNodeColors(10, 20, false);
+ // (10 / 20) * 100 = 50% saturation
+ expect(colors.backgroundColor).toBe('hsl(0 50% 80%)');
+ });
+
+ it('should use full saturation when error percent equals max', () => {
+ const colors = getNodeColors(20, 20, false);
+ // (20 / 20) * 100 = 100% saturation
+ expect(colors.backgroundColor).toBe('hsl(0 100% 80%)');
+ });
+
+ it('should cap at max error rate even if actual error is higher', () => {
+ const colors = getNodeColors(30, 20, false);
+ // Math.min(20, 30) = 20, (20 / 20) * 100 = 100% saturation
+ expect(colors.backgroundColor).toBe('hsl(0 100% 80%)');
+ });
+
+ it('should handle very small error percentages', () => {
+ const colors = getNodeColors(0.1, 20, false);
+ // (0.1 / 20) * 100 = 0.5% saturation
+ expect(colors.backgroundColor).toBe('hsl(0 0.5% 80%)');
+ });
+
+ it('should handle when maxErrorPercent is 0', () => {
+ // This would cause division by zero, but Math results in Infinity
+ const colors = getNodeColors(5, 0, false);
+ expect(colors.backgroundColor).toContain('hsl(0');
+ });
+ });
+
+ describe('border color calculation', () => {
+ it('should return white border when node is selected', () => {
+ const colors = getNodeColors(10, 20, true);
+ expect(colors.borderColor).toBe('white');
+ });
+
+ it('should return calculated border color when not selected', () => {
+ const colors = getNodeColors(10, 20, false);
+ // (10 / 20) * 100 = 50% saturation with 40% lightness
+ expect(colors.borderColor).toBe('hsl(0 50% 40%)');
+ });
+
+ it('should return dark border for high error rates when not selected', () => {
+ const colors = getNodeColors(20, 20, false);
+ expect(colors.borderColor).toBe('hsl(0 100% 40%)');
+ });
+
+ it('should return light border for zero errors when not selected', () => {
+ const colors = getNodeColors(0, 20, false);
+ expect(colors.borderColor).toBe('hsl(0 0% 40%)');
+ });
+
+ it('should cap border color saturation like background', () => {
+ const colors = getNodeColors(30, 20, false);
+ // Should cap at 20% error rate
+ expect(colors.borderColor).toBe('hsl(0 100% 40%)');
+ });
+ });
+
+ describe('selected state', () => {
+ it('should always use white border when selected regardless of error rate', () => {
+ expect(getNodeColors(0, 20, true).borderColor).toBe('white');
+ expect(getNodeColors(5, 20, true).borderColor).toBe('white');
+ expect(getNodeColors(10, 20, true).borderColor).toBe('white');
+ expect(getNodeColors(20, 20, true).borderColor).toBe('white');
+ expect(getNodeColors(30, 20, true).borderColor).toBe('white');
+ });
+
+ it('should still calculate background color correctly when selected', () => {
+ const colors = getNodeColors(10, 20, true);
+ expect(colors.backgroundColor).toBe('hsl(0 50% 80%)');
+ expect(colors.borderColor).toBe('white');
+ });
+ });
+
+ describe('various error percentage scenarios', () => {
+ it('should handle low error rates', () => {
+ const colors = getNodeColors(1, 20, false);
+ expect(colors.backgroundColor).toBe('hsl(0 5% 80%)');
+ expect(colors.borderColor).toBe('hsl(0 5% 40%)');
+ });
+
+ it('should handle medium error rates', () => {
+ const colors = getNodeColors(10, 20, false);
+ expect(colors.backgroundColor).toBe('hsl(0 50% 80%)');
+ expect(colors.borderColor).toBe('hsl(0 50% 40%)');
+ });
+
+ it('should handle high error rates', () => {
+ const colors = getNodeColors(18, 20, false);
+ expect(colors.backgroundColor).toBe('hsl(0 90% 80%)');
+ expect(colors.borderColor).toBe('hsl(0 90% 40%)');
+ });
+ });
+
+ describe('return value structure', () => {
+ it('should return an object with backgroundColor and borderColor', () => {
+ const colors = getNodeColors(10, 20, false);
+ expect(colors).toHaveProperty('backgroundColor');
+ expect(colors).toHaveProperty('borderColor');
+ expect(typeof colors.backgroundColor).toBe('string');
+ expect(typeof colors.borderColor).toBe('string');
+ });
+
+ it('should return different objects for different inputs', () => {
+ const colors1 = getNodeColors(5, 20, false);
+ const colors2 = getNodeColors(10, 20, false);
+ expect(colors1).not.toEqual(colors2);
+ });
+ });
+});
diff --git a/packages/app/src/components/ServiceMap/utils.ts b/packages/app/src/components/ServiceMap/utils.ts
new file mode 100644
index 00000000..a14eb3dd
--- /dev/null
+++ b/packages/app/src/components/ServiceMap/utils.ts
@@ -0,0 +1,62 @@
+import router from 'next/router';
+import { TSource } from '@hyperdx/common-utils/dist/types';
+
+export function navigateToTraceSearch({
+ dateRange,
+ source,
+ where,
+}: {
+ dateRange: [Date, Date];
+ source: TSource;
+ where: string;
+}) {
+ const from = dateRange[0].getTime().toString();
+ const to = dateRange[1].getTime().toString();
+ const query = new URLSearchParams({
+ isLive: 'false',
+ source: source?.id,
+ where,
+ whereLanguage: 'sql',
+ from,
+ to,
+ });
+
+ router.push(`/search?${query.toString()}`);
+}
+
+export function formatApproximateNumber(num: number): string {
+ if (num < 1000) {
+ return `~${num.toString()}`;
+ }
+
+ if (num < 1_000_000) {
+ const thousands = num / 1000;
+ return `~${Math.round(thousands)}k`;
+ }
+
+ if (num < 1_000_000_000) {
+ const millions = num / 1_000_000;
+ return `~${Math.round(millions)}M`;
+ }
+
+ const billions = num / 1_000_000_000;
+ return `~${Math.round(billions)}B`;
+}
+
+export function getNodeColors(
+ errorPercent: number,
+ maxErrorPercent: number,
+ isSelected: boolean,
+) {
+ const saturation =
+ maxErrorPercent > 0
+ ? (Math.min(errorPercent, maxErrorPercent) / maxErrorPercent) * 100
+ : 0;
+ const backgroundColor = `hsl(0 ${saturation}% 80%)`;
+ const borderColor = isSelected ? 'white' : `hsl(0 ${saturation}% 40%)`;
+
+ return {
+ backgroundColor,
+ borderColor,
+ };
+}
diff --git a/packages/app/src/hooks/__tests__/useServiceMap.test.ts b/packages/app/src/hooks/__tests__/useServiceMap.test.ts
new file mode 100644
index 00000000..c34534ac
--- /dev/null
+++ b/packages/app/src/hooks/__tests__/useServiceMap.test.ts
@@ -0,0 +1,484 @@
+import { aggregateServiceMapData, SpanAggregationRow } from '../useServiceMap';
+
+describe('aggregateServiceMapData', () => {
+ describe('basic aggregation', () => {
+ it('should return empty map for empty input', () => {
+ const result = aggregateServiceMapData([]);
+ expect(result.size).toBe(0);
+ });
+
+ it('should aggregate single service with single status', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ requestCount: 100,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+
+ expect(result.size).toBe(1);
+ expect(result.has('api-service')).toBe(true);
+
+ const service = result.get('api-service')!;
+ expect(service.serviceName).toBe('api-service');
+ expect(service.incomingRequests.totalRequests).toBe(100);
+ expect(service.incomingRequests.requestCountByStatus.get('Ok')).toBe(100);
+ expect(service.incomingRequests.errorPercentage).toBe(0);
+ expect(service.incomingRequestsByClient.size).toBe(0);
+ });
+
+ it('should aggregate multiple rows for same service and status', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ requestCount: 100,
+ },
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ requestCount: 50,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+
+ expect(result.size).toBe(1);
+ const service = result.get('api-service')!;
+ expect(service.incomingRequests.totalRequests).toBe(150);
+ expect(service.incomingRequests.requestCountByStatus.get('Ok')).toBe(150);
+ });
+
+ it('should aggregate multiple status codes for same service', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ requestCount: 100,
+ },
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Error',
+ requestCount: 10,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+
+ expect(result.size).toBe(1);
+ const service = result.get('api-service')!;
+ expect(service.incomingRequests.totalRequests).toBe(110);
+ expect(service.incomingRequests.requestCountByStatus.get('Ok')).toBe(100);
+ expect(service.incomingRequests.requestCountByStatus.get('Error')).toBe(
+ 10,
+ );
+ });
+
+ it('should aggregate multiple services', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ requestCount: 100,
+ },
+ {
+ serverServiceName: 'db-service',
+ serverStatusCode: 'Ok',
+ requestCount: 200,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+
+ expect(result.size).toBe(2);
+ expect(result.has('api-service')).toBe(true);
+ expect(result.has('db-service')).toBe(true);
+ expect(result.get('api-service')!.incomingRequests.totalRequests).toBe(
+ 100,
+ );
+ expect(result.get('db-service')!.incomingRequests.totalRequests).toBe(
+ 200,
+ );
+ });
+ });
+
+ describe('error percentage calculation', () => {
+ it('should calculate error percentage correctly', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ requestCount: 70,
+ },
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Unset',
+ requestCount: 20,
+ },
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Error',
+ requestCount: 10,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+ const service = result.get('api-service')!;
+
+ expect(service.incomingRequests.totalRequests).toBe(100);
+ expect(service.incomingRequests.errorPercentage).toBe(10);
+ });
+
+ it('should calculate 0% error when no errors', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ requestCount: 100,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+ const service = result.get('api-service')!;
+
+ expect(service.incomingRequests.errorPercentage).toBe(0);
+ });
+
+ it('should calculate 100% error when all errors', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Error',
+ requestCount: 100,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+ const service = result.get('api-service')!;
+
+ expect(service.incomingRequests.errorPercentage).toBe(100);
+ });
+
+ it('should calculate precise error percentages', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ requestCount: 97,
+ },
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Error',
+ requestCount: 3,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+ const service = result.get('api-service')!;
+
+ expect(service.incomingRequests.errorPercentage).toBe(3);
+ });
+
+ it('should handle 0 total requests without division by zero', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ clientServiceName: 'client-service',
+ requestCount: 100,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+
+ // client-service is added but has no incoming requests
+ const clientService = result.get('client-service')!;
+ expect(clientService.incomingRequests.totalRequests).toBe(0);
+ expect(clientService.incomingRequests.errorPercentage).toBe(0);
+ });
+ });
+
+ describe('client service aggregation', () => {
+ it('should aggregate requests by client service', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ clientServiceName: 'web-service',
+ requestCount: 100,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+
+ expect(result.size).toBe(2);
+ expect(result.has('api-service')).toBe(true);
+ expect(result.has('web-service')).toBe(true);
+
+ const service = result.get('api-service')!;
+ expect(service.incomingRequests.totalRequests).toBe(100);
+ expect(service.incomingRequestsByClient.size).toBe(1);
+ expect(service.incomingRequestsByClient.has('web-service')).toBe(true);
+
+ const clientStats = service.incomingRequestsByClient.get('web-service')!;
+ expect(clientStats.totalRequests).toBe(100);
+ expect(clientStats.requestCountByStatus.get('Ok')).toBe(100);
+ });
+
+ it('should aggregate multiple clients calling same service', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ clientServiceName: 'web-service',
+ requestCount: 100,
+ },
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ clientServiceName: 'mobile-service',
+ requestCount: 50,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+
+ expect(result.size).toBe(3); // api-service, web-service, mobile-service
+ const service = result.get('api-service')!;
+ expect(service.incomingRequests.totalRequests).toBe(150);
+ expect(service.incomingRequestsByClient.size).toBe(2);
+
+ const webStats = service.incomingRequestsByClient.get('web-service')!;
+ expect(webStats.totalRequests).toBe(100);
+
+ const mobileStats =
+ service.incomingRequestsByClient.get('mobile-service')!;
+ expect(mobileStats.totalRequests).toBe(50);
+ });
+
+ it('should aggregate multiple requests from same client with different status codes', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ clientServiceName: 'web-service',
+ requestCount: 90,
+ },
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Error',
+ clientServiceName: 'web-service',
+ requestCount: 10,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+ const service = result.get('api-service')!;
+ const clientStats = service.incomingRequestsByClient.get('web-service')!;
+
+ expect(clientStats.totalRequests).toBe(100);
+ expect(clientStats.requestCountByStatus.get('Ok')).toBe(90);
+ expect(clientStats.requestCountByStatus.get('Error')).toBe(10);
+ expect(clientStats.errorPercentage).toBe(10);
+ });
+
+ it('should handle mix of requests with and without client service names', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ clientServiceName: 'web-service',
+ requestCount: 100,
+ },
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ requestCount: 50, // No client service (uninstrumented)
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+
+ const service = result.get('api-service')!;
+ expect(service.incomingRequests.totalRequests).toBe(150);
+ expect(service.incomingRequestsByClient.size).toBe(1);
+ expect(service.incomingRequestsByClient.has('web-service')).toBe(true);
+ });
+
+ it('should create client service entry even if client has no incoming requests', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ clientServiceName: 'web-service',
+ requestCount: 100,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+
+ expect(result.has('web-service')).toBe(true);
+ const clientService = result.get('web-service')!;
+ expect(clientService.serviceName).toBe('web-service');
+ expect(clientService.incomingRequests.totalRequests).toBe(0);
+ expect(clientService.incomingRequestsByClient.size).toBe(0);
+ expect(clientService.incomingRequests.errorPercentage).toBe(0);
+ });
+ });
+
+ describe('complex scenarios', () => {
+ it('should handle service chain (A -> B -> C)', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'service-b',
+ serverStatusCode: 'Ok',
+ clientServiceName: 'service-a',
+ requestCount: 100,
+ },
+ {
+ serverServiceName: 'service-c',
+ serverStatusCode: 'Ok',
+ clientServiceName: 'service-b',
+ requestCount: 100,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+
+ expect(result.size).toBe(3);
+
+ // Service A (no incoming requests)
+ const serviceA = result.get('service-a')!;
+ expect(serviceA.incomingRequests.totalRequests).toBe(0);
+
+ // Service B (receives from A, calls C)
+ const serviceB = result.get('service-b')!;
+ expect(serviceB.incomingRequests.totalRequests).toBe(100);
+ expect(serviceB.incomingRequestsByClient.has('service-a')).toBe(true);
+
+ // Service C (receives from B)
+ const serviceC = result.get('service-c')!;
+ expect(serviceC.incomingRequests.totalRequests).toBe(100);
+ expect(serviceC.incomingRequestsByClient.has('service-b')).toBe(true);
+ });
+
+ it('should handle multiple clients with different error rates', () => {
+ const data: SpanAggregationRow[] = [
+ // Client 1: 10% error rate
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ clientServiceName: 'client-1',
+ requestCount: 90,
+ },
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Error',
+ clientServiceName: 'client-1',
+ requestCount: 10,
+ },
+ // Client 2: 50% error rate
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ clientServiceName: 'client-2',
+ requestCount: 50,
+ },
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Error',
+ clientServiceName: 'client-2',
+ requestCount: 50,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+ const service = result.get('api-service')!;
+
+ // Overall error rate: 60 errors out of 200 = 30%
+ expect(service.incomingRequests.totalRequests).toBe(200);
+ expect(service.incomingRequests.errorPercentage).toBe(30);
+
+ // Client 1: 10% error rate
+ const client1Stats = service.incomingRequestsByClient.get('client-1')!;
+ expect(client1Stats.errorPercentage).toBe(10);
+
+ // Client 2: 50% error rate
+ const client2Stats = service.incomingRequestsByClient.get('client-2')!;
+ expect(client2Stats.errorPercentage).toBe(50);
+ });
+
+ it('should handle large request counts', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ requestCount: 1_000_000,
+ },
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Error',
+ requestCount: 1_000,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+ const service = result.get('api-service')!;
+
+ expect(service.incomingRequests.totalRequests).toBe(1_001_000);
+ expect(service.incomingRequests.errorPercentage).toBeCloseTo(0.0999, 4);
+ });
+
+ it('should handle different status code strings', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ requestCount: 50,
+ },
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Error',
+ requestCount: 10,
+ },
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Unset',
+ requestCount: 40,
+ },
+ ];
+
+ const result = aggregateServiceMapData(data);
+ const service = result.get('api-service')!;
+
+ expect(service.incomingRequests.totalRequests).toBe(100);
+ expect(service.incomingRequests.requestCountByStatus.get('Ok')).toBe(50);
+ expect(service.incomingRequests.requestCountByStatus.get('Error')).toBe(
+ 10,
+ );
+ expect(service.incomingRequests.requestCountByStatus.get('Unset')).toBe(
+ 40,
+ );
+ expect(service.incomingRequests.errorPercentage).toBe(10);
+ });
+ });
+
+ describe('data structure integrity', () => {
+ it('should not mutate input data', () => {
+ const data: SpanAggregationRow[] = [
+ {
+ serverServiceName: 'api-service',
+ serverStatusCode: 'Ok',
+ requestCount: 100,
+ },
+ ];
+
+ const originalData = JSON.parse(JSON.stringify(data));
+ aggregateServiceMapData(data);
+
+ expect(data).toEqual(originalData);
+ });
+ });
+});
diff --git a/packages/app/src/hooks/useServiceMap.tsx b/packages/app/src/hooks/useServiceMap.tsx
new file mode 100644
index 00000000..eb26554e
--- /dev/null
+++ b/packages/app/src/hooks/useServiceMap.tsx
@@ -0,0 +1,296 @@
+import SqlString from 'sqlstring';
+import { chSql } from '@hyperdx/common-utils/dist/clickhouse';
+import { Metadata } from '@hyperdx/common-utils/dist/core/metadata';
+import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
+import { TSource } from '@hyperdx/common-utils/dist/types';
+import { useQuery } from '@tanstack/react-query';
+
+import { useClickhouseClient } from '@/clickhouse';
+
+import { useMetadataWithSettings } from './useMetadata';
+
+export type SpanAggregationRow = {
+ serverServiceName: string;
+ serverStatusCode: string;
+ requestCount: number;
+ clientServiceName?: string;
+};
+
+async function getServiceMapQuery({
+ source,
+ dateRange,
+ traceId,
+ metadata,
+ samplingFactor,
+}: {
+ source: TSource;
+ dateRange: [Date, Date];
+ traceId?: string;
+ metadata: Metadata;
+ samplingFactor: number;
+}) {
+ // Don't sample if we're looking for a specific trace
+ const effectiveSamplingLevel = traceId ? 1 : samplingFactor;
+
+ const baseCTEConfig = {
+ from: source.from,
+ connection: source.connection,
+ dateRange,
+ timestampValueExpression: source.timestampValueExpression,
+ filters: [
+ // Sample a subset of traces, for performance in the following join
+ {
+ type: 'sql' as const,
+ condition: `cityHash64(${source.traceIdExpression}) % ${effectiveSamplingLevel} = 0`,
+ },
+ // Optionally filter for a specific trace ID
+ ...(traceId
+ ? [
+ {
+ type: 'sql' as const,
+ condition: SqlString.format('?? = ?', [
+ source.traceIdExpression,
+ traceId,
+ ]),
+ },
+ ]
+ : []),
+ ],
+ select: [
+ {
+ valueExpression: source.traceIdExpression ?? 'TraceId',
+ alias: 'traceId',
+ },
+ {
+ valueExpression: source.spanIdExpression ?? 'SpanId',
+ alias: 'spanId',
+ },
+ {
+ valueExpression: source.serviceNameExpression ?? 'ServiceName',
+ alias: 'serviceName',
+ },
+ {
+ valueExpression: source.parentSpanIdExpression ?? 'ParentSpanId',
+ alias: 'parentSpanId',
+ },
+ {
+ valueExpression: source.statusCodeExpression ?? 'StatusCode',
+ alias: 'statusCode',
+ },
+ ],
+ };
+
+ const [serverCTE, clientCTE] = await Promise.all([
+ renderChartConfig(
+ {
+ ...baseCTEConfig,
+ filters: [
+ ...baseCTEConfig.filters,
+ {
+ type: 'sql',
+ condition: `${source.spanKindExpression} IN ('Server', 'Consumer')`,
+ },
+ ],
+ where: '',
+ },
+ metadata,
+ ),
+ renderChartConfig(
+ {
+ ...baseCTEConfig,
+ filters: [
+ ...baseCTEConfig.filters,
+ {
+ type: 'sql',
+ condition: `${source.spanKindExpression} IN ('Client', 'Producer')`,
+ },
+ ],
+ where: '',
+ },
+ metadata,
+ ),
+ ]);
+
+ // Left join to support services which receive requests from clients that are not instrumented.
+ // Ordering helps ensure stable graph layout.
+ return chSql`
+ WITH
+ ServerSpans AS (${serverCTE}),
+ ClientSpans AS (${clientCTE})
+ SELECT
+ ServerSpans.serviceName AS serverServiceName,
+ ServerSpans.statusCode AS serverStatusCode,
+ ClientSpans.serviceName AS clientServiceName,
+ count(*) * ${{ Int64: effectiveSamplingLevel }} as requestCount
+ FROM ServerSpans
+ LEFT JOIN ClientSpans
+ ON ServerSpans.traceId = ClientSpans.traceId
+ AND ServerSpans.parentSpanId = ClientSpans.spanId
+ WHERE (ClientSpans.serviceName IS NULL OR ServerSpans.serviceName != ClientSpans.serviceName)
+ GROUP BY serverServiceName, serverStatusCode, clientServiceName
+ ORDER BY serverServiceName, serverStatusCode, clientServiceName
+ `;
+}
+
+type IncomingRequestStats = {
+ totalRequests: number;
+ requestCountByStatus: Map;
+ errorPercentage: number;
+};
+
+export type ServiceAggregation = {
+ serviceName: string;
+ incomingRequests: IncomingRequestStats;
+ incomingRequestsByClient: Map;
+};
+
+export function aggregateServiceMapData(data: SpanAggregationRow[]) {
+ // Aggregate data by service
+ const services = new Map();
+ for (const row of data) {
+ const {
+ serverServiceName,
+ serverStatusCode,
+ clientServiceName,
+ requestCount,
+ } = row;
+
+ if (!services.has(serverServiceName)) {
+ services.set(serverServiceName, {
+ serviceName: serverServiceName,
+ incomingRequests: {
+ totalRequests: 0,
+ requestCountByStatus: new Map(),
+ errorPercentage: 0,
+ },
+ incomingRequestsByClient: new Map(),
+ });
+ }
+
+ const service = services.get(serverServiceName)!;
+
+ // Add to total incoming request count
+ service.incomingRequests.totalRequests += requestCount;
+
+ // Add to request count per status
+ const currentStatusCount =
+ service.incomingRequests.requestCountByStatus.get(serverStatusCode) || 0;
+ service.incomingRequests.requestCountByStatus.set(
+ serverStatusCode,
+ currentStatusCount + requestCount,
+ );
+
+ // Add to request count per client per status
+ if (clientServiceName) {
+ if (!service.incomingRequestsByClient.has(clientServiceName)) {
+ service.incomingRequestsByClient.set(clientServiceName, {
+ totalRequests: 0,
+ requestCountByStatus: new Map(),
+ errorPercentage: 0,
+ });
+ }
+
+ const perClientStats =
+ service.incomingRequestsByClient.get(clientServiceName)!;
+ perClientStats.totalRequests += requestCount;
+
+ const currentClientStatusCount =
+ perClientStats.requestCountByStatus.get(serverStatusCode) || 0;
+ perClientStats.requestCountByStatus.set(
+ serverStatusCode,
+ currentClientStatusCount + requestCount,
+ );
+
+ if (!services.has(clientServiceName)) {
+ services.set(clientServiceName, {
+ serviceName: clientServiceName,
+ incomingRequests: {
+ totalRequests: 0,
+ requestCountByStatus: new Map(),
+ errorPercentage: 0,
+ },
+ incomingRequestsByClient: new Map(),
+ });
+ }
+ }
+ }
+
+ // Calculate error percentages for all services and their client stats
+ for (const service of services.values()) {
+ // Calculate error percentage for total incoming requests
+ const errorCount =
+ service.incomingRequests.requestCountByStatus.get('Error') || 0;
+ service.incomingRequests.errorPercentage =
+ service.incomingRequests.totalRequests > 0
+ ? (errorCount / service.incomingRequests.totalRequests) * 100
+ : 0;
+
+ // Calculate error percentage for each client
+ for (const clientStats of service.incomingRequestsByClient.values()) {
+ const clientErrorCount =
+ clientStats.requestCountByStatus.get('Error') || 0;
+ clientStats.errorPercentage =
+ clientStats.totalRequests > 0
+ ? (clientErrorCount / clientStats.totalRequests) * 100
+ : 0;
+ }
+ }
+
+ return services;
+}
+
+export default function useServiceMap({
+ source,
+ dateRange,
+ traceId,
+ samplingFactor,
+}: {
+ source: TSource;
+ dateRange: [Date, Date];
+ traceId?: string;
+ samplingFactor: number;
+}) {
+ const client = useClickhouseClient();
+ const metadata = useMetadataWithSettings();
+
+ return useQuery({
+ queryKey: ['serviceMapData', traceId, source, dateRange, samplingFactor],
+ queryFn: async ({ signal }) => {
+ const query = await getServiceMapQuery({
+ source,
+ dateRange,
+ traceId,
+ metadata,
+ samplingFactor,
+ });
+
+ const data = await client
+ .query({
+ query: query.sql,
+ query_params: query.params,
+ connectionId: source.connection,
+ format: 'JSON',
+ abort_signal: signal,
+ clickhouse_settings: {
+ max_execution_time: 60,
+ join_algorithm: 'auto',
+ },
+ })
+ .then(res => res.json>())
+ .then(data =>
+ data.data.map((row: Record) => ({
+ serverServiceName: row.serverServiceName,
+ serverStatusCode: row.serverStatusCode,
+ clientServiceName: row.clientServiceName,
+ requestCount: Number.parseInt(row.requestCount),
+ })),
+ );
+
+ return aggregateServiceMapData(data);
+ },
+ // Prevent refetching and updating the map layout
+ staleTime: Infinity,
+ refetchOnWindowFocus: false,
+ retry: 1,
+ });
+}
diff --git a/yarn.lock b/yarn.lock
index bda6933e..aa74ee95 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3402,6 +3402,22 @@ __metadata:
languageName: node
linkType: hard
+"@dagrejs/dagre@npm:^1.1.5":
+ version: 1.1.5
+ resolution: "@dagrejs/dagre@npm:1.1.5"
+ dependencies:
+ "@dagrejs/graphlib": "npm:2.2.4"
+ checksum: 10c0/738e2d86ee093b99ec96098f1a4d0b6290b6ddaef51db912885df0ab5d09291edb82ef875575a4b321bc13bab78b1cb00347b246aad4099ce4af3202a72a8586
+ languageName: node
+ linkType: hard
+
+"@dagrejs/graphlib@npm:2.2.4":
+ version: 2.2.4
+ resolution: "@dagrejs/graphlib@npm:2.2.4"
+ checksum: 10c0/14597ea9294c46b2571aee78bcaad3a24e3e5e0ebcdf198b6eae5b3805f99af727ac54a477dd9152e8b0a576efea0528fb7d4919c74801e9f669c90e5e6f5bd9
+ languageName: node
+ linkType: hard
+
"@discoveryjs/json-ext@npm:^0.5.3":
version: 0.5.7
resolution: "@discoveryjs/json-ext@npm:0.5.7"
@@ -4384,6 +4400,7 @@ __metadata:
"@chromatic-com/storybook": "npm:^1.5.0"
"@codemirror/lang-json": "npm:^6.0.1"
"@codemirror/lang-sql": "npm:^6.7.0"
+ "@dagrejs/dagre": "npm:^1.1.5"
"@hookform/devtools": "npm:^4.3.1"
"@hookform/resolvers": "npm:^3.9.0"
"@hyperdx/browser": "npm:^0.21.1"
@@ -4435,6 +4452,7 @@ __metadata:
"@uiw/codemirror-theme-atomone": "npm:^4.23.3"
"@uiw/codemirror-themes": "npm:^4.23.3"
"@uiw/react-codemirror": "npm:^4.23.3"
+ "@xyflow/react": "npm:^12.9.0"
bootstrap: "npm:^5.1.3"
chrono-node: "npm:^2.7.8"
classnames: "npm:^2.3.1"
@@ -9631,6 +9649,15 @@ __metadata:
languageName: node
linkType: hard
+"@types/d3-drag@npm:^3.0.7":
+ version: 3.0.7
+ resolution: "@types/d3-drag@npm:3.0.7"
+ dependencies:
+ "@types/d3-selection": "npm:*"
+ checksum: 10c0/65e29fa32a87c72d26c44b5e2df3bf15af21cd128386bcc05bcacca255927c0397d0cd7e6062aed5f0abd623490544a9d061c195f5ed9f018fe0b698d99c079d
+ languageName: node
+ linkType: hard
+
"@types/d3-ease@npm:^3.0.0":
version: 3.0.0
resolution: "@types/d3-ease@npm:3.0.0"
@@ -9638,6 +9665,15 @@ __metadata:
languageName: node
linkType: hard
+"@types/d3-interpolate@npm:*, @types/d3-interpolate@npm:^3.0.4":
+ version: 3.0.4
+ resolution: "@types/d3-interpolate@npm:3.0.4"
+ dependencies:
+ "@types/d3-color": "npm:*"
+ checksum: 10c0/066ebb8da570b518dd332df6b12ae3b1eaa0a7f4f0c702e3c57f812cf529cc3500ec2aac8dc094f31897790346c6b1ebd8cd7a077176727f4860c2b181a65ca4
+ languageName: node
+ linkType: hard
+
"@types/d3-interpolate@npm:^3.0.1":
version: 3.0.1
resolution: "@types/d3-interpolate@npm:3.0.1"
@@ -9663,6 +9699,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/d3-selection@npm:*, @types/d3-selection@npm:^3.0.10":
+ version: 3.0.11
+ resolution: "@types/d3-selection@npm:3.0.11"
+ checksum: 10c0/0c512956c7503ff5def4bb32e0c568cc757b9a2cc400a104fc0f4cfe5e56d83ebde2a97821b6f2cb26a7148079d3b86a2f28e11d68324ed311cf35c2ed980d1d
+ languageName: node
+ linkType: hard
+
"@types/d3-shape@npm:^3.1.0":
version: 3.1.1
resolution: "@types/d3-shape@npm:3.1.1"
@@ -9686,6 +9729,25 @@ __metadata:
languageName: node
linkType: hard
+"@types/d3-transition@npm:^3.0.8":
+ version: 3.0.9
+ resolution: "@types/d3-transition@npm:3.0.9"
+ dependencies:
+ "@types/d3-selection": "npm:*"
+ checksum: 10c0/4f68b9df7ac745b3491216c54203cbbfa0f117ae4c60e2609cdef2db963582152035407fdff995b10ee383bae2f05b7743493f48e1b8e46df54faa836a8fb7b5
+ languageName: node
+ linkType: hard
+
+"@types/d3-zoom@npm:^3.0.8":
+ version: 3.0.8
+ resolution: "@types/d3-zoom@npm:3.0.8"
+ dependencies:
+ "@types/d3-interpolate": "npm:*"
+ "@types/d3-selection": "npm:*"
+ checksum: 10c0/1dbdbcafddcae12efb5beb6948546963f29599e18bc7f2a91fb69cc617c2299a65354f2d47e282dfb86fec0968406cd4fb7f76ba2d2fb67baa8e8d146eb4a547
+ languageName: node
+ linkType: hard
+
"@types/debug@npm:^4.0.0":
version: 4.1.7
resolution: "@types/debug@npm:4.1.7"
@@ -11210,6 +11272,37 @@ __metadata:
languageName: node
linkType: hard
+"@xyflow/react@npm:^12.9.0":
+ version: 12.9.0
+ resolution: "@xyflow/react@npm:12.9.0"
+ dependencies:
+ "@xyflow/system": "npm:0.0.71"
+ classcat: "npm:^5.0.3"
+ zustand: "npm:^4.4.0"
+ peerDependencies:
+ react: ">=17"
+ react-dom: ">=17"
+ checksum: 10c0/bf349a119d27aa22c3ed34afe2315a325c5ed35aa328c0c0c4eee7751e07828a6c0f32839c1240e839c6b6221b0c67b47030e4c62ef59ed4f06f8b1bcf4c5203
+ languageName: node
+ linkType: hard
+
+"@xyflow/system@npm:0.0.71":
+ version: 0.0.71
+ resolution: "@xyflow/system@npm:0.0.71"
+ dependencies:
+ "@types/d3-drag": "npm:^3.0.7"
+ "@types/d3-interpolate": "npm:^3.0.4"
+ "@types/d3-selection": "npm:^3.0.10"
+ "@types/d3-transition": "npm:^3.0.8"
+ "@types/d3-zoom": "npm:^3.0.8"
+ d3-drag: "npm:^3.0.0"
+ d3-interpolate: "npm:^3.0.1"
+ d3-selection: "npm:^3.0.0"
+ d3-zoom: "npm:^3.0.0"
+ checksum: 10c0/c03db95ac41a1edd651a7941144640fc90f3a21e4eede1813e25525534229506e73625c8092308a5fb690147265336437f4dd6bbe781224ab2c12df6b98eb23d
+ languageName: node
+ linkType: hard
+
"@yarnpkg/esbuild-plugin-pnp@npm:^3.0.0-rc.10":
version: 3.0.0-rc.15
resolution: "@yarnpkg/esbuild-plugin-pnp@npm:3.0.0-rc.15"
@@ -13100,6 +13193,13 @@ __metadata:
languageName: node
linkType: hard
+"classcat@npm:^5.0.3":
+ version: 5.0.5
+ resolution: "classcat@npm:5.0.5"
+ checksum: 10c0/ff8d273055ef9b518529cfe80fd0486f7057a9917373807ff802d75ceb46e8f8e148f41fa094ee7625c8f34642cfaa98395ff182d9519898da7cbf383d4a210d
+ languageName: node
+ linkType: hard
+
"classnames@npm:^2.3.1":
version: 2.3.2
resolution: "classnames@npm:2.3.2"
@@ -13992,7 +14092,24 @@ __metadata:
languageName: node
linkType: hard
-"d3-ease@npm:^3.0.1":
+"d3-dispatch@npm:1 - 3":
+ version: 3.0.1
+ resolution: "d3-dispatch@npm:3.0.1"
+ checksum: 10c0/6eca77008ce2dc33380e45d4410c67d150941df7ab45b91d116dbe6d0a3092c0f6ac184dd4602c796dc9e790222bad3ff7142025f5fd22694efe088d1d941753
+ languageName: node
+ linkType: hard
+
+"d3-drag@npm:2 - 3, d3-drag@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "d3-drag@npm:3.0.0"
+ dependencies:
+ d3-dispatch: "npm:1 - 3"
+ d3-selection: "npm:3"
+ checksum: 10c0/d2556e8dc720741a443b595a30af403dd60642dfd938d44d6e9bfc4c71a962142f9a028c56b61f8b4790b65a34acad177d1263d66f103c3c527767b0926ef5aa
+ languageName: node
+ linkType: hard
+
+"d3-ease@npm:1 - 3, d3-ease@npm:^3.0.1":
version: 3.0.1
resolution: "d3-ease@npm:3.0.1"
checksum: 10c0/fec8ef826c0cc35cda3092c6841e07672868b1839fcaf556e19266a3a37e6bc7977d8298c0fcb9885e7799bfdcef7db1baaba9cd4dcf4bc5e952cf78574a88b0
@@ -14006,7 +14123,7 @@ __metadata:
languageName: node
linkType: hard
-"d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1":
+"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1":
version: 3.0.1
resolution: "d3-interpolate@npm:3.0.1"
dependencies:
@@ -14035,6 +14152,13 @@ __metadata:
languageName: node
linkType: hard
+"d3-selection@npm:2 - 3, d3-selection@npm:3, d3-selection@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "d3-selection@npm:3.0.0"
+ checksum: 10c0/e59096bbe8f0cb0daa1001d9bdd6dbc93a688019abc97d1d8b37f85cd3c286a6875b22adea0931b0c88410d025563e1643019161a883c516acf50c190a11b56b
+ languageName: node
+ linkType: hard
+
"d3-shape@npm:^3.1.0":
version: 3.2.0
resolution: "d3-shape@npm:3.2.0"
@@ -14062,13 +14186,41 @@ __metadata:
languageName: node
linkType: hard
-"d3-timer@npm:^3.0.1":
+"d3-timer@npm:1 - 3, d3-timer@npm:^3.0.1":
version: 3.0.1
resolution: "d3-timer@npm:3.0.1"
checksum: 10c0/d4c63cb4bb5461d7038aac561b097cd1c5673969b27cbdd0e87fa48d9300a538b9e6f39b4a7f0e3592ef4f963d858c8a9f0e92754db73116770856f2fc04561a
languageName: node
linkType: hard
+"d3-transition@npm:2 - 3":
+ version: 3.0.1
+ resolution: "d3-transition@npm:3.0.1"
+ dependencies:
+ d3-color: "npm:1 - 3"
+ d3-dispatch: "npm:1 - 3"
+ d3-ease: "npm:1 - 3"
+ d3-interpolate: "npm:1 - 3"
+ d3-timer: "npm:1 - 3"
+ peerDependencies:
+ d3-selection: 2 - 3
+ checksum: 10c0/4e74535dda7024aa43e141635b7522bb70cf9d3dfefed975eb643b36b864762eca67f88fafc2ca798174f83ca7c8a65e892624f824b3f65b8145c6a1a88dbbad
+ languageName: node
+ linkType: hard
+
+"d3-zoom@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "d3-zoom@npm:3.0.0"
+ dependencies:
+ d3-dispatch: "npm:1 - 3"
+ d3-drag: "npm:2 - 3"
+ d3-interpolate: "npm:1 - 3"
+ d3-selection: "npm:2 - 3"
+ d3-transition: "npm:2 - 3"
+ checksum: 10c0/ee2036479049e70d8c783d594c444fe00e398246048e3f11a59755cd0e21de62ece3126181b0d7a31bf37bcf32fd726f83ae7dea4495ff86ec7736ce5ad36fd3
+ languageName: node
+ linkType: hard
+
"damerau-levenshtein@npm:^1.0.8":
version: 1.0.8
resolution: "damerau-levenshtein@npm:1.0.8"
@@ -28934,6 +29086,15 @@ __metadata:
languageName: node
linkType: hard
+"use-sync-external-store@npm:^1.2.2":
+ version: 1.6.0
+ resolution: "use-sync-external-store@npm:1.6.0"
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ checksum: 10c0/35e1179f872a53227bdf8a827f7911da4c37c0f4091c29b76b1e32473d1670ebe7bcd880b808b7549ba9a5605c233350f800ffab963ee4a4ee346ee983b6019b
+ languageName: node
+ linkType: hard
+
"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1":
version: 1.0.2
resolution: "util-deprecate@npm:1.0.2"
@@ -29834,6 +29995,26 @@ __metadata:
languageName: node
linkType: hard
+"zustand@npm:^4.4.0":
+ version: 4.5.7
+ resolution: "zustand@npm:4.5.7"
+ dependencies:
+ use-sync-external-store: "npm:^1.2.2"
+ peerDependencies:
+ "@types/react": ">=16.8"
+ immer: ">=9.0.6"
+ react: ">=16.8"
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ checksum: 10c0/55559e37a82f0c06cadc61cb08f08314c0fe05d6a93815e41e3376130c13db22a5017cbb0cd1f018c82f2dad0051afe3592561d40f980bd4082e32005e8a950c
+ languageName: node
+ linkType: hard
+
"zwitch@npm:^2.0.0":
version: 2.0.4
resolution: "zwitch@npm:2.0.4"