From c83c811e8e3649d9151de459b1bd06c20b8b8f6f Mon Sep 17 00:00:00 2001 From: Alisha Aneja Date: Tue, 7 Apr 2026 11:10:22 +1000 Subject: [PATCH] Add new optimisations --- chartkit/src/lib/components/BarChart.tsx | 55 +++-------- chartkit/src/lib/components/DataTable.tsx | 9 +- chartkit/src/lib/components/LineChart.tsx | 55 +++-------- chartkit/src/lib/components/useChartSeries.ts | 97 +++++++++++++++++++ chartkit/src/lib/format.ts | 30 ++++-- chartkit/src/lib/runtime/db-manager.ts | 6 +- 6 files changed, 146 insertions(+), 106 deletions(-) create mode 100644 chartkit/src/lib/components/useChartSeries.ts diff --git a/chartkit/src/lib/components/BarChart.tsx b/chartkit/src/lib/components/BarChart.tsx index b671367..64f9de4 100644 --- a/chartkit/src/lib/components/BarChart.tsx +++ b/chartkit/src/lib/components/BarChart.tsx @@ -1,10 +1,10 @@ import { useQuery } from "../useQuery"; import { formatChartKitDisplayValue } from "../format"; -import { asNumber, asString } from "./utils"; import { chartKitTooltipBase } from "./chartTooltip"; import { echarts, ReactEChartsCore } from "./echartsCore"; import { themeVars } from "./themeVars"; import { useChartKitNamespace } from "./useChartKitNamespace"; +import { useChartSeries } from "./useChartSeries"; export interface BarChartProps { namespace: string; @@ -39,6 +39,16 @@ export function BarChart({ }: BarChartProps) { const theme = useChartKitNamespace(namespace).theme; const { loading, error, rows } = useQuery(namespace, data); + const { xLabels, rankedSeries } = useChartSeries({ + rows, + xValueField, + xLabelField, + yValueField, + yLabelField, + yAxisTitle, + seriesValueField, + seriesLabelField, + }); if (loading) { return
Loading...
; @@ -46,53 +56,10 @@ export function BarChart({ if (error) { return
Error: {error}
; } - - const xValues = new Map(); - const seriesLabels = new Map(); - const matrix = new Map>(); - - for (const row of rows) { - const xValue = asString(row[xValueField]); - const xLabelCandidate = xLabelField ? asString(row[xLabelField]) : ""; - const xLabel = xLabelCandidate || xValue; - if (!xValues.has(xValue)) { - xValues.set(xValue, xLabel); - } - - const seriesValue = seriesValueField ? asString(row[seriesValueField]) : "__single"; - const seriesLabelCandidate = seriesLabelField ? asString(row[seriesLabelField]) : ""; - const fallbackSeriesLabel = yAxisTitle ?? yLabelField ?? "Value"; - const seriesLabel = seriesValueField - ? seriesLabelCandidate || seriesValue - : fallbackSeriesLabel; - if (!seriesLabels.has(seriesValue)) { - seriesLabels.set(seriesValue, seriesLabel); - } - - const yValue = asNumber(row[yValueField]); - const currentSeries = matrix.get(seriesValue) ?? new Map(); - currentSeries.set(xValue, (currentSeries.get(xValue) ?? 0) + yValue); - matrix.set(seriesValue, currentSeries); - } - - const xKeys = Array.from(xValues.keys()); - const xLabels = xKeys.map((key) => xValues.get(key) ?? key); const shouldRotate = xLabels.length > 8 || xLabels.some((label) => label.length > 10); const valueFmt = yFmt ?? fmt; const palette = theme.charts.palette; - const rankedSeries = Array.from(seriesLabels.entries()) - .map(([seriesKey, seriesName]) => ({ - seriesKey, - seriesName, - values: xKeys.map((xKey) => matrix.get(seriesKey)?.get(xKey) ?? 0), - })) - .map((entry) => ({ - ...entry, - total: entry.values.reduce((sum, value) => sum + value, 0), - })) - .sort((left, right) => right.total - left.total || left.seriesName.localeCompare(right.seriesName)); - const series = rankedSeries.map(({ seriesName, values }, index) => ({ name: seriesName, type: "bar", diff --git a/chartkit/src/lib/components/DataTable.tsx b/chartkit/src/lib/components/DataTable.tsx index dc39730..b52599b 100644 --- a/chartkit/src/lib/components/DataTable.tsx +++ b/chartkit/src/lib/components/DataTable.tsx @@ -1,6 +1,6 @@ import { Children, isValidElement, type ReactNode } from "react"; import { useQuery } from "../useQuery"; -import { formatChartKitDisplayValue } from "../format"; +import { formatChartKitDisplayValue, formatChartKitNumber } from "../format"; import type { DataTableColumnDefinition } from "../runtime/definitions"; import { DataColumn, type DataColumnProps } from "./DataColumn"; import { OverflowTooltip } from "./OverflowTooltip"; @@ -85,13 +85,10 @@ export function DataTable({ namespace, data, columns, children }: DataTableProps } if (typeof value === "number") { const fractionDigits = Number.isInteger(value) ? 0 : 2; - return value.toLocaleString("en-AU", { - minimumFractionDigits: fractionDigits, - maximumFractionDigits: fractionDigits, - }); + return formatChartKitNumber(value, `num${fractionDigits}`); } if (typeof value === "bigint") { - return value.toLocaleString("en-AU"); + return formatChartKitNumber(Number(value), "num0"); } return asString(value); }; diff --git a/chartkit/src/lib/components/LineChart.tsx b/chartkit/src/lib/components/LineChart.tsx index f4a161c..f73975b 100644 --- a/chartkit/src/lib/components/LineChart.tsx +++ b/chartkit/src/lib/components/LineChart.tsx @@ -1,10 +1,10 @@ import { useQuery } from "../useQuery"; import { formatChartKitDisplayValue } from "../format"; -import { asNumber, asString } from "./utils"; import { chartKitTooltipBase } from "./chartTooltip"; import { echarts, ReactEChartsCore } from "./echartsCore"; import { themeVars } from "./themeVars"; import { useChartKitNamespace } from "./useChartKitNamespace"; +import { useChartSeries } from "./useChartSeries"; export interface LineChartProps { namespace: string; @@ -37,6 +37,16 @@ export function LineChart({ }: LineChartProps) { const theme = useChartKitNamespace(namespace).theme; const { loading, error, rows } = useQuery(namespace, data); + const { xLabels, rankedSeries } = useChartSeries({ + rows, + xValueField, + xLabelField, + yValueField, + yLabelField, + yAxisTitle, + seriesValueField, + seriesLabelField, + }); if (loading) { return
Loading...
; @@ -44,53 +54,10 @@ export function LineChart({ if (error) { return
Error: {error}
; } - - const xValues = new Map(); - const seriesLabels = new Map(); - const matrix = new Map>(); - - for (const row of rows) { - const xValue = asString(row[xValueField]); - const xLabelCandidate = xLabelField ? asString(row[xLabelField]) : ""; - const xLabel = xLabelCandidate || xValue; - if (!xValues.has(xValue)) { - xValues.set(xValue, xLabel); - } - - const seriesValue = seriesValueField ? asString(row[seriesValueField]) : "__single"; - const seriesLabelCandidate = seriesLabelField ? asString(row[seriesLabelField]) : ""; - const fallbackSeriesLabel = yAxisTitle ?? yLabelField ?? "Value"; - const seriesLabel = seriesValueField - ? seriesLabelCandidate || seriesValue - : fallbackSeriesLabel; - if (!seriesLabels.has(seriesValue)) { - seriesLabels.set(seriesValue, seriesLabel); - } - - const yValue = asNumber(row[yValueField]); - const currentSeries = matrix.get(seriesValue) ?? new Map(); - currentSeries.set(xValue, (currentSeries.get(xValue) ?? 0) + yValue); - matrix.set(seriesValue, currentSeries); - } - - const xKeys = Array.from(xValues.keys()); - const xLabels = xKeys.map((key) => xValues.get(key) ?? key); const shouldRotate = xLabels.length > 10 || xLabels.some((label) => label.length > 11); const valueFmt = yFmt ?? fmt; const palette = theme.charts.palette; - const rankedSeries = Array.from(seriesLabels.entries()) - .map(([seriesKey, seriesName]) => ({ - seriesKey, - seriesName, - values: xKeys.map((xKey) => matrix.get(seriesKey)?.get(xKey) ?? 0), - })) - .map((entry) => ({ - ...entry, - total: entry.values.reduce((sum, value) => sum + value, 0), - })) - .sort((left, right) => right.total - left.total || left.seriesName.localeCompare(right.seriesName)); - const series = rankedSeries.map(({ seriesName, values }, index) => { const color = palette[index % palette.length]; diff --git a/chartkit/src/lib/components/useChartSeries.ts b/chartkit/src/lib/components/useChartSeries.ts new file mode 100644 index 0000000..dcc95b6 --- /dev/null +++ b/chartkit/src/lib/components/useChartSeries.ts @@ -0,0 +1,97 @@ +import { useMemo } from "react"; +import { asNumber, asString } from "./utils"; + +export interface ChartSeriesEntry { + seriesKey: string; + seriesName: string; + values: number[]; + total: number; +} + +export interface ChartSeriesResult { + xKeys: string[]; + xLabels: string[]; + rankedSeries: ChartSeriesEntry[]; +} + +interface ChartSeriesOptions { + rows: Array>; + xValueField: string; + xLabelField?: string; + yValueField: string; + yLabelField?: string; + yAxisTitle?: string; + seriesValueField?: string; + seriesLabelField?: string; +} + +function pivotRows(options: ChartSeriesOptions): ChartSeriesResult { + const { + rows, + xValueField, + xLabelField, + yValueField, + yLabelField, + yAxisTitle, + seriesValueField, + seriesLabelField, + } = options; + + const xValues = new Map(); + const seriesLabels = new Map(); + const matrix = new Map>(); + + for (const row of rows) { + const xValue = asString(row[xValueField]); + const xLabelCandidate = xLabelField ? asString(row[xLabelField]) : ""; + const xLabel = xLabelCandidate || xValue; + if (!xValues.has(xValue)) { + xValues.set(xValue, xLabel); + } + + const seriesValue = seriesValueField ? asString(row[seriesValueField]) : "__single"; + const seriesLabelCandidate = seriesLabelField ? asString(row[seriesLabelField]) : ""; + const fallbackSeriesLabel = yAxisTitle ?? yLabelField ?? "Value"; + const seriesLabel = seriesValueField + ? seriesLabelCandidate || seriesValue + : fallbackSeriesLabel; + if (!seriesLabels.has(seriesValue)) { + seriesLabels.set(seriesValue, seriesLabel); + } + + const yValue = asNumber(row[yValueField]); + const currentSeries = matrix.get(seriesValue) ?? new Map(); + currentSeries.set(xValue, (currentSeries.get(xValue) ?? 0) + yValue); + matrix.set(seriesValue, currentSeries); + } + + const xKeys = Array.from(xValues.keys()); + const xLabels = xKeys.map((key) => xValues.get(key) ?? key); + + const rankedSeries = Array.from(seriesLabels.entries()) + .map(([seriesKey, seriesName]) => ({ + seriesKey, + seriesName, + values: xKeys.map((xKey) => matrix.get(seriesKey)?.get(xKey) ?? 0), + })) + .map((entry) => ({ + ...entry, + total: entry.values.reduce((sum, value) => sum + value, 0), + })) + .sort((left, right) => right.total - left.total || left.seriesName.localeCompare(right.seriesName)); + + return { xKeys, xLabels, rankedSeries }; +} + +export function useChartSeries(options: ChartSeriesOptions): ChartSeriesResult { + return useMemo(() => pivotRows(options), [ + options.rows, + options.xValueField, + options.xLabelField, + options.yValueField, + options.yLabelField, + options.yAxisTitle, + options.seriesValueField, + options.seriesLabelField, + ]); +} diff --git a/chartkit/src/lib/format.ts b/chartkit/src/lib/format.ts index c6d53da..0cb5d40 100644 --- a/chartkit/src/lib/format.ts +++ b/chartkit/src/lib/format.ts @@ -2,6 +2,18 @@ export type ChartKitFmt = string | undefined; const LOCALE = "en-AU"; +const formatterCache = new Map(); + +function cachedFormatter(options: Intl.NumberFormatOptions): Intl.NumberFormat { + const key = JSON.stringify(options); + let fmt = formatterCache.get(key); + if (!fmt) { + fmt = new Intl.NumberFormat(LOCALE, options); + formatterCache.set(key, fmt); + } + return fmt; +} + function parseDecimals(fmt: string, prefix: string): number | null { if (!fmt.startsWith(prefix)) { return null; @@ -47,14 +59,14 @@ function parseCurrencyThousandsToken( } function formatCompactNumber(value: number): string { - return new Intl.NumberFormat(LOCALE, { + return cachedFormatter({ notation: "compact", maximumFractionDigits: 1, }).format(value); } function formatCurrency(value: number, currency: "AUD" | "USD", decimals: number): string { - return new Intl.NumberFormat(LOCALE, { + return cachedFormatter({ style: "currency", currency, minimumFractionDigits: decimals, @@ -75,13 +87,13 @@ export function formatChartKitNumber(value: number, fmt?: ChartKitFmt): string { return `US$${value.toFixed(1)}B`; } if (fmt === "currency_usd_m") { - return `US$${value.toLocaleString(LOCALE, { maximumFractionDigits: 0 })}M`; + return `US$${cachedFormatter({ maximumFractionDigits: 0 }).format(value)}M`; } if (fmt === "currency_aud_bn") { return `A$${value.toFixed(1)}B`; } if (fmt === "currency_aud_m") { - return `A$${value.toLocaleString(LOCALE, { maximumFractionDigits: 0 })}M`; + return `A$${cachedFormatter({ maximumFractionDigits: 0 }).format(value)}M`; } if (fmt === "usd") { return formatCurrency(value, "USD", 2); @@ -100,10 +112,10 @@ export function formatChartKitNumber(value: number, fmt?: ChartKitFmt): string { const numDecimals = parseDecimals(fmt, "num"); if (numDecimals !== null) { - return value.toLocaleString(LOCALE, { + return cachedFormatter({ minimumFractionDigits: numDecimals, maximumFractionDigits: numDecimals, - }); + }).format(value); } const audDecimals = parseDecimals(fmt, "aud"); @@ -120,10 +132,10 @@ export function formatChartKitNumber(value: number, fmt?: ChartKitFmt): string { if (currencyThousandsToken) { const symbol = currencyThousandsToken.currency === "AUD" ? "A$" : "US$"; const scaled = value / 1_000; - return `${symbol}${scaled.toLocaleString(LOCALE, { + return `${symbol}${cachedFormatter({ minimumFractionDigits: currencyThousandsToken.decimals, maximumFractionDigits: currencyThousandsToken.decimals, - })}K`; + }).format(scaled)}K`; } const currencyToken = parseCurrencyToken(fmt); @@ -131,7 +143,7 @@ export function formatChartKitNumber(value: number, fmt?: ChartKitFmt): string { return formatCurrency(value, currencyToken.currency, currencyToken.decimals); } - return value.toLocaleString(LOCALE); + return cachedFormatter({}).format(value); } export function formatChartKitDisplayValue(value: unknown, fmt?: ChartKitFmt): string { diff --git a/chartkit/src/lib/runtime/db-manager.ts b/chartkit/src/lib/runtime/db-manager.ts index d9cc521..1f1eb0f 100644 --- a/chartkit/src/lib/runtime/db-manager.ts +++ b/chartkit/src/lib/runtime/db-manager.ts @@ -44,12 +44,12 @@ export async function getSharedRuntime( return runtime; } + // createDuckDbRuntime already calls registerDataSources with the initial + // dataSources array, so we skip the redundant second call for new runtimes. const runtimePromise = createDuckDbRuntime(dataSources, onTelemetry, options).catch((error) => { runtimeByKey.delete(key); throw error; }); runtimeByKey.set(key, runtimePromise); - const runtime = await runtimePromise; - await runtime.registerDataSources(dataSources); - return runtime; + return runtimePromise; }