diff --git a/frontend/src/apexcharts.ts b/frontend/src/apexcharts.ts index 56b0edee..91686141 100644 --- a/frontend/src/apexcharts.ts +++ b/frontend/src/apexcharts.ts @@ -18,6 +18,8 @@ type DataPoint = { link?: string; }; +type AxisTitles = Record<"x" | "y" | "z", string | undefined>; + type TooltipArgs = { seriesIndex: number; dataPointIndex: number; @@ -29,13 +31,25 @@ function formatTooltipX( value: string | number | null, link: string | undefined, ) { - if (!link || !value) return value; + const text = value == null ? "" : String(value); + if (!link || !value) return text; const anchor = document.createElement("a"); anchor.setAttribute("href", link); - anchor.textContent = String(value); + anchor.textContent = text; return anchor.outerHTML; } +const rangeBarLabel = (_value: string | number, args?: TooltipArgs) => + args ? args.w.config.series[args.seriesIndex].name : ""; + +const pieLabel = (value: string | number, args?: TooltipArgs) => + args + ? `${args.w.config.labels[args.seriesIndex]}: ${Number(value).toFixed()}%` + : ""; + +const numberLabel = (value: string | number) => + value == null ? "" : value.toLocaleString?.() || String(value); + const sqlpage_chart = (() => { function sqlpage_chart() { const charts = document.querySelectorAll( @@ -195,7 +209,9 @@ const sqlpage_chart = (() => { ); const labels = - chart_type === "pie" ? points.map(({ name, x }) => x || name) : undefined; + chart_type === "pie" + ? points.map(({ name, x }) => String(x || name)) + : undefined; const series = chart_type === "pie" ? points.map(({ y }) => Number.parseFloat(String(y))) @@ -217,11 +233,16 @@ const sqlpage_chart = (() => { chart_type === "rangeBar" || (chart_type === "bar" && !!data.horizontal); const value_axis = inverted ? "x" : "y"; const category_axis = inverted ? "y" : "x"; + const axis_titles: AxisTitles = { + x: data.xtitle || undefined, + y: data.ytitle || undefined, + z: data.ztitle || undefined, + }; const has_point_links = points.some((point) => point.link); const text_x_values = chart_series.every(({ data }) => data.every(({ x }) => x == null || typeof x === "string"), ); - const options = { + const options: ApexOptions = { annotations: { [`${value_axis}axis`]: reference_lines( reference_rows, @@ -254,7 +275,7 @@ const sqlpage_chart = (() => { }, events: { dataPointSelection: (_event, _chart, args) => { - const link = pointLink(args, points); + const link = args && pointLink(args, points); if (link) window.location.assign(link); }, }, @@ -274,11 +295,10 @@ const sqlpage_chart = (() => { }, formatter: chart_type === "rangeBar" - ? (_val, { seriesIndex, w }) => w.config.series[seriesIndex].name + ? rangeBarLabel : chart_type === "pie" - ? (value, { seriesIndex, w }) => - `${w.config.labels[seriesIndex]}: ${value.toFixed()}%` - : (value) => value?.toLocaleString?.() || value, + ? pieLabel + : numberLabel, }, fill: { type: chart_type === "area" ? "gradient" : "solid", @@ -295,12 +315,15 @@ const sqlpage_chart = (() => { min: data.xmin, max: data.xmax, title: { - text: data.xtitle || undefined, + text: axis_titles.x, }, type: xaxis_type, labels: { datetimeUTC: false, }, + // Numeric axes count intervals; category and time axes use tickAmount + // as a target for label density. + tickAmount: data.xticks || undefined, }, yaxis: { logarithmic: !!data.logarithmic, @@ -309,12 +332,7 @@ const sqlpage_chart = (() => { stepSize: data.ystep, tickAmount: data.yticks, title: { - text: data.ytitle || undefined, - }, - }, - zaxis: { - title: { - text: data.ztitle || undefined, + text: axis_titles.y, }, }, markers: { @@ -329,7 +347,7 @@ const sqlpage_chart = (() => { interactive: has_point_links, custom: chart_type === "bubble" || chart_type === "scatter" - ? bubbleTooltip + ? (args: TooltipArgs) => chartTooltip(args, axis_titles) : undefined, x: { formatter: @@ -363,19 +381,18 @@ const sqlpage_chart = (() => { colors, series, }; - if (labels) (options as { labels?: unknown }).labels = labels; - // Numeric axes count intervals; category and time axes use tickAmount as a - // target for label density. - if (data.xticks) - (options.xaxis as { tickAmount?: number }).tickAmount = data.xticks; - const chart = new ApexCharts(chartContainer, options as ApexOptions); + if (labels) options.labels = labels; + const chart = new ApexCharts(chartContainer, options); chart.render().catch(console.error); if (window.charts) window.charts.push(chart); else window.charts = [chart]; c.removeAttribute("data-pre-init"); } - function chartTooltip({ seriesIndex, dataPointIndex, w }: TooltipArgs) { + function chartTooltip( + { seriesIndex, dataPointIndex, w }: TooltipArgs, + titles: AxisTitles, + ) { const series = w.config.series[seriesIndex]; const name = series?.name || ""; const point = series?.data[dataPointIndex]; @@ -390,14 +407,12 @@ const sqlpage_chart = (() => { seriesName.innerText = name; tooltip.appendChild(seriesName); - for (const axis of ["x", "y", "z"]) { + for (const axis of ["x", "y", "z"] as const) { const value = point[axis]; if (value == null) continue; const axisValue = document.createElement("div"); axisValue.className = "apexcharts-tooltip-y-group"; - let axis_conf = w.config[`${axis}axis`]; - if (axis_conf.length) axis_conf = axis_conf[0]; - const title = axis_conf.title.text || axis; + const title = titles[axis] || axis; const labelSpan = document.createElement("span"); labelSpan.className = "apexcharts-tooltip-text-y-label"; labelSpan.innerText = `${title}: `; @@ -426,10 +441,6 @@ const sqlpage_chart = (() => { : points[seriesIndex]?.link; } - function bubbleTooltip(args: TooltipArgs) { - return chartTooltip(args); - } - return sqlpage_chart; })(); diff --git a/tests/end-to-end/fixtures/chart/test.ts b/tests/end-to-end/fixtures/chart/test.ts index 09b1b641..022dfcdc 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -10,6 +10,13 @@ declare global { chart: { type: string; stacked: boolean }; xaxis: { type?: string; tickAmount?: number }; series: { name: string | number; data?: ChartPoint[] }[]; + tooltip: { + custom?: (args: { + seriesIndex: number; + dataPointIndex: number; + w: unknown; + }) => string; + }; }; globals: { labels: (string | number)[] }; }; @@ -582,3 +589,35 @@ test("keeps coloring reference lines from their own row", async ({ page }) => { expect(chart.failures).toEqual([]); expect(chart.referenceLines.strokes).toEqual([GREEN]); }); + +test("labels each axis of a bubble tooltip with its own title", async ({ + page, +}) => { + await renderChart(page, "titled-bubble"); + + const rows = await page.evaluate(() => { + const chart = window.charts?.[0]; + if (!chart) throw new Error("Chart fixture did not render"); + const custom = chart.w.config.tooltip.custom; + if (!custom) throw new Error("A bubble chart needs the custom tooltip"); + const holder = document.createElement("div"); + holder.innerHTML = custom({ + seriesIndex: 0, + dataPointIndex: 1, + w: chart.w, + }); + const values = holder.querySelectorAll(".apexcharts-tooltip-text-y-value"); + return [...holder.querySelectorAll(".apexcharts-tooltip-text-y-label")].map( + (label, i): [string, string] => [ + label.textContent?.trim() ?? "", + values[i]?.textContent?.trim() ?? "", + ], + ); + }); + + expect(rows).toEqual([ + ["Weekday:", "Tue"], + ["Hours:", "4"], + ["Weight:", "30"], + ]); +}); diff --git a/tests/end-to-end/fixtures/chart/titled-bubble.sql b/tests/end-to-end/fixtures/chart/titled-bubble.sql new file mode 100644 index 00000000..bcb5025f --- /dev/null +++ b/tests/end-to-end/fixtures/chart/titled-bubble.sql @@ -0,0 +1,3 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'bubble' AS type, + 'Weekday' AS xtitle, 'Hours' AS ytitle, 'Weight' AS ztitle; +WITH points(series, x, y, z) AS (VALUES ('Coding', 'Mon', 6, 30), ('Coding', 'Tue', 4, 30)) SELECT * FROM points;