From f8f2b28d9411b2f1e0f36bd815ce3c4973765e75 Mon Sep 17 00:00:00 2001 From: Ophir LOJKINE Date: Thu, 10 Sep 2026 17:12:41 +0200 Subject: [PATCH 1/5] feat(chart): add links to data point tooltips (#1438) * feat(chart): add links to data point tooltips * fix(chart): keep linked tooltips interactive * fix(chart): keep point links on their rows * fix(chart): format custom tooltip values * test(chart): cover links across tooltip types * style(chart): use camel case for tooltip helper * test(chart): remove unused serialization fixture * fix(chart): keep tooltip links accessible * docs(changelog): file the point link entry under unreleased * fix(chart): link every slice of a pie chart --- CHANGELOG.md | 2 + .../sqlpage/migrations/01_documentation.sql | 5 +- sqlpage/apexcharts.js | 54 +++++++++++--- sqlpage/sqlpage.css | 9 +++ sqlpage/templates/chart.handlebars | 5 +- .../components/chart_point_serialization.sql | 6 -- tests/end-to-end/fixtures/chart/link-bar.sql | 4 ++ tests/end-to-end/fixtures/chart/link-line.sql | 4 ++ tests/end-to-end/fixtures/chart/link-pie.sql | 4 ++ .../fixtures/chart/link-scatter.sql | 4 ++ tests/end-to-end/fixtures/chart/link.sql | 7 ++ tests/end-to-end/fixtures/chart/test.ts | 71 +++++++++++++++++++ 12 files changed, 156 insertions(+), 19 deletions(-) delete mode 100644 tests/components/chart_point_serialization.sql create mode 100644 tests/end-to-end/fixtures/chart/link-bar.sql create mode 100644 tests/end-to-end/fixtures/chart/link-line.sql create mode 100644 tests/end-to-end/fixtures/chart/link-pie.sql create mode 100644 tests/end-to-end/fixtures/chart/link-scatter.sql create mode 100644 tests/end-to-end/fixtures/chart/link.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index e149c7f4..0d790b43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## unreleased +- Chart data points can now include a `link`, which is shown as a clickable link in the point's tooltip. + ## v0.46.2 - Numeric x values on Cartesian charts now explicitly use a continuous numeric axis, preventing fractional tick positions from being displayed as misleading rounded integers. diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index ce61b247..698c409b 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -688,6 +688,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('label', 'An alias for parameter "x". On a row that draws a reference line, the text to display next to the line.', 'TEXT', 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), + ('link', 'Adds a clickable link to this point in its tooltip.', 'URL', FALSE, TRUE), ('yline', 'Draws a reference line across the chart at this value of the y axis instead of plotting a point, to show a limit such as a quota or an alarm threshold. Not drawn if it falls outside of the axis, so set ymax when the limit is above the data.', 'REAL', FALSE, TRUE), ('yline_end', 'Makes the yline a band instead of a line, reaching to this value.', 'REAL', FALSE, TRUE), ('xline', 'Draws a reference line across the chart at this position of the x axis instead of plotting a point, to mark an event such as a deployment. A date or a timestamp when time is set, otherwise one of the x values.', 'TEXT', FALSE, TRUE), @@ -789,10 +790,10 @@ The `color` property sets the color of each series separately, in order. { "series": "PostgreSQL", "x": "2010", "y": 65},{ "series": "SQLite", "x": "2010", "y": 62},{ "series": "MySQL", "x": "2010", "y": 83}, { "series": "PostgreSQL", "x": "2020", "y": 73},{ "series": "SQLite", "x": "2020", "y": 38},{ "series": "MySQL", "x": "2020", "y": 87} ]')), - ('chart', 'A timeline displaying events with a start and an end date', + ('chart', 'A timeline displaying events with a start and an end date. A data row can include a `link` to make it available as a clickable action in the tooltip.', json('[ {"component":"chart", "title": "Project Timeline", "type": "rangeBar", "time": true, "color": ["teal", "cyan"], "labels": true, "xmin": "2021-12-28", "xmax": "2022-01-04" }, - {"series": "Phase 1", "label": "Operations", "value": ["2021-12-29", "2022-01-02"]}, + {"series": "Phase 1", "label": "Operations", "value": ["2021-12-29", "2022-01-02"], "link": "/examples/chart.sql?phase=1"}, {"series": "Phase 2", "label": "Operations", "value": ["2022-01-03", "2022-01-04"]}, {"series": "Yearly maintenance", "label": "Maintenance", "value": ["2022-01-01", "2022-01-03"]} ]')), diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index b822f9e1..2935b212 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -51,7 +51,7 @@ sqlpage_chart = (() => { }; /** @typedef {number|string|Date} XValue */ - /** @typedef { {x:XValue, y:number|null, z?:number, fillColor?:string} } ChartPoint */ + /** @typedef { {x:XValue, y:number|null, z?:number, fillColor?:string, link?:string} } ChartPoint */ /** @typedef { {name:string, data:ChartPoint[]} } ChartSeries */ /** @typedef { { [name:string]: ChartSeries } } Series */ @@ -200,7 +200,7 @@ sqlpage_chart = (() => { const reference_rows = data.points.filter((row) => !Array.isArray(row)); /** @type { Series } */ const series_map = {}; - for (const [name, old_x, old_y, color, z] of points) { + for (const [name, old_x, old_y, color, z, link] of points) { series_map[name] = series_map[name] || { name, data: [] }; let x = old_x; let y = old_y; @@ -210,7 +210,13 @@ sqlpage_chart = (() => { y = y.map((y) => new Date(y).getTime()); else x = new Date(x); } - series_map[name].data.push({ x, y, z, fillColor: named_color(color) }); + series_map[name].data.push({ + x, + y, + z, + link, + fillColor: named_color(color), + }); } if (data.xmin == null) data.xmin = undefined; if (data.xmax == null) data.xmax = undefined; @@ -355,8 +361,9 @@ sqlpage_chart = (() => { }, tooltip: { fillSeriesColor: false, - custom: - chart_type === "bubble" || chart_type === "scatter" + custom: points.some((point) => point[5]) + ? (args) => chartTooltip(args, points) + : chart_type === "bubble" || chart_type === "scatter" ? bubbleTooltip : undefined, y: { @@ -396,9 +403,16 @@ sqlpage_chart = (() => { c.removeAttribute("data-pre-init"); } - function bubbleTooltip({ seriesIndex, dataPointIndex, w }) { - const { name, data } = w.config.series[seriesIndex]; - const point = data[dataPointIndex]; + function chartTooltip({ seriesIndex, dataPointIndex, w }, raw_points) { + const series = w.config.series[seriesIndex]; + const has_series_data = Array.isArray(series?.data); + const point_index = has_series_data ? dataPointIndex : seriesIndex; + const raw_point = raw_points[point_index]; + const name = series?.name || w.config.labels?.[point_index] || ""; + const point = has_series_data + ? series.data[dataPointIndex] + : { y: raw_point?.[2], z: raw_point?.[4] }; + const link = has_series_data ? point?.link : raw_point?.[5]; const tooltip = document.createElement("div"); tooltip.className = "apexcharts-tooltip-text"; @@ -424,13 +438,35 @@ sqlpage_chart = (() => { axisValue.appendChild(labelSpan); const valueSpan = document.createElement("span"); valueSpan.className = "apexcharts-tooltip-text-y-value"; - valueSpan.innerText = value; + const formatter = axis === "y" && w.config.tooltip.y.formatter; + const format = (v) => + formatter ? formatter(v, { seriesIndex, dataPointIndex, w }) : v; + valueSpan.innerText = Array.isArray(value) + ? value.map(format).join(" - ") + : format(value); axisValue.appendChild(valueSpan); tooltip.appendChild(axisValue); } + addLinkToTooltip(tooltip, link); return tooltip.outerHTML; } + function bubbleTooltip(args) { + return chartTooltip(args, []); + } + + /** @param {HTMLElement} tooltip @param {string|undefined} link */ + function addLinkToTooltip(tooltip, link) { + if (!link) return; + const linkContainer = document.createElement("div"); + linkContainer.className = "apexcharts-tooltip-y-group"; + const anchor = document.createElement("a"); + anchor.href = link; + anchor.textContent = "Open link"; + linkContainer.appendChild(anchor); + tooltip.appendChild(linkContainer); + } + return sqlpage_chart; })(); diff --git a/sqlpage/sqlpage.css b/sqlpage/sqlpage.css index 2976dd62..89672414 100644 --- a/sqlpage/sqlpage.css +++ b/sqlpage/sqlpage.css @@ -64,6 +64,15 @@ code { color: inherit; } +.apexcharts-tooltip:has(a) { + pointer-events: auto; +} + +.apexcharts-tooltip a { + color: currentColor; + text-decoration: underline; +} + /** table **/ .table-freeze-headers thead { position: sticky; diff --git a/sqlpage/templates/chart.handlebars b/sqlpage/templates/chart.handlebars index e0f88e6d..17962b04 100644 --- a/sqlpage/templates/chart.handlebars +++ b/sqlpage/templates/chart.handlebars @@ -51,8 +51,9 @@ {{~ stringify (default series (default ../title "")) ~}}, {{~ stringify (default x label) ~}}, {{~ stringify (default y value) ~}} - {{~#if (or color z)}}, {{~ stringify color ~}} {{~/if~}} - {{~#if z}}, {{~ stringify z ~}} {{~/if~}} + {{~#if (or color z link)}}, {{~ stringify color ~}} {{~/if~}} + {{~#if (or z link)}}, {{~ stringify z ~}} {{~/if~}} + {{~#if link}}, {{~ stringify link ~}} {{~/if~}} ] {{~/if~}} {{~/each_row~}} diff --git a/tests/components/chart_point_serialization.sql b/tests/components/chart_point_serialization.sql deleted file mode 100644 index 246251e3..00000000 --- a/tests/components/chart_point_serialization.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT 'chart' AS component, 'It works !' AS title; -SELECT 'plain' AS x, '1' AS y; -SELECT 'colored' AS x, '2' AS y, 'red' AS color; -SELECT 'sized' AS x, '3' AS y, '30' AS z; -SELECT 'both' AS x, '4' AS y, 'green' AS color, '40' AS z; -SELECT '70' AS yline, 'limit' AS label, 'orange' AS color; diff --git a/tests/end-to-end/fixtures/chart/link-bar.sql b/tests/end-to-end/fixtures/chart/link-bar.sql new file mode 100644 index 00000000..271a9e9b --- /dev/null +++ b/tests/end-to-end/fixtures/chart/link-bar.sql @@ -0,0 +1,4 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, + 'bar' AS type; +SELECT 'Points' AS series, 'Linked' AS label, 10 AS value, '/linked.sql' AS link; +SELECT 'Points' AS series, 'Linked too' AS label, 20 AS value, '/linked-too.sql' AS link; diff --git a/tests/end-to-end/fixtures/chart/link-line.sql b/tests/end-to-end/fixtures/chart/link-line.sql new file mode 100644 index 00000000..055288a5 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/link-line.sql @@ -0,0 +1,4 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, + 'line' AS type, 8 AS marker; +SELECT 'Points' AS series, 1 AS x, 10 AS y, '/linked.sql' AS link; +SELECT 'Points' AS series, 2 AS x, 20 AS y, '/linked-too.sql' AS link; diff --git a/tests/end-to-end/fixtures/chart/link-pie.sql b/tests/end-to-end/fixtures/chart/link-pie.sql new file mode 100644 index 00000000..da0828ef --- /dev/null +++ b/tests/end-to-end/fixtures/chart/link-pie.sql @@ -0,0 +1,4 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, + 'pie' AS type; +SELECT 'Points' AS series, 'A' AS label, 10 AS value, '/linked.sql' AS link; +SELECT 'Points' AS series, 'B' AS label, 20 AS value, '/linked-too.sql' AS link; diff --git a/tests/end-to-end/fixtures/chart/link-scatter.sql b/tests/end-to-end/fixtures/chart/link-scatter.sql new file mode 100644 index 00000000..984c90e3 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/link-scatter.sql @@ -0,0 +1,4 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, + 'scatter' AS type, 8 AS marker; +SELECT 'Points' AS series, 1 AS x, 1 AS y, '/linked.sql' AS link; +SELECT 'Points' AS series, 2 AS x, 2 AS y, '/linked-too.sql' AS link; diff --git a/tests/end-to-end/fixtures/chart/link.sql b/tests/end-to-end/fixtures/chart/link.sql new file mode 100644 index 00000000..3c5c7d9d --- /dev/null +++ b/tests/end-to-end/fixtures/chart/link.sql @@ -0,0 +1,7 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, + 'rangeBar' AS type, TRUE AS time; +SELECT 'Design' AS series, 'Alice' AS label, + '2024-03-01' AS value, '2024-03-05' AS value, + '/workpackage_edit.sql?workpackage_name=Design' AS link; +SELECT 'Research' AS series, 'Bob' AS label, + '2024-03-06' AS value, '2024-03-10' AS value; diff --git a/tests/end-to-end/fixtures/chart/test.ts b/tests/end-to-end/fixtures/chart/test.ts index d20553a9..7d06689a 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -367,6 +367,77 @@ test("leaves a rangeBar chart on a category axis alone", async ({ page }) => { expect(chart.shapes).toHaveLength(2); }); +test("shows an interactive data point link in a rangeBar tooltip", async ({ + page, +}) => { + await renderChart(page, "link"); + + await page.locator("#test-chart .apexcharts-rangebar-area").first().hover(); + const link = page.locator("#test-chart .apexcharts-tooltip a"); + await expect(link).toHaveText("Open link"); + await expect(link).toHaveAttribute( + "href", + "/workpackage_edit.sql?workpackage_name=Design", + ); + await expect(page.locator("#test-chart .apexcharts-tooltip")).toHaveCSS( + "pointer-events", + "auto", + ); + const colors = await link.evaluate((anchor) => { + const tooltip = anchor.closest(".apexcharts-tooltip"); + if (!tooltip) throw new Error("Link has no tooltip"); + return { + link: getComputedStyle(anchor).color, + tooltip: getComputedStyle(tooltip).color, + }; + }); + expect(colors.link).toBe(colors.tooltip); + await page.locator("#test-chart .apexcharts-rangebar-area").nth(1).hover(); + await expect(page.locator("#test-chart .apexcharts-tooltip a")).toHaveCount( + 0, + ); + await page.locator("#test-chart .apexcharts-rangebar-area").first().hover(); + await link.click(); + await expect(page).toHaveURL(/workpackage_edit/); +}); + +for (const [type, mark] of [ + ["bar", ".apexcharts-bar-area"], + ["line", ".apexcharts-marker"], + ["scatter", ".apexcharts-marker"], +]) { + test(`shows a data point link in a ${type} tooltip`, async ({ page }) => { + await renderChart(page, `link-${type}`); + + const marks = page.locator(`#test-chart ${mark}`); + await marks.nth(0).hover({ force: true }); + await expect(page.locator("#test-chart .apexcharts-tooltip a")).toHaveCount( + 1, + ); + }); +} + +test("links each slice of a pie chart from its own row", async ({ page }) => { + const chart = await renderChart(page, "link-pie"); + + expect(chart.failures).toEqual([]); + const slices = page.locator("#test-chart .apexcharts-pie-area"); + const link = page.locator("#test-chart .apexcharts-tooltip a"); + await slices.nth(0).hover(); + await expect(link).toHaveAttribute("href", "/linked.sql"); + await slices.nth(1).hover(); + await expect(link).toHaveAttribute("href", "/linked-too.sql"); +}); + +test("formats date ranges in a linked tooltip", async ({ page }) => { + await renderChart(page, "link"); + + await page.locator("#test-chart .apexcharts-rangebar-area").first().hover(); + await expect( + page.locator("#test-chart .apexcharts-tooltip"), + ).not.toContainText("1709251200000"); +}); + test("leaves a treemap chart alone", async ({ page }) => { const chart = await renderChart(page, "treemap"); From 427a7358b6d05d30176c9731cc9d8ed1abc0e4ee Mon Sep 17 00:00:00 2001 From: lovasoa Date: Fri, 11 Sep 2026 18:16:06 +0200 Subject: [PATCH 2/5] Fix chart tooltip links on hover --- sqlpage/apexcharts.js | 17 +++++++++++++++++ tests/end-to-end/fixtures/chart/test.ts | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index 2935b212..d62a8fc1 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -398,6 +398,7 @@ sqlpage_chart = (() => { if (data.xticks) options.xaxis.tickAmount = data.xticks; const chart = new ApexCharts(chartContainer, options); chart.render(); + keepLinkedTooltipOpen(chartContainer); if (window.charts) window.charts.push(chart); else window.charts = [chart]; c.removeAttribute("data-pre-init"); @@ -455,6 +456,22 @@ sqlpage_chart = (() => { return chartTooltip(args, []); } + /** @param {HTMLElement} chartContainer */ + function keepLinkedTooltipOpen(chartContainer) { + chartContainer.addEventListener( + "mouseout", + (event) => { + const nextTarget = event.relatedTarget; + if ( + nextTarget instanceof Element && + nextTarget.closest(".apexcharts-tooltip")?.querySelector("a") + ) + event.stopPropagation(); + }, + true, + ); + } + /** @param {HTMLElement} tooltip @param {string|undefined} link */ function addLinkToTooltip(tooltip, link) { if (!link) return; diff --git a/tests/end-to-end/fixtures/chart/test.ts b/tests/end-to-end/fixtures/chart/test.ts index 7d06689a..1f98352d 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -392,6 +392,8 @@ test("shows an interactive data point link in a rangeBar tooltip", async ({ }; }); expect(colors.link).toBe(colors.tooltip); + await link.hover(); + await expect(link).toBeVisible(); await page.locator("#test-chart .apexcharts-rangebar-area").nth(1).hover(); await expect(page.locator("#test-chart .apexcharts-tooltip a")).toHaveCount( 0, @@ -417,6 +419,23 @@ for (const [type, mark] of [ }); } +for (const [type, mark] of [ + ["bar", ".apexcharts-bar-area"], + ["scatter", ".apexcharts-marker"], +]) { + test(`keeps a data point link open when entering a ${type} tooltip`, async ({ + page, + }) => { + await renderChart(page, `link-${type}`); + + await page.locator(`#test-chart ${mark}`).nth(0).hover({ force: true }); + const link = page.locator("#test-chart .apexcharts-tooltip a"); + await expect(link).toHaveCount(1); + await link.hover(); + await expect(link).toBeVisible(); + }); +} + test("links each slice of a pie chart from its own row", async ({ page }) => { const chart = await renderChart(page, "link-pie"); From a8c80820610b210543a972ad198184716aab7ef8 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Fri, 11 Sep 2026 21:38:39 +0200 Subject: [PATCH 3/5] Link chart tooltip titles --- sqlpage/apexcharts.js | 16 ++-------------- tests/end-to-end/fixtures/chart/test.ts | 6 ++++-- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index d62a8fc1..db85c8f9 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -419,9 +419,10 @@ sqlpage_chart = (() => { tooltip.className = "apexcharts-tooltip-text"; tooltip.style.fontFamily = "inherit"; - const seriesName = document.createElement("div"); + const seriesName = document.createElement(link ? "a" : "div"); seriesName.className = "apexcharts-tooltip-y-group"; seriesName.style.fontWeight = "bold"; + if (seriesName instanceof HTMLAnchorElement) seriesName.href = link; seriesName.innerText = name; tooltip.appendChild(seriesName); @@ -448,7 +449,6 @@ sqlpage_chart = (() => { axisValue.appendChild(valueSpan); tooltip.appendChild(axisValue); } - addLinkToTooltip(tooltip, link); return tooltip.outerHTML; } @@ -472,18 +472,6 @@ sqlpage_chart = (() => { ); } - /** @param {HTMLElement} tooltip @param {string|undefined} link */ - function addLinkToTooltip(tooltip, link) { - if (!link) return; - const linkContainer = document.createElement("div"); - linkContainer.className = "apexcharts-tooltip-y-group"; - const anchor = document.createElement("a"); - anchor.href = link; - anchor.textContent = "Open link"; - linkContainer.appendChild(anchor); - tooltip.appendChild(linkContainer); - } - return sqlpage_chart; })(); diff --git a/tests/end-to-end/fixtures/chart/test.ts b/tests/end-to-end/fixtures/chart/test.ts index 1f98352d..f0c2197c 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -373,8 +373,10 @@ test("shows an interactive data point link in a rangeBar tooltip", async ({ await renderChart(page, "link"); await page.locator("#test-chart .apexcharts-rangebar-area").first().hover(); - const link = page.locator("#test-chart .apexcharts-tooltip a"); - await expect(link).toHaveText("Open link"); + const link = page.locator( + "#test-chart .apexcharts-tooltip .apexcharts-tooltip-text > a", + ); + await expect(link).toHaveText("Design"); await expect(link).toHaveAttribute( "href", "/workpackage_edit.sql?workpackage_name=Design", From 3945c7b815d7858429338be62ee41ee742aed2cc Mon Sep 17 00:00:00 2001 From: lovasoa Date: Sun, 13 Sep 2026 00:10:08 +0200 Subject: [PATCH 4/5] Make chart point links clickable --- CHANGELOG.md | 2 +- .../sqlpage/migrations/01_documentation.sql | 4 +-- sqlpage/apexcharts.js | 18 +++++++++++-- sqlpage/sqlpage.css | 15 ++++++++++- tests/end-to-end/fixtures/chart/test.ts | 25 +++++++++++++------ 5 files changed, 51 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d790b43..9543c462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## unreleased -- Chart data points can now include a `link`, which is shown as a clickable link in the point's tooltip. +- **Beta:** Chart data points can now include a `link`. The link is shown in an interactive tooltip, and clicking the data point itself also follows it. Interactive tooltips depend on [upstream ApexCharts support](https://github.com/apexcharts/apexcharts.js/issues/4469). ## v0.46.2 diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index 698c409b..19aa316d 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -688,7 +688,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('label', 'An alias for parameter "x". On a row that draws a reference line, the text to display next to the line.', 'TEXT', 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), - ('link', 'Adds a clickable link to this point in its tooltip.', 'URL', FALSE, TRUE), + ('link', 'Beta: makes this data point clickable and adds the same link to its tooltip. Interactive tooltips depend on ApexCharts issue #4469.', 'URL', FALSE, TRUE), ('yline', 'Draws a reference line across the chart at this value of the y axis instead of plotting a point, to show a limit such as a quota or an alarm threshold. Not drawn if it falls outside of the axis, so set ymax when the limit is above the data.', 'REAL', FALSE, TRUE), ('yline_end', 'Makes the yline a band instead of a line, reaching to this value.', 'REAL', FALSE, TRUE), ('xline', 'Draws a reference line across the chart at this position of the x axis instead of plotting a point, to mark an event such as a deployment. A date or a timestamp when time is set, otherwise one of the x values.', 'TEXT', FALSE, TRUE), @@ -790,7 +790,7 @@ The `color` property sets the color of each series separately, in order. { "series": "PostgreSQL", "x": "2010", "y": 65},{ "series": "SQLite", "x": "2010", "y": 62},{ "series": "MySQL", "x": "2010", "y": 83}, { "series": "PostgreSQL", "x": "2020", "y": 73},{ "series": "SQLite", "x": "2020", "y": 38},{ "series": "MySQL", "x": "2020", "y": 87} ]')), - ('chart', 'A timeline displaying events with a start and an end date. A data row can include a `link` to make it available as a clickable action in the tooltip.', + ('chart', 'A timeline displaying events with a start and an end date. A data row can include a beta `link` to make both the bar and its tooltip clickable; see [ApexCharts issue #4469](https://github.com/apexcharts/apexcharts.js/issues/4469).', json('[ {"component":"chart", "title": "Project Timeline", "type": "rangeBar", "time": true, "color": ["teal", "cyan"], "labels": true, "xmin": "2021-12-28", "xmax": "2022-01-04" }, {"series": "Phase 1", "label": "Operations", "value": ["2021-12-29", "2022-01-02"], "link": "/examples/chart.sql?phase=1"}, diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index db85c8f9..670e02d8 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -258,6 +258,7 @@ sqlpage_chart = (() => { chart_type === "rangeBar" || (chart_type === "bar" && !!data.horizontal); const value_axis = inverted ? "x" : "y"; const category_axis = inverted ? "y" : "x"; + const has_point_links = points.some((point) => point[5]); const options = { annotations: { [`${value_axis}axis`]: reference_lines( @@ -289,6 +290,12 @@ sqlpage_chart = (() => { zoom: { enabled: false, }, + events: { + dataPointSelection: (_event, _chart, args) => { + const link = pointLink(args, points); + if (link) window.location.assign(link); + }, + }, }, theme: { mode: isDarkTheme ? "dark" : "light", @@ -361,7 +368,7 @@ sqlpage_chart = (() => { }, tooltip: { fillSeriesColor: false, - custom: points.some((point) => point[5]) + custom: has_point_links ? (args) => chartTooltip(args, points) : chart_type === "bubble" || chart_type === "scatter" ? bubbleTooltip @@ -413,7 +420,7 @@ sqlpage_chart = (() => { const point = has_series_data ? series.data[dataPointIndex] : { y: raw_point?.[2], z: raw_point?.[4] }; - const link = has_series_data ? point?.link : raw_point?.[5]; + const link = pointLink({ seriesIndex, dataPointIndex, w }, raw_points); const tooltip = document.createElement("div"); tooltip.className = "apexcharts-tooltip-text"; @@ -452,6 +459,13 @@ sqlpage_chart = (() => { return tooltip.outerHTML; } + function pointLink({ seriesIndex, dataPointIndex, w }, raw_points) { + const series = w.config.series[seriesIndex]; + return Array.isArray(series?.data) + ? series.data[dataPointIndex]?.link + : raw_points[seriesIndex]?.[5]; + } + function bubbleTooltip(args) { return chartTooltip(args, []); } diff --git a/sqlpage/sqlpage.css b/sqlpage/sqlpage.css index 89672414..8fe63bad 100644 --- a/sqlpage/sqlpage.css +++ b/sqlpage/sqlpage.css @@ -64,15 +64,28 @@ code { color: inherit; } -.apexcharts-tooltip:has(a) { +.apexcharts-tooltip.apexcharts-active:has(a) { + isolation: isolate; pointer-events: auto; } +.apexcharts-tooltip.apexcharts-active:has(a)::before { + content: ""; + position: absolute; + inset: -12px; + z-index: -1; +} + .apexcharts-tooltip a { color: currentColor; text-decoration: underline; } +.apexcharts-canvas:has(.apexcharts-tooltip.apexcharts-active a) + .apexcharts-svg { + cursor: pointer; +} + /** table **/ .table-freeze-headers thead { position: sticky; diff --git a/tests/end-to-end/fixtures/chart/test.ts b/tests/end-to-end/fixtures/chart/test.ts index f0c2197c..3db12c07 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -385,6 +385,14 @@ test("shows an interactive data point link in a rangeBar tooltip", async ({ "pointer-events", "auto", ); + await expect( + page.locator("#test-chart .apexcharts-rangebar-area").first(), + ).toHaveCSS("cursor", "pointer"); + await page.locator("#test-chart .apexcharts-rangebar-area").nth(1).hover(); + await expect( + page.locator("#test-chart .apexcharts-rangebar-area").nth(1), + ).not.toHaveCSS("cursor", "pointer"); + await page.locator("#test-chart .apexcharts-rangebar-area").first().hover(); const colors = await link.evaluate((anchor) => { const tooltip = anchor.closest(".apexcharts-tooltip"); if (!tooltip) throw new Error("Link has no tooltip"); @@ -394,13 +402,16 @@ test("shows an interactive data point link in a rangeBar tooltip", async ({ }; }); expect(colors.link).toBe(colors.tooltip); - await link.hover(); - await expect(link).toBeVisible(); - await page.locator("#test-chart .apexcharts-rangebar-area").nth(1).hover(); - await expect(page.locator("#test-chart .apexcharts-tooltip a")).toHaveCount( - 0, - ); - await page.locator("#test-chart .apexcharts-rangebar-area").first().hover(); + for (let attempt = 0; attempt < 10; attempt++) { + await link.hover(); + await expect(link).toBeVisible(); + await page.locator("#test-chart .apexcharts-rangebar-area").nth(1).hover(); + await expect(page.locator("#test-chart .apexcharts-tooltip a")).toHaveCount( + 0, + ); + await page.locator("#test-chart .apexcharts-rangebar-area").first().hover(); + await expect(link).toBeVisible(); + } await link.click(); await expect(page).toHaveURL(/workpackage_edit/); }); From 61094138941d45ae958816c5cb2d5e2ffd16ac9f Mon Sep 17 00:00:00 2001 From: lovasoa Date: Sun, 13 Sep 2026 00:41:14 +0200 Subject: [PATCH 5/5] Rely on ApexCharts for interactive tooltips --- CHANGELOG.md | 2 +- sqlpage/apexcharts.js | 18 +----------- sqlpage/sqlpage.css | 12 -------- tests/end-to-end/fixtures/chart/test.ts | 37 ++----------------------- 4 files changed, 4 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9543c462..6fac799d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## unreleased -- **Beta:** Chart data points can now include a `link`. The link is shown in an interactive tooltip, and clicking the data point itself also follows it. Interactive tooltips depend on [upstream ApexCharts support](https://github.com/apexcharts/apexcharts.js/issues/4469). +- **Beta:** Chart data points can now include a `link`. The link is shown in an interactive tooltip, and clicking the data point itself also follows it. Interactive tooltips depend on [ApexCharts PR #5307](https://github.com/apexcharts/apexcharts.js/pull/5307). ## v0.46.2 diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index 670e02d8..9ca89ca2 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -368,6 +368,7 @@ sqlpage_chart = (() => { }, tooltip: { fillSeriesColor: false, + interactive: has_point_links, custom: has_point_links ? (args) => chartTooltip(args, points) : chart_type === "bubble" || chart_type === "scatter" @@ -405,7 +406,6 @@ sqlpage_chart = (() => { if (data.xticks) options.xaxis.tickAmount = data.xticks; const chart = new ApexCharts(chartContainer, options); chart.render(); - keepLinkedTooltipOpen(chartContainer); if (window.charts) window.charts.push(chart); else window.charts = [chart]; c.removeAttribute("data-pre-init"); @@ -470,22 +470,6 @@ sqlpage_chart = (() => { return chartTooltip(args, []); } - /** @param {HTMLElement} chartContainer */ - function keepLinkedTooltipOpen(chartContainer) { - chartContainer.addEventListener( - "mouseout", - (event) => { - const nextTarget = event.relatedTarget; - if ( - nextTarget instanceof Element && - nextTarget.closest(".apexcharts-tooltip")?.querySelector("a") - ) - event.stopPropagation(); - }, - true, - ); - } - return sqlpage_chart; })(); diff --git a/sqlpage/sqlpage.css b/sqlpage/sqlpage.css index 8fe63bad..619726b7 100644 --- a/sqlpage/sqlpage.css +++ b/sqlpage/sqlpage.css @@ -64,18 +64,6 @@ code { color: inherit; } -.apexcharts-tooltip.apexcharts-active:has(a) { - isolation: isolate; - pointer-events: auto; -} - -.apexcharts-tooltip.apexcharts-active:has(a)::before { - content: ""; - position: absolute; - inset: -12px; - z-index: -1; -} - .apexcharts-tooltip a { color: currentColor; text-decoration: underline; diff --git a/tests/end-to-end/fixtures/chart/test.ts b/tests/end-to-end/fixtures/chart/test.ts index 3db12c07..6370b8eb 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -367,9 +367,7 @@ test("leaves a rangeBar chart on a category axis alone", async ({ page }) => { expect(chart.shapes).toHaveLength(2); }); -test("shows an interactive data point link in a rangeBar tooltip", async ({ - page, -}) => { +test("shows a data point link in a rangeBar tooltip", async ({ page }) => { await renderChart(page, "link"); await page.locator("#test-chart .apexcharts-rangebar-area").first().hover(); @@ -381,10 +379,6 @@ test("shows an interactive data point link in a rangeBar tooltip", async ({ "href", "/workpackage_edit.sql?workpackage_name=Design", ); - await expect(page.locator("#test-chart .apexcharts-tooltip")).toHaveCSS( - "pointer-events", - "auto", - ); await expect( page.locator("#test-chart .apexcharts-rangebar-area").first(), ).toHaveCSS("cursor", "pointer"); @@ -402,17 +396,7 @@ test("shows an interactive data point link in a rangeBar tooltip", async ({ }; }); expect(colors.link).toBe(colors.tooltip); - for (let attempt = 0; attempt < 10; attempt++) { - await link.hover(); - await expect(link).toBeVisible(); - await page.locator("#test-chart .apexcharts-rangebar-area").nth(1).hover(); - await expect(page.locator("#test-chart .apexcharts-tooltip a")).toHaveCount( - 0, - ); - await page.locator("#test-chart .apexcharts-rangebar-area").first().hover(); - await expect(link).toBeVisible(); - } - await link.click(); + await page.locator("#test-chart .apexcharts-rangebar-area").first().click(); await expect(page).toHaveURL(/workpackage_edit/); }); @@ -432,23 +416,6 @@ for (const [type, mark] of [ }); } -for (const [type, mark] of [ - ["bar", ".apexcharts-bar-area"], - ["scatter", ".apexcharts-marker"], -]) { - test(`keeps a data point link open when entering a ${type} tooltip`, async ({ - page, - }) => { - await renderChart(page, `link-${type}`); - - await page.locator(`#test-chart ${mark}`).nth(0).hover({ force: true }); - const link = page.locator("#test-chart .apexcharts-tooltip a"); - await expect(link).toHaveCount(1); - await link.hover(); - await expect(link).toBeVisible(); - }); -} - test("links each slice of a pie chart from its own row", async ({ page }) => { const chart = await renderChart(page, "link-pie");