Skip to content

Commit 8017972

Browse files
committed
improve popularity chart for limited data and tooltip readability
1 parent 40ba406 commit 8017972

2 files changed

Lines changed: 202 additions & 9 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,51 @@
1+
@use "sass:map";
2+
13
popularity-chart {
24
display: block;
35
// 600px: comfortable max on large screens (leaves room for navbar, heading, footer on 900px viewports)
46
// 65dvh: viewport-relative cap so the chart never causes vertical scrolling
57
height: min(600px, 65dvh);
8+
position: relative;
69

710
// prevent scroll bars during graph rendering
811
canvas {
912
display: none;
1013
}
1114
}
15+
16+
.chart-tooltip {
17+
position: absolute;
18+
pointer-events: none;
19+
opacity: 0;
20+
transform: translate(-50%, -100%);
21+
background: var(--bs-body-bg);
22+
border: $border-width solid var(--bs-border-color);
23+
border-radius: $border-radius;
24+
padding: map.get($spacers, 2) map.get($spacers, 3);
25+
font-size: $font-size-sm;
26+
white-space: nowrap;
27+
z-index: $zindex-tooltip;
28+
box-shadow: $box-shadow-sm;
29+
30+
.chart-tooltip-title {
31+
font-weight: bold;
32+
margin-bottom: map.get($spacers, 1);
33+
text-align: center;
34+
}
35+
36+
table {
37+
border-spacing: 0;
38+
39+
td {
40+
padding: 0;
41+
42+
&:nth-child(2) {
43+
padding: 0 map.get($spacers, 3) 0 map.get($spacers, 2);
44+
}
45+
46+
&:last-child {
47+
text-align: right;
48+
}
49+
}
50+
}
51+
}

src/components/popularity-chart.ts

Lines changed: 162 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,25 @@ function renderYearMonth(yearMonth: number | string): string {
2626
return `${s.substring(0, 4)}-${s.substring(4, 6)}`;
2727
}
2828

