Skip to content
Draft
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## 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 [ApexCharts PR #5307](https://github.com/apexcharts/apexcharts.js/pull/5307).

## 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', '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),
Expand Down Expand Up @@ -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 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"]},
{"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"]}
]')),
Expand Down
59 changes: 49 additions & 10 deletions sqlpage/apexcharts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -252,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(
Expand Down Expand Up @@ -283,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",
Expand Down Expand Up @@ -355,8 +368,10 @@ sqlpage_chart = (() => {
},
tooltip: {
fillSeriesColor: false,
custom:
chart_type === "bubble" || chart_type === "scatter"
interactive: has_point_links,
custom: has_point_links
? (args) => chartTooltip(args, points)
: chart_type === "bubble" || chart_type === "scatter"
? bubbleTooltip
: undefined,
y: {
Expand Down Expand Up @@ -396,17 +411,25 @@ 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 = pointLink({ seriesIndex, dataPointIndex, w }, raw_points);

const tooltip = document.createElement("div");
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);

Expand All @@ -424,13 +447,29 @@ 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);
}
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, []);
}

return sqlpage_chart;
})();

Expand Down
10 changes: 10 additions & 0 deletions sqlpage/sqlpage.css
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ code {
color: inherit;
}

.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;
Expand Down
5 changes: 3 additions & 2 deletions sqlpage/templates/chart.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -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~}}
Expand Down
6 changes: 0 additions & 6 deletions tests/components/chart_point_serialization.sql

This file was deleted.

4 changes: 4 additions & 0 deletions tests/end-to-end/fixtures/chart/link-bar.sql
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions tests/end-to-end/fixtures/chart/link-line.sql
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions tests/end-to-end/fixtures/chart/link-pie.sql
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions tests/end-to-end/fixtures/chart/link-scatter.sql
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions tests/end-to-end/fixtures/chart/link.sql
Original file line number Diff line number Diff line change
@@ -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;
70 changes: 70 additions & 0 deletions tests/end-to-end/fixtures/chart/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,76 @@ test("leaves a rangeBar chart on a category axis alone", async ({ page }) => {
expect(chart.shapes).toHaveLength(2);
});

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();
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",
);
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");
return {
link: getComputedStyle(anchor).color,
tooltip: getComputedStyle(tooltip).color,
};
});
expect(colors.link).toBe(colors.tooltip);
await page.locator("#test-chart .apexcharts-rangebar-area").first().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");

Expand Down