Skip to content

Commit 5670b69

Browse files
committed
Add chart datapoint links via metadata tuples
1 parent 70cf59e commit 5670b69

4 files changed

Lines changed: 133 additions & 11 deletions

File tree

examples/official-site/sqlpage/migrations/01_documentation.sql

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,8 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S
665665
('y', 'The value of the point on the vertical axis', 'REAL', FALSE, FALSE),
666666
('label', 'An alias for parameter "x"', 'REAL', FALSE, TRUE),
667667
('value', 'An alias for parameter "y"', 'REAL', FALSE, TRUE),
668-
('series', 'If multiple series are represented and share the same y-axis, this parameter can be used to distinguish between them.', 'TEXT', FALSE, TRUE)
668+
('series', 'If multiple series are represented and share the same y-axis, this parameter can be used to distinguish between them.', 'TEXT', FALSE, TRUE),
669+
('link', 'URL opened when user clicks this datapoint or slice.', 'URL', FALSE, TRUE)
669670
) x;
670671
INSERT INTO example(component, description, properties) VALUES
671672
('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
692693
('chart', 'A basic bar chart', json('[
693694
{"component":"chart", "type": "bar", "title": "Quarterly Results", "horizontal": true, "labels": true},
694695
{"label": "Tom", "value": 35}, {"label": "Olive", "value": 15}]')),
696+
('chart', 'A bar chart with clickable datapoints. Each row can set a `link` URL; clicking a datapoint opens that URL.', json('[
697+
{"component":"chart", "type": "bar", "title": "Linked Sales", "labels": true},
698+
{"label": "North", "value": 35, "link": "/documentation.sql?component=table"},
699+
{"label": "South", "value": 22},
700+
{"label": "West", "value": 41, "link": "/documentation.sql?component=map"}
701+
]')),
702+
('chart', 'A pie chart with per-slice links.', json('[
703+
{"component":"chart", "title": "Linked Answers", "type": "pie", "labels": true},
704+
{"label": "Yes", "value": 65, "link": "/documentation.sql?component=form"},
705+
{"label": "No", "value": 35, "link": "/documentation.sql?component=table"}
706+
]')),
707+
('chart', 'A bubble chart demonstrating `z` and `link` metadata together on points.',
708+
json('[
709+
{"component":"chart", "title": "Bubbles with links", "type": "bubble", "ztitle": "Population", "marker": 8},
710+
{"series": "Europe", "x": 2.1, "y": 18.5, "z": 742, "link": "/documentation.sql?component=chart"},
711+
{"series": "Asia", "x": 5.2, "y": 24.1, "z": 4700, "link": "/documentation.sql?component=map"}
712+
]')),
695713
('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.',
696714
json('[
697715
{"component":"chart", "type": "treemap", "title": "Quarterly Results By Region (in k$)", "labels": true },

sqlpage/apexcharts.js

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ sqlpage_chart = (() => {
3636
);
3737
const isDarkTheme = document.body?.dataset?.bsTheme === "dark";
3838

39-
/** @typedef { { [name:string]: {data:{x:number|string|Date,y:number}[], name:string} } } Series */
39+
/** @typedef { { [name:string]: {data:{x:number|string|Date,y:number,z?:number|string,l?:string}[], name:string} } } Series */
4040

4141
/**
4242
* Aligns series data points by their x-axis categories, ensuring all series have data points
@@ -78,19 +78,37 @@ sqlpage_chart = (() => {
7878
series_idxs.splice(series_idxs.indexOf(idx_of_xmin), 1);
7979
}
8080
}
81-
// Create a map of category -> value for each series and rebuild
81+
// Create a map of category -> point for each series and rebuild
8282
return series.map((s) => {
83-
const valueMap = new Map(s.data.map((point) => [point.x, point.y]));
83+
const valueMap = new Map(s.data.map((point) => [point.x, point]));
8484
return {
8585
name: s.name,
86-
data: Array.from(categoriesSet, (category) => ({
87-
x: category,
88-
y: valueMap.get(category) || 0,
89-
})),
86+
data: Array.from(
87+
categoriesSet,
88+
(category) =>
89+
valueMap.get(category) || {
90+
x: category,
91+
y: 0,
92+
},
93+
),
9094
};
9195
});
9296
}
9397

98+
function resolvePointLink(data, opts, pieLinks) {
99+
if (opts.dataPointIndex == null || opts.dataPointIndex < 0)
100+
return undefined;
101+
if (data.type === "pie") return pieLinks[opts.dataPointIndex];
102+
if (opts.seriesIndex == null || opts.seriesIndex < 0) return undefined;
103+
const series = opts.w?.config?.series?.[opts.seriesIndex];
104+
const point = series?.data?.[opts.dataPointIndex];
105+
return point?.l;
106+
}
107+
108+
function navigateIfLink(link) {
109+
if (typeof link === "string" && link) window.location.href = link;
110+
}
111+
94112
/** @param {HTMLElement} c */
95113
function build_sqlpage_chart(c) {
96114
const [data_element] = c.getElementsByTagName("data");
@@ -100,7 +118,27 @@ sqlpage_chart = (() => {
100118
const is_timeseries = !!data.time;
101119
/** @type { Series } */
102120
const series_map = {};
103-
for (const [name, old_x, old_y, z] of data.points) {
121+
const pieLinks = [];
122+
let warnedLegacyPointMetadata = false;
123+
for (const point of data.points) {
124+
const [name, old_x, old_y] = point;
125+
let meta;
126+
if (point.length === 4) {
127+
if (
128+
typeof point[3] === "object" &&
129+
point[3] != null &&
130+
!Array.isArray(point[3])
131+
) {
132+
meta = point[3];
133+
} else if (!warnedLegacyPointMetadata) {
134+
warnedLegacyPointMetadata = true;
135+
console.warn(
136+
"Chart point metadata must be an object in the 4th tuple slot. Legacy formats are ignored.",
137+
);
138+
}
139+
}
140+
const z = meta?.z;
141+
const link = meta?.l;
104142
series_map[name] = series_map[name] || { name, data: [] };
105143
let x = old_x;
106144
let y = old_y;
@@ -110,7 +148,11 @@ sqlpage_chart = (() => {
110148
y = y.map((y) => new Date(y).getTime());
111149
else x = new Date(x);
112150
}
113-
series_map[name].data.push({ x, y, z });
151+
const seriesPoint = { x, y };
152+
if (z != null) seriesPoint.z = z;
153+
if (link != null) seriesPoint.l = link;
154+
series_map[name].data.push(seriesPoint);
155+
pieLinks.push(link);
114156
}
115157
if (data.xmin == null) data.xmin = undefined;
116158
if (data.xmax == null) data.xmax = undefined;
@@ -135,6 +177,16 @@ sqlpage_chart = (() => {
135177
series = align_categories(series);
136178

137179
const chart_type = data.type || "line";
180+
let skipNextChartClick = false;
181+
const onDataPointInteraction = (_event, _chartContext, opts) => {
182+
const link = resolvePointLink(data, opts, pieLinks);
183+
if (!link) return;
184+
skipNextChartClick = true;
185+
navigateIfLink(link);
186+
setTimeout(() => {
187+
skipNextChartClick = false;
188+
}, 0);
189+
};
138190
const options = {
139191
chart: {
140192
type: chart_type,
@@ -151,6 +203,13 @@ sqlpage_chart = (() => {
151203
zoom: {
152204
enabled: false,
153205
},
206+
events: {
207+
dataPointSelection: onDataPointInteraction,
208+
click: (event, chartContext, opts) => {
209+
if (skipNextChartClick) return;
210+
onDataPointInteraction(event, chartContext, opts);
211+
},
212+
},
154213
},
155214
theme: {
156215
palette: "palette4",

sqlpage/templates/chart.handlebars

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,15 @@
4343
{{~ stringify (default series (default ../title "")) ~}},
4444
{{~ stringify (default x label) ~}},
4545
{{~ stringify (default y value) ~}}
46-
{{~#if z}}, {{~ stringify z ~}} {{~/if~}}
46+
{{~#if (or (or z (eq z 0)) link)~}}, {
47+
{{~#if (or z (eq z 0))~}}
48+
"z": {{~ stringify z ~}}
49+
{{~#if link~}},{{/if~}}
50+
{{~/if~}}
51+
{{~#if link~}}
52+
"l": {{~ stringify link ~}}
53+
{{~/if~}}
54+
}{{~/if~}}
4755
]
4856
{{~/each_row~}}
4957
]

tests/end-to-end/official-site.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,43 @@ test("chart", async ({ page }) => {
2424
await expect(page.locator(".apexcharts-canvas").first()).toBeVisible();
2525
});
2626

27+
test("chart point links - bar", async ({ page }) => {
28+
await page.goto(`${BASE}/documentation.sql?component=chart`);
29+
const linkedBarCard = page.locator(".card", {
30+
has: page.getByRole("heading", { name: "Linked Sales" }),
31+
});
32+
await expect(linkedBarCard.locator(".apexcharts-canvas")).toBeVisible();
33+
await linkedBarCard
34+
.locator(".apexcharts-series path, .apexcharts-series rect")
35+
.first()
36+
.click();
37+
await expect(page).toHaveURL(/component=table/);
38+
});
39+
40+
test("chart point links - pie", async ({ page }) => {
41+
await page.goto(`${BASE}/documentation.sql?component=chart`);
42+
const linkedPieCard = page.locator(".card", {
43+
has: page.getByRole("heading", { name: "Linked Answers" }),
44+
});
45+
await expect(linkedPieCard.locator(".apexcharts-canvas")).toBeVisible();
46+
await linkedPieCard.locator(".apexcharts-pie-series path").first().click();
47+
await expect(page).toHaveURL(/component=form/);
48+
});
49+
50+
test("chart links - no-link datapoint", async ({ page }) => {
51+
await page.goto(`${BASE}/documentation.sql?component=chart`);
52+
const linkedBarCard = page.locator(".card", {
53+
has: page.getByRole("heading", { name: "Linked Sales" }),
54+
});
55+
await expect(linkedBarCard.locator(".apexcharts-canvas")).toBeVisible();
56+
const initialUrl = page.url();
57+
await linkedBarCard
58+
.locator(".apexcharts-series path, .apexcharts-series rect")
59+
.nth(1)
60+
.click();
61+
await expect(page).toHaveURL(initialUrl);
62+
});
63+
2764
test("map", async ({ page }) => {
2865
await page.goto(`${BASE}/documentation.sql?component=map#component`);
2966
await expect(page.getByText("Loading...")).not.toBeVisible();

0 commit comments

Comments
 (0)