Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 11 additions & 44 deletions chartkit/src/lib/components/BarChart.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -39,60 +39,27 @@ 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 <div className="ck-inline-status">Loading...</div>;
}
if (error) {
return <div className="ck-inline-status">Error: {error}</div>;
}

const xValues = new Map<string, string>();
const seriesLabels = new Map<string, string>();
const matrix = new Map<string, Map<string, number>>();

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<string, number>();
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",
Expand Down
9 changes: 3 additions & 6 deletions chartkit/src/lib/components/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
};
Expand Down
55 changes: 11 additions & 44 deletions chartkit/src/lib/components/LineChart.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -37,60 +37,27 @@ 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 <div className="ck-inline-status">Loading...</div>;
}
if (error) {
return <div className="ck-inline-status">Error: {error}</div>;
}

const xValues = new Map<string, string>();
const seriesLabels = new Map<string, string>();
const matrix = new Map<string, Map<string, number>>();

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<string, number>();
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];

Expand Down
97 changes: 97 additions & 0 deletions chartkit/src/lib/components/useChartSeries.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>;
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<string, string>();
const seriesLabels = new Map<string, string>();
const matrix = new Map<string, Map<string, number>>();

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<string, number>();
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,
]);
}
30 changes: 21 additions & 9 deletions chartkit/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ export type ChartKitFmt = string | undefined;

const LOCALE = "en-AU";

const formatterCache = new Map<string, Intl.NumberFormat>();

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;
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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");
Expand All @@ -120,18 +132,18 @@ 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);
if (currencyToken) {
return formatCurrency(value, currencyToken.currency, currencyToken.decimals);
}

return value.toLocaleString(LOCALE);
return cachedFormatter({}).format(value);
}

export function formatChartKitDisplayValue(value: unknown, fmt?: ChartKitFmt): string {
Expand Down
6 changes: 3 additions & 3 deletions chartkit/src/lib/runtime/db-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading