From 5670b690392d33d9856965a46815bf31eb1246c2 Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Thu, 19 Mar 2026 10:44:56 +0100 Subject: [PATCH] Add chart datapoint links via metadata tuples --- .../sqlpage/migrations/01_documentation.sql | 20 ++++- sqlpage/apexcharts.js | 77 ++++++++++++++++--- sqlpage/templates/chart.handlebars | 10 ++- tests/end-to-end/official-site.spec.ts | 37 +++++++++ 4 files changed, 133 insertions(+), 11 deletions(-) diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index a5f3bde38..a1b5f7400 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -665,7 +665,8 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('y', 'The value of the point on the vertical axis', 'REAL', FALSE, FALSE), ('label', 'An alias for parameter "x"', 'REAL', FALSE, TRUE), ('value', 'An alias for parameter "y"', 'REAL', FALSE, TRUE), - ('series', 'If multiple series are represented and share the same y-axis, this parameter can be used to distinguish between them.', 'TEXT', FALSE, TRUE) + ('series', 'If multiple series are represented and share the same y-axis, this parameter can be used to distinguish between them.', 'TEXT', FALSE, TRUE), + ('link', 'URL opened when user clicks this datapoint or slice.', 'URL', FALSE, TRUE) ) x; INSERT INTO example(component, description, properties) VALUES ('chart', 'An area chart representing a time series, using the top-level property `time`. @@ -692,6 +693,23 @@ INSERT INTO example(component, description, properties) VALUES ('chart', 'A basic bar chart', json('[ {"component":"chart", "type": "bar", "title": "Quarterly Results", "horizontal": true, "labels": true}, {"label": "Tom", "value": 35}, {"label": "Olive", "value": 15}]')), + ('chart', 'A bar chart with clickable datapoints. Each row can set a `link` URL; clicking a datapoint opens that URL.', json('[ + {"component":"chart", "type": "bar", "title": "Linked Sales", "labels": true}, + {"label": "North", "value": 35, "link": "/documentation.sql?component=table"}, + {"label": "South", "value": 22}, + {"label": "West", "value": 41, "link": "/documentation.sql?component=map"} + ]')), + ('chart', 'A pie chart with per-slice links.', json('[ + {"component":"chart", "title": "Linked Answers", "type": "pie", "labels": true}, + {"label": "Yes", "value": 65, "link": "/documentation.sql?component=form"}, + {"label": "No", "value": 35, "link": "/documentation.sql?component=table"} + ]')), + ('chart', 'A bubble chart demonstrating `z` and `link` metadata together on points.', + json('[ + {"component":"chart", "title": "Bubbles with links", "type": "bubble", "ztitle": "Population", "marker": 8}, + {"series": "Europe", "x": 2.1, "y": 18.5, "z": 742, "link": "/documentation.sql?component=chart"}, + {"series": "Asia", "x": 5.2, "y": 24.1, "z": 4700, "link": "/documentation.sql?component=map"} + ]')), ('chart', 'A TreeMap Chart allows you to display hierarchical data in a nested layout. This is useful for visualizing the proportion of each part to the whole.', json('[ {"component":"chart", "type": "treemap", "title": "Quarterly Results By Region (in k$)", "labels": true }, diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index a0f46367b..93407ca3a 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -36,7 +36,7 @@ sqlpage_chart = (() => { ); const isDarkTheme = document.body?.dataset?.bsTheme === "dark"; - /** @typedef { { [name:string]: {data:{x:number|string|Date,y:number}[], name:string} } } Series */ + /** @typedef { { [name:string]: {data:{x:number|string|Date,y:number,z?:number|string,l?:string}[], name:string} } } Series */ /** * Aligns series data points by their x-axis categories, ensuring all series have data points @@ -78,19 +78,37 @@ sqlpage_chart = (() => { series_idxs.splice(series_idxs.indexOf(idx_of_xmin), 1); } } - // Create a map of category -> value for each series and rebuild + // Create a map of category -> point for each series and rebuild return series.map((s) => { - const valueMap = new Map(s.data.map((point) => [point.x, point.y])); + const valueMap = new Map(s.data.map((point) => [point.x, point])); return { name: s.name, - data: Array.from(categoriesSet, (category) => ({ - x: category, - y: valueMap.get(category) || 0, - })), + data: Array.from( + categoriesSet, + (category) => + valueMap.get(category) || { + x: category, + y: 0, + }, + ), }; }); } + function resolvePointLink(data, opts, pieLinks) { + if (opts.dataPointIndex == null || opts.dataPointIndex < 0) + return undefined; + if (data.type === "pie") return pieLinks[opts.dataPointIndex]; + if (opts.seriesIndex == null || opts.seriesIndex < 0) return undefined; + const series = opts.w?.config?.series?.[opts.seriesIndex]; + const point = series?.data?.[opts.dataPointIndex]; + return point?.l; + } + + function navigateIfLink(link) { + if (typeof link === "string" && link) window.location.href = link; + } + /** @param {HTMLElement} c */ function build_sqlpage_chart(c) { const [data_element] = c.getElementsByTagName("data"); @@ -100,7 +118,27 @@ sqlpage_chart = (() => { const is_timeseries = !!data.time; /** @type { Series } */ const series_map = {}; - for (const [name, old_x, old_y, z] of data.points) { + const pieLinks = []; + let warnedLegacyPointMetadata = false; + for (const point of data.points) { + const [name, old_x, old_y] = point; + let meta; + if (point.length === 4) { + if ( + typeof point[3] === "object" && + point[3] != null && + !Array.isArray(point[3]) + ) { + meta = point[3]; + } else if (!warnedLegacyPointMetadata) { + warnedLegacyPointMetadata = true; + console.warn( + "Chart point metadata must be an object in the 4th tuple slot. Legacy formats are ignored.", + ); + } + } + const z = meta?.z; + const link = meta?.l; series_map[name] = series_map[name] || { name, data: [] }; let x = old_x; let y = old_y; @@ -110,7 +148,11 @@ sqlpage_chart = (() => { y = y.map((y) => new Date(y).getTime()); else x = new Date(x); } - series_map[name].data.push({ x, y, z }); + const seriesPoint = { x, y }; + if (z != null) seriesPoint.z = z; + if (link != null) seriesPoint.l = link; + series_map[name].data.push(seriesPoint); + pieLinks.push(link); } if (data.xmin == null) data.xmin = undefined; if (data.xmax == null) data.xmax = undefined; @@ -135,6 +177,16 @@ sqlpage_chart = (() => { series = align_categories(series); const chart_type = data.type || "line"; + let skipNextChartClick = false; + const onDataPointInteraction = (_event, _chartContext, opts) => { + const link = resolvePointLink(data, opts, pieLinks); + if (!link) return; + skipNextChartClick = true; + navigateIfLink(link); + setTimeout(() => { + skipNextChartClick = false; + }, 0); + }; const options = { chart: { type: chart_type, @@ -151,6 +203,13 @@ sqlpage_chart = (() => { zoom: { enabled: false, }, + events: { + dataPointSelection: onDataPointInteraction, + click: (event, chartContext, opts) => { + if (skipNextChartClick) return; + onDataPointInteraction(event, chartContext, opts); + }, + }, }, theme: { palette: "palette4", diff --git a/sqlpage/templates/chart.handlebars b/sqlpage/templates/chart.handlebars index 667e5eae8..3db9836c1 100644 --- a/sqlpage/templates/chart.handlebars +++ b/sqlpage/templates/chart.handlebars @@ -43,7 +43,15 @@ {{~ stringify (default series (default ../title "")) ~}}, {{~ stringify (default x label) ~}}, {{~ stringify (default y value) ~}} - {{~#if z}}, {{~ stringify z ~}} {{~/if~}} + {{~#if (or (or z (eq z 0)) link)~}}, { + {{~#if (or z (eq z 0))~}} + "z": {{~ stringify z ~}} + {{~#if link~}},{{/if~}} + {{~/if~}} + {{~#if link~}} + "l": {{~ stringify link ~}} + {{~/if~}} + }{{~/if~}} ] {{~/each_row~}} ] diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index eda20e7e0..e3ad73bd1 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -24,6 +24,43 @@ test("chart", async ({ page }) => { await expect(page.locator(".apexcharts-canvas").first()).toBeVisible(); }); +test("chart point links - bar", async ({ page }) => { + await page.goto(`${BASE}/documentation.sql?component=chart`); + const linkedBarCard = page.locator(".card", { + has: page.getByRole("heading", { name: "Linked Sales" }), + }); + await expect(linkedBarCard.locator(".apexcharts-canvas")).toBeVisible(); + await linkedBarCard + .locator(".apexcharts-series path, .apexcharts-series rect") + .first() + .click(); + await expect(page).toHaveURL(/component=table/); +}); + +test("chart point links - pie", async ({ page }) => { + await page.goto(`${BASE}/documentation.sql?component=chart`); + const linkedPieCard = page.locator(".card", { + has: page.getByRole("heading", { name: "Linked Answers" }), + }); + await expect(linkedPieCard.locator(".apexcharts-canvas")).toBeVisible(); + await linkedPieCard.locator(".apexcharts-pie-series path").first().click(); + await expect(page).toHaveURL(/component=form/); +}); + +test("chart links - no-link datapoint", async ({ page }) => { + await page.goto(`${BASE}/documentation.sql?component=chart`); + const linkedBarCard = page.locator(".card", { + has: page.getByRole("heading", { name: "Linked Sales" }), + }); + await expect(linkedBarCard.locator(".apexcharts-canvas")).toBeVisible(); + const initialUrl = page.url(); + await linkedBarCard + .locator(".apexcharts-series path, .apexcharts-series rect") + .nth(1) + .click(); + await expect(page).toHaveURL(initialUrl); +}); + test("map", async ({ page }) => { await page.goto(`${BASE}/documentation.sql?component=map#component`); await expect(page.getByText("Loading...")).not.toBeVisible();