29+
function getTooltipElement(chart: {
30+
canvas: HTMLCanvasElement;
31+
}): HTMLDivElement {
32+
const parent = chart.canvas.parentElement;
33+
if (!parent) {
34+
throw new Error("chart-tooltip: canvas has no parent element");
35+
}
36+
37+
let el = parent.querySelector(".chart-tooltip") as HTMLDivElement | null;
38+
39+
if (!el) {
40+
el = document.createElement("div");
41+
el.className = "chart-tooltip";
42+
parent.appendChild(el);
43+
}
44+
45+
return el;
46+
}
47+
2948
class PopularityChart extends HTMLElement {
3049
connectedCallback() {
3150
const script = this.querySelector('script[type="application/json"]');
@@ -54,10 +73,117 @@ class PopularityChart extends HTMLElement {
5473
canvas.height = 720;
5574
this.appendChild(canvas);
5675

57-
this.drawChart(canvas, data);
76+
const style = getComputedStyle(document.documentElement);
77+
const textColor = style.getPropertyValue("--bs-body-color");
78+
const gridColor = style.getPropertyValue("--bs-border-color");
79+
80+
if (data.labels.length < 3) {
81+
this.drawBarChart(canvas, data, textColor, gridColor);
82+
} else {
83+
this.drawLineChart(canvas, data, textColor, gridColor);
84+
}
5885
}
5986

60-
private async drawChart(canvas: HTMLCanvasElement, data: ChartData) {
87+
private async drawBarChart(
88+
canvas: HTMLCanvasElement,
89+
data: ChartData,
90+
textColor: string,
91+
gridColor: string,
92+
) {
93+
const {
94+
Chart,
95+
BarElement,
96+
BarController,
97+
CategoryScale,
98+
LinearScale,
99+
Tooltip,
100+
} = await import("chart.js");
101+
102+
Chart.register(
103+
BarElement,
104+
BarController,
105+
CategoryScale,
106+
LinearScale,
107+
Tooltip,
108+
);
109+
110+
const lastIndex = data.labels.length - 1;
111+
const barData = {
112+
labels: data.datasets.map((ds) => ds.label),
113+
datasets: [
114+
{
115+
label: renderYearMonth(data.labels[lastIndex]),
116+
data: data.datasets.map((ds) => ds.data[lastIndex] ?? 0),
117+
backgroundColor: colors.slice(0, data.datasets.length),
118+
},
119+
],
120+
};
121+
122+
new Chart(canvas, {
123+
type: "bar",
124+
data: barData,
125+
options: {
126+
indexAxis: "y",
127+
animation: false,
128+
maintainAspectRatio: false,
129+
plugins: {
130+
legend: {
131+
display: false,
132+
},
133+
tooltip: {
134+
enabled: false,
135+
external: ({ chart, tooltip }) => {
136+
const el = getTooltipElement(chart);
137+
138+
if (tooltip.opacity === 0) {
139+
el.style.opacity = "0";
140+
return;
141+
}
142+
143+
const item = tooltip.dataPoints[0];
144+
el.innerHTML = (item.raw as number).toFixed(2);
145+
el.style.opacity = "1";
146+
el.style.left = `${tooltip.caretX}px`;
147+
el.style.top = `${tooltip.caretY}px`;
148+
},
149+
},
150+
},
151+
scales: {
152+
x: {
153+
min: 0,
154+
max: 100,
155+
border: {
156+
color: gridColor,
157+
},
158+
grid: {
159+
color: gridColor,
160+
},
161+
ticks: {
162+
color: textColor,
163+
},
164+
},
165+
y: {
166+
border: {
167+
color: gridColor,
168+
},
169+
grid: {
170+
display: false,
171+
},
172+
ticks: {
173+
color: textColor,
174+
},
175+
},
176+
},
177+
},
178+
});
179+
}
180+
181+
private async drawLineChart(
182+
canvas: HTMLCanvasElement,
183+
data: ChartData,
184+
textColor: string,
185+
gridColor: string,
186+
) {
61187
const {
62188
Chart,
63189
LineElement,
@@ -79,26 +205,47 @@ class PopularityChart extends HTMLElement {
79205
Tooltip,
80206
);
81207

82-
const style = getComputedStyle(document.documentElement);
83-
const textColor = style.getPropertyValue("--bs-body-color");
84-
const gridColor = style.getPropertyValue("--bs-border-color");
85-
86208
new Chart(canvas, {
87209
type: "line",
88210
data,
89211
options: {
212+
animation: false,
90213
maintainAspectRatio: false,
91214
interaction: {
92215
mode: "index",
93216
intersect: false,
94217
},
95218
plugins: {
96219
tooltip: {
97-
displayColors: false,
220+
enabled: false,
98221
itemSort: (a, b) =>
99222
(b.raw as number) - (a.raw as number),
100-
callbacks: {
101-
title: (items) => renderYearMonth(items[0].label),
223+
external: ({ chart, tooltip }) => {
224+
const el = getTooltipElement(chart);
225+
226+
if (tooltip.opacity === 0) {
227+
el.style.opacity = "0";
228+
return;
229+
}
230+
231+
const rows = tooltip.dataPoints
232+
.map((item) => {
233+
const color =
234+
colors[
235+
item.datasetIndex % colors.length
236+
];
237+
return `<tr>
238+
<td style="color:${color}">&#9679;</td>
239+
<td>${item.dataset.label}</td>
240+
<td>${(item.raw as number).toFixed(2)}</td>
241+
</tr>`;
242+
})
243+
.join("");
244+
245+
el.innerHTML = `<div class="chart-tooltip-title">${renderYearMonth(tooltip.title[0])}</div><table>${rows}</table>`;
246+
el.style.opacity = "1";
247+
el.style.left = `${tooltip.caretX}px`;
248+
el.style.top = `${tooltip.caretY}px`;
102249
},
103250
},
104251
legend: {
@@ -110,6 +257,9 @@ class PopularityChart extends HTMLElement {
110257
normalized: true,
111258
scales: {
112259
x: {
260+
border: {
261+
color: gridColor,
262+
},
113263
ticks: {
114264
callback(val) {
115265
return renderYearMonth(
@@ -127,6 +277,9 @@ class PopularityChart extends HTMLElement {
127277
y: {
128278
type: "linear",
129279
min: 0,
280+
border: {
281+
color: gridColor,
282+
},
130283
grid: {
131284
color: gridColor,
132285
},

0 commit comments

Comments
 (0)