@celestia-island/hikari 0.55.207 → 0.55.208
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/HkTreemap.scss +88 -0
- package/src/components/HkTreemap.test.tsx +452 -0
- package/src/components/HkTreemap.tsx +567 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
.hk-treemap {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: flex;
|
|
4
|
+
flex-direction: column;
|
|
5
|
+
min-width: 0;
|
|
6
|
+
min-height: 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.hk-treemap__legend {
|
|
10
|
+
display: flex;
|
|
11
|
+
flex: 0 0 auto;
|
|
12
|
+
flex-wrap: wrap;
|
|
13
|
+
gap: 0.375rem 0.875rem;
|
|
14
|
+
margin: 0 0 0.625rem;
|
|
15
|
+
padding: 0;
|
|
16
|
+
list-style: none;
|
|
17
|
+
font-size: var(--text-xs, 0.75rem);
|
|
18
|
+
line-height: 1.4;
|
|
19
|
+
color: rgb(var(--color-text, 32 32 32));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.hk-treemap__legend-item {
|
|
23
|
+
display: inline-flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
min-width: 0;
|
|
26
|
+
gap: 0.3rem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.hk-treemap__legend-swatch {
|
|
30
|
+
flex: 0 0 auto;
|
|
31
|
+
width: 0.625rem;
|
|
32
|
+
height: 0.625rem;
|
|
33
|
+
border-radius: 0.1875rem;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.hk-treemap__legend-label {
|
|
37
|
+
max-width: 14rem;
|
|
38
|
+
overflow: hidden;
|
|
39
|
+
text-overflow: ellipsis;
|
|
40
|
+
white-space: nowrap;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.hk-treemap__legend-value {
|
|
44
|
+
color: rgb(var(--color-muted, 108 108 108));
|
|
45
|
+
font-variant-numeric: tabular-nums;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.hk-treemap__legend-percent {
|
|
49
|
+
color: rgb(var(--color-muted, 108 108 108));
|
|
50
|
+
font-variant-numeric: tabular-nums;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.hk-treemap__body {
|
|
54
|
+
position: relative;
|
|
55
|
+
flex: 1 1 auto;
|
|
56
|
+
width: 100%;
|
|
57
|
+
min-height: 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.hk-treemap__chart {
|
|
61
|
+
position: absolute;
|
|
62
|
+
inset: 0;
|
|
63
|
+
width: 100%;
|
|
64
|
+
height: 100%;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.hk-treemap__empty {
|
|
68
|
+
position: absolute;
|
|
69
|
+
inset: 0;
|
|
70
|
+
display: grid;
|
|
71
|
+
place-items: center;
|
|
72
|
+
padding: 1rem;
|
|
73
|
+
color: rgb(var(--color-muted, 108 108 108));
|
|
74
|
+
font-size: var(--text-sm, 0.8125rem);
|
|
75
|
+
text-align: center;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.hk-treemap__a11y-summary {
|
|
79
|
+
position: absolute;
|
|
80
|
+
width: 1px;
|
|
81
|
+
height: 1px;
|
|
82
|
+
padding: 0;
|
|
83
|
+
margin: -1px;
|
|
84
|
+
overflow: hidden;
|
|
85
|
+
clip-path: inset(50%);
|
|
86
|
+
white-space: nowrap;
|
|
87
|
+
border: 0;
|
|
88
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
|
3
|
+
|
|
4
|
+
const H = vi.hoisted(() => {
|
|
5
|
+
class FakeChart {
|
|
6
|
+
static instances: FakeChart[] = [];
|
|
7
|
+
readonly options: unknown[] = [];
|
|
8
|
+
clearCount = 0;
|
|
9
|
+
resizeCount = 0;
|
|
10
|
+
disposed = false;
|
|
11
|
+
|
|
12
|
+
constructor(readonly element: HTMLElement) {
|
|
13
|
+
FakeChart.instances.push(this);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
setOption(option: unknown): void {
|
|
17
|
+
this.options.push(option);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
clear(): void {
|
|
21
|
+
this.clearCount += 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
resize(): void {
|
|
25
|
+
this.resizeCount += 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
dispose(): void {
|
|
29
|
+
this.disposed = true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
FakeChart,
|
|
35
|
+
init: vi.fn(),
|
|
36
|
+
use: vi.fn(),
|
|
37
|
+
importGate: null as Promise<void> | null,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
vi.mock("echarts/core", () => ({
|
|
42
|
+
init: H.init,
|
|
43
|
+
use: H.use,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
vi.mock("echarts/charts", async () => {
|
|
47
|
+
if (H.importGate) await H.importGate;
|
|
48
|
+
return { TreemapChart: { name: "TreemapChart" } };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
vi.mock("echarts/components", () => ({
|
|
52
|
+
TooltipComponent: { name: "TooltipComponent" },
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
vi.mock("echarts/renderers", () => ({
|
|
56
|
+
CanvasRenderer: { name: "CanvasRenderer" },
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
import HkTreemap, {
|
|
60
|
+
HK_TREEMAP_DEFAULT_COLORS,
|
|
61
|
+
buildHkTreemapLayout,
|
|
62
|
+
contrastTextColor,
|
|
63
|
+
createHkTreemapOption,
|
|
64
|
+
createHkTreemapSummary,
|
|
65
|
+
formatHkTreemapPercent,
|
|
66
|
+
type HkTreemapNode,
|
|
67
|
+
} from "./HkTreemap";
|
|
68
|
+
|
|
69
|
+
class FakeResizeObserver {
|
|
70
|
+
static instances: FakeResizeObserver[] = [];
|
|
71
|
+
readonly observed: Element[] = [];
|
|
72
|
+
disconnected = false;
|
|
73
|
+
|
|
74
|
+
constructor(readonly callback: () => void) {
|
|
75
|
+
FakeResizeObserver.instances.push(this);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
observe(element: Element): void {
|
|
79
|
+
this.observed.push(element);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
disconnect(): void {
|
|
83
|
+
this.disconnected = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
88
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
89
|
+
|
|
90
|
+
const NODES: HkTreemapNode[] = [
|
|
91
|
+
{
|
|
92
|
+
id: "group",
|
|
93
|
+
label: "Group",
|
|
94
|
+
value: 999,
|
|
95
|
+
children: [
|
|
96
|
+
{ id: "a", label: "Alpha", value: 2 },
|
|
97
|
+
{ id: "b", label: "Beta", value: 4 },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
{ id: "other", label: "Other", value: 4 },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
function mountTreemap(initialProps: Record<string, unknown> = { nodes: NODES }) {
|
|
104
|
+
const props = reactive<Record<string, unknown>>({ ...initialProps });
|
|
105
|
+
const container = document.createElement("div");
|
|
106
|
+
document.body.appendChild(container);
|
|
107
|
+
const app = createApp(
|
|
108
|
+
defineComponent({
|
|
109
|
+
setup() {
|
|
110
|
+
return () => h(HkTreemap, { ...props });
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
app.mount(container);
|
|
115
|
+
const mounted = { app, container };
|
|
116
|
+
mounts.push(mounted);
|
|
117
|
+
return {
|
|
118
|
+
...mounted,
|
|
119
|
+
update(next: Record<string, unknown>) {
|
|
120
|
+
Object.assign(props, next);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function unmount(mounted: { app: ReturnType<typeof createApp>; container: HTMLElement }) {
|
|
126
|
+
const index = mounts.findIndex((item) => item.app === mounted.app);
|
|
127
|
+
if (index >= 0) mounts.splice(index, 1);
|
|
128
|
+
mounted.app.unmount();
|
|
129
|
+
mounted.container.remove();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function waitForChart(): Promise<void> {
|
|
133
|
+
await vi.waitFor(() => expect(H.FakeChart.instances).toHaveLength(1));
|
|
134
|
+
await nextTick();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function lastOption(): any {
|
|
138
|
+
const chart = H.FakeChart.instances[0]!;
|
|
139
|
+
return chart.options[chart.options.length - 1];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
vi.clearAllMocks();
|
|
144
|
+
H.FakeChart.instances = [];
|
|
145
|
+
H.importGate = null;
|
|
146
|
+
H.init.mockImplementation((element: HTMLElement) => new H.FakeChart(element));
|
|
147
|
+
FakeResizeObserver.instances = [];
|
|
148
|
+
globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
afterEach(() => {
|
|
152
|
+
for (const mounted of mounts.splice(0)) {
|
|
153
|
+
mounted.app.unmount();
|
|
154
|
+
mounted.container.remove();
|
|
155
|
+
}
|
|
156
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("HkTreemap pure layout input", () => {
|
|
160
|
+
it("sums nested groups from leaves and ignores caller-provided parent values", () => {
|
|
161
|
+
const layout = buildHkTreemapLayout([
|
|
162
|
+
{
|
|
163
|
+
id: "root",
|
|
164
|
+
label: "Root",
|
|
165
|
+
value: 1000,
|
|
166
|
+
children: [
|
|
167
|
+
{ id: "leaf", label: "Leaf", value: 2 },
|
|
168
|
+
{
|
|
169
|
+
id: "nested",
|
|
170
|
+
label: "Nested",
|
|
171
|
+
value: 900,
|
|
172
|
+
children: [{ id: "deep", label: "Deep", value: 3 }],
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
{ id: "top", label: "Top", value: 5 },
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
expect(layout.total).toBe(10);
|
|
180
|
+
expect(layout.nodes[0]!.value).toBe(5);
|
|
181
|
+
expect(layout.nodes[0]!.children![1]!.value).toBe(3);
|
|
182
|
+
expect(layout.nodes[1]!.value).toBe(5);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("filters zero, negative and non-finite values before calculating totals", () => {
|
|
186
|
+
const layout = buildHkTreemapLayout([
|
|
187
|
+
{
|
|
188
|
+
id: "valid",
|
|
189
|
+
label: "Valid",
|
|
190
|
+
value: 0,
|
|
191
|
+
children: [
|
|
192
|
+
{ id: "zero", label: "Zero", value: 0 },
|
|
193
|
+
{ id: "negative", label: "Negative", value: -2 },
|
|
194
|
+
{ id: "nan", label: "NaN", value: Number.NaN },
|
|
195
|
+
{ id: "infinite", label: "Infinite", value: Number.POSITIVE_INFINITY },
|
|
196
|
+
{ id: "neg-infinite", label: "Negative infinite", value: Number.NEGATIVE_INFINITY },
|
|
197
|
+
{ id: "kept", label: "Kept", value: 4 },
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "invalid-group",
|
|
202
|
+
label: "Invalid group",
|
|
203
|
+
value: 500,
|
|
204
|
+
children: [{ id: "invalid-leaf", label: "Invalid", value: 0 }],
|
|
205
|
+
},
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
expect(layout.nodes).toHaveLength(1);
|
|
209
|
+
expect(layout.nodes[0]!.children!.map((node) => node.id)).toEqual(["kept"]);
|
|
210
|
+
expect(layout.nodes[0]!.value).toBe(4);
|
|
211
|
+
expect(layout.total).toBe(4);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("uses all visible leaves in the chart as the percentage denominator", () => {
|
|
215
|
+
const layout = buildHkTreemapLayout(NODES);
|
|
216
|
+
const group = layout.nodes[0]!;
|
|
217
|
+
const alpha = group.children![0]!;
|
|
218
|
+
const beta = group.children![1]!;
|
|
219
|
+
const other = layout.nodes[1]!;
|
|
220
|
+
|
|
221
|
+
expect(layout.total).toBe(10);
|
|
222
|
+
expect(group.percent).toBe(60);
|
|
223
|
+
expect(alpha.percent).toBe(20);
|
|
224
|
+
expect(beta.percent).toBe(40);
|
|
225
|
+
expect(other.percent).toBe(40);
|
|
226
|
+
expect(alpha.percent + beta.percent + other.percent).toBe(100);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("assigns deterministic top-level palette colors and honors opaque overrides", () => {
|
|
230
|
+
const nodes: HkTreemapNode[] = [
|
|
231
|
+
{ id: "first", label: "First", value: 1 },
|
|
232
|
+
{
|
|
233
|
+
id: "custom",
|
|
234
|
+
label: "Custom",
|
|
235
|
+
value: 1,
|
|
236
|
+
color: "#123456",
|
|
237
|
+
children: [{ id: "child", label: "Child", value: 1 }],
|
|
238
|
+
},
|
|
239
|
+
{ id: "third", label: "Third", value: 1 },
|
|
240
|
+
];
|
|
241
|
+
const first = buildHkTreemapLayout(nodes);
|
|
242
|
+
const second = buildHkTreemapLayout(nodes);
|
|
243
|
+
|
|
244
|
+
expect(first).toEqual(second);
|
|
245
|
+
// Host hex overrides normalize to an opaque canvas-paintable rgb() form.
|
|
246
|
+
expect(first.nodes.map((node) => node.color)).toEqual([
|
|
247
|
+
HK_TREEMAP_DEFAULT_COLORS[0],
|
|
248
|
+
"rgb(18 52 86)",
|
|
249
|
+
HK_TREEMAP_DEFAULT_COLORS[2],
|
|
250
|
+
]);
|
|
251
|
+
expect(first.nodes[1]!.children![0]!.color).toBe("rgb(18 52 86)");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("returns a genuinely empty model for absent or entirely invalid data", () => {
|
|
255
|
+
expect(buildHkTreemapLayout([])).toEqual({ nodes: [], total: 0 });
|
|
256
|
+
expect(
|
|
257
|
+
buildHkTreemapLayout([
|
|
258
|
+
{ id: "zero", label: "Zero", value: 0 },
|
|
259
|
+
{ id: "nan", label: "NaN", value: Number.NaN },
|
|
260
|
+
]),
|
|
261
|
+
).toEqual({ nodes: [], total: 0 });
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("chooses readable label colours across pale, mid-tone and dark backgrounds", () => {
|
|
265
|
+
const option = createHkTreemapOption(buildHkTreemapLayout([
|
|
266
|
+
{ id: "pale", label: "Pale", value: 1, color: "#edc948" },
|
|
267
|
+
{ id: "dark", label: "Dark", value: 1, color: "#4e79a7" },
|
|
268
|
+
{ id: "mid", label: "Mid", value: 1, color: "#76b7b2" },
|
|
269
|
+
{ id: "rgb", label: "RGB", value: 1, color: "rgb(242, 142, 43)" },
|
|
270
|
+
{ id: "invalid", label: "Invalid", value: 1, color: "var(--x)" },
|
|
271
|
+
])) as any;
|
|
272
|
+
const data = option.series[0].data;
|
|
273
|
+
expect(data[0].label.color).toBe("#0b1220");
|
|
274
|
+
expect(data[0].upperLabel.color).toBe("#0b1220");
|
|
275
|
+
expect(data[1].label.color).toBe("#fff");
|
|
276
|
+
expect(data[2].label.color).toBe("#0b1220");
|
|
277
|
+
expect(data[3].label.color).toBe("#0b1220");
|
|
278
|
+
// An unparseable/translucent override falls back to the palette colour
|
|
279
|
+
// (inherited #e15759) instead of corrupting the canvas fill.
|
|
280
|
+
expect(data[4].label.color).toBe(contrastTextColor("#e15759"));
|
|
281
|
+
expect(contrastTextColor("#abc")).toBe("#0b1220");
|
|
282
|
+
expect(contrastTextColor("hsl(0, 0%, 100%)")).toBe("#0b1220");
|
|
283
|
+
expect(contrastTextColor("not-a-color")).toBe("#fff");
|
|
284
|
+
// Every default palette member must clear WCAG AA with its chosen text.
|
|
285
|
+
for (const color of HK_TREEMAP_DEFAULT_COLORS) {
|
|
286
|
+
expect(["#fff", "#0b1220"]).toContain(contrastTextColor(color));
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("paints group title bands with the same colour the title contrast describes", () => {
|
|
291
|
+
const option = createHkTreemapOption(buildHkTreemapLayout([
|
|
292
|
+
{
|
|
293
|
+
id: "group",
|
|
294
|
+
label: "Group",
|
|
295
|
+
value: 0,
|
|
296
|
+
color: "#76b7b2",
|
|
297
|
+
children: [{ id: "leaf", label: "Leaf", value: 1 }],
|
|
298
|
+
},
|
|
299
|
+
])) as any;
|
|
300
|
+
const series = option.series[0];
|
|
301
|
+
const group = series.data[0];
|
|
302
|
+
|
|
303
|
+
expect(series.animation).toBe(false);
|
|
304
|
+
expect(series.levels[0].upperLabel.show).toBe(false);
|
|
305
|
+
expect(series.levels[0].itemStyle.borderColor).toBe("transparent");
|
|
306
|
+
const normalized = "rgb(118 183 178)";
|
|
307
|
+
expect(group.itemStyle.color).toBe(normalized);
|
|
308
|
+
expect(group.itemStyle.borderColor).toBe(normalized);
|
|
309
|
+
expect(group.upperLabel.color).toBe(contrastTextColor(normalized));
|
|
310
|
+
// A light group title on a mid-tone band would be unreadable.
|
|
311
|
+
expect(group.upperLabel.color).toBe("#0b1220");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("feeds real values and percentages to leaf, group and tooltip formatters", () => {
|
|
315
|
+
const option = createHkTreemapOption(buildHkTreemapLayout(NODES), (value) => `${value} units`) as any;
|
|
316
|
+
const series = option.series[0];
|
|
317
|
+
const group = series.data[0];
|
|
318
|
+
const leaf = group.children[0];
|
|
319
|
+
|
|
320
|
+
expect(series.nodeClick).toBe(false);
|
|
321
|
+
expect(series.roam).toBe(false);
|
|
322
|
+
expect(series.breadcrumb.show).toBe(false);
|
|
323
|
+
expect(series.label.formatter({ data: leaf })).toBe("Alpha\n2 units · 20%");
|
|
324
|
+
expect(series.upperLabel.formatter({ data: group })).toBe("Group · 6 units · 60%");
|
|
325
|
+
expect(option.tooltip.formatter({ data: leaf })).toBe(
|
|
326
|
+
"<strong>Alpha</strong><br/>2 units · 20%",
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("escapes HTML in tooltip labels and formatted values", () => {
|
|
331
|
+
const option = createHkTreemapOption(
|
|
332
|
+
buildHkTreemapLayout([{ id: "html", label: "<unsafe>", value: 1 }]),
|
|
333
|
+
() => "<b>1</b>",
|
|
334
|
+
) as any;
|
|
335
|
+
const datum = option.series[0].data[0];
|
|
336
|
+
|
|
337
|
+
expect(option.tooltip.formatter({ data: datum })).toBe(
|
|
338
|
+
"<strong><unsafe></strong><br/><b>1</b> · 100%",
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("builds an accessible leaf-path summary from the same layout", () => {
|
|
343
|
+
const summary = createHkTreemapSummary(buildHkTreemapLayout(NODES), (value) => `${value}u`);
|
|
344
|
+
expect(summary).toBe("Group / Alpha: 2u (20%); Group / Beta: 4u (40%); Other: 4u (40%)");
|
|
345
|
+
expect(formatHkTreemapPercent(0.04)).toBe("<0.1%");
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe("HkTreemap component lifecycle", () => {
|
|
350
|
+
it("mounts lazily and renders legend, formatted values and accessible text", async () => {
|
|
351
|
+
const mounted = mountTreemap({
|
|
352
|
+
nodes: NODES,
|
|
353
|
+
ariaLabel: "Resource usage",
|
|
354
|
+
valueFormatter: (value: number) => `${value} units`,
|
|
355
|
+
});
|
|
356
|
+
await waitForChart();
|
|
357
|
+
|
|
358
|
+
expect(H.init).toHaveBeenCalledTimes(1);
|
|
359
|
+
expect(H.use).toHaveBeenCalledTimes(1);
|
|
360
|
+
expect(H.FakeChart.instances[0]!.options).toHaveLength(1);
|
|
361
|
+
expect(mounted.container.querySelector<HTMLElement>(".hk-treemap")!.style.height).toBe("320px");
|
|
362
|
+
expect(mounted.container.querySelectorAll(".hk-treemap__legend-item")).toHaveLength(2);
|
|
363
|
+
expect(mounted.container.textContent).toContain("6 units");
|
|
364
|
+
|
|
365
|
+
const chart = mounted.container.querySelector<HTMLElement>(".hk-treemap__chart")!;
|
|
366
|
+
expect(chart.getAttribute("role")).toBe("img");
|
|
367
|
+
expect(chart.getAttribute("aria-label")).toBe("Resource usage");
|
|
368
|
+
expect(chart.getAttribute("aria-hidden")).toBe("false");
|
|
369
|
+
const summaryId = chart.getAttribute("aria-describedby")!;
|
|
370
|
+
expect(mounted.container.querySelector(`#${summaryId}`)!.textContent).toBe(
|
|
371
|
+
"Group / Alpha: 2 units (20%); Group / Beta: 4 units (40%); Other: 4 units (40%)",
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("calls setOption when data or value formatting changes", async () => {
|
|
376
|
+
const mounted = mountTreemap();
|
|
377
|
+
await waitForChart();
|
|
378
|
+
const chart = H.FakeChart.instances[0]!;
|
|
379
|
+
expect(chart.options).toHaveLength(1);
|
|
380
|
+
|
|
381
|
+
mounted.update({ nodes: [{ id: "new", label: "New", value: 7 }] });
|
|
382
|
+
await vi.waitFor(() => expect(chart.options).toHaveLength(2));
|
|
383
|
+
expect(lastOption().series[0].data[0]).toMatchObject({ id: "new", value: 7 });
|
|
384
|
+
|
|
385
|
+
mounted.update({ valueFormatter: (value: number) => `${value} updated` });
|
|
386
|
+
await vi.waitFor(() => expect(chart.options).toHaveLength(3));
|
|
387
|
+
expect(lastOption().series[0].data[0].hkTreemap.valueText).toBe("7 updated");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("resizes with its container and window, then disconnects and disposes", async () => {
|
|
391
|
+
const mounted = mountTreemap();
|
|
392
|
+
await waitForChart();
|
|
393
|
+
const chart = H.FakeChart.instances[0]!;
|
|
394
|
+
const observer = FakeResizeObserver.instances[0]!;
|
|
395
|
+
const chartElement = mounted.container.querySelector(".hk-treemap__chart")!;
|
|
396
|
+
|
|
397
|
+
expect(observer.observed).toEqual([chartElement]);
|
|
398
|
+
observer.callback();
|
|
399
|
+
window.dispatchEvent(new Event("resize"));
|
|
400
|
+
expect(chart.resizeCount).toBe(2);
|
|
401
|
+
|
|
402
|
+
unmount(mounted);
|
|
403
|
+
expect(chart.disposed).toBe(true);
|
|
404
|
+
expect(observer.disconnected).toBe(true);
|
|
405
|
+
observer.callback();
|
|
406
|
+
window.dispatchEvent(new Event("resize"));
|
|
407
|
+
expect(chart.resizeCount).toBe(2);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("shows an honest empty state and clears stale options across updates", async () => {
|
|
411
|
+
const mounted = mountTreemap({ nodes: [], emptyText: "Nothing visible" });
|
|
412
|
+
await waitForChart();
|
|
413
|
+
const chart = H.FakeChart.instances[0]!;
|
|
414
|
+
|
|
415
|
+
expect(mounted.container.querySelector(".hk-treemap__empty")!.textContent).toBe("Nothing visible");
|
|
416
|
+
expect(mounted.container.querySelector(".hk-treemap__legend")).toBeNull();
|
|
417
|
+
expect(mounted.container.querySelector(".hk-treemap__chart")!.getAttribute("aria-hidden")).toBe("true");
|
|
418
|
+
expect(chart.clearCount).toBe(1);
|
|
419
|
+
|
|
420
|
+
mounted.update({ nodes: NODES });
|
|
421
|
+
await vi.waitFor(() => expect(chart.options).toHaveLength(1));
|
|
422
|
+
expect(mounted.container.querySelector(".hk-treemap__empty")).toBeNull();
|
|
423
|
+
|
|
424
|
+
mounted.update({ nodes: [] });
|
|
425
|
+
await vi.waitFor(() => expect(chart.clearCount).toBe(2));
|
|
426
|
+
expect(mounted.container.querySelector(".hk-treemap__empty")!.textContent).toBe("Nothing visible");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("supports hiding the legend and numeric pixel heights", async () => {
|
|
430
|
+
const mounted = mountTreemap({ showLegend: false, height: 280 });
|
|
431
|
+
await waitForChart();
|
|
432
|
+
|
|
433
|
+
expect(mounted.container.querySelector(".hk-treemap__legend")).toBeNull();
|
|
434
|
+
expect(mounted.container.querySelector<HTMLElement>(".hk-treemap")!.style.height).toBe("280px");
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("does not init a detached element when async imports finish after unmount", async () => {
|
|
438
|
+
let releaseImports!: () => void;
|
|
439
|
+
H.importGate = new Promise<void>((resolve) => {
|
|
440
|
+
releaseImports = resolve;
|
|
441
|
+
});
|
|
442
|
+
const mounted = mountTreemap();
|
|
443
|
+
await nextTick();
|
|
444
|
+
|
|
445
|
+
unmount(mounted);
|
|
446
|
+
releaseImports();
|
|
447
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
448
|
+
|
|
449
|
+
expect(H.init).not.toHaveBeenCalled();
|
|
450
|
+
expect(H.FakeChart.instances).toHaveLength(0);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
defineComponent,
|
|
4
|
+
onMounted,
|
|
5
|
+
onUnmounted,
|
|
6
|
+
ref,
|
|
7
|
+
useId,
|
|
8
|
+
watch,
|
|
9
|
+
type PropType,
|
|
10
|
+
} from "vue";
|
|
11
|
+
|
|
12
|
+
// Keep echarts out of the initial bundle. Only the type imports are static;
|
|
13
|
+
// the renderer and the treemap implementation are loaded after mount.
|
|
14
|
+
import type { ECharts, EChartsCoreOption } from "echarts/core";
|
|
15
|
+
|
|
16
|
+
import "./HkTreemap.scss";
|
|
17
|
+
|
|
18
|
+
export interface HkTreemapNode {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
value: number;
|
|
22
|
+
/** Optional opaque sRGB colour: #rgb/#rrggbb/#rrggbbaa, rgb()/rgba() or
|
|
23
|
+
* hsl()/hsla(). Canvas cannot resolve CSS custom properties or named
|
|
24
|
+
* colours reliably; unsupported/translucent values fall back to the
|
|
25
|
+
* inherited palette colour instead of corrupting the next cell. */
|
|
26
|
+
color?: string;
|
|
27
|
+
children?: HkTreemapNode[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type HkTreemapValueFormatter = (value: number) => string;
|
|
31
|
+
|
|
32
|
+
/** A sanitized node. `percent` is a 0–100 percentage of all visible leaves. */
|
|
33
|
+
export interface HkTreemapLayoutNode {
|
|
34
|
+
id: string;
|
|
35
|
+
label: string;
|
|
36
|
+
value: number;
|
|
37
|
+
color: string;
|
|
38
|
+
percent: number;
|
|
39
|
+
children?: HkTreemapLayoutNode[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface HkTreemapLayout {
|
|
43
|
+
nodes: HkTreemapLayoutNode[];
|
|
44
|
+
total: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type RgbaChannels = [number, number, number, number];
|
|
48
|
+
|
|
49
|
+
function parseHexColor(raw: string): RgbaChannels | null {
|
|
50
|
+
const value = raw.slice(1);
|
|
51
|
+
if (![3, 6, 8].includes(value.length) || !/^[0-9a-f]+$/i.test(value)) return null;
|
|
52
|
+
const expanded = value.length === 3
|
|
53
|
+
? value.split("").map((char) => char + char).join("")
|
|
54
|
+
: value;
|
|
55
|
+
return [
|
|
56
|
+
Number.parseInt(expanded.slice(0, 2), 16),
|
|
57
|
+
Number.parseInt(expanded.slice(2, 4), 16),
|
|
58
|
+
Number.parseInt(expanded.slice(4, 6), 16),
|
|
59
|
+
value.length === 8 ? Number.parseInt(expanded.slice(6, 8), 16) / 255 : 1,
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function numericChannel(raw: string): number | null {
|
|
64
|
+
if (raw.endsWith("%")) {
|
|
65
|
+
const percent = Number.parseFloat(raw.slice(0, -1));
|
|
66
|
+
return Number.isFinite(percent) ? Math.min(255, Math.max(0, (percent / 100) * 255)) : null;
|
|
67
|
+
}
|
|
68
|
+
const value = Number.parseFloat(raw);
|
|
69
|
+
return Number.isFinite(value) ? Math.min(255, Math.max(0, value)) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hueToRgb(hue: number, saturation: number, lightness: number): [number, number, number] {
|
|
73
|
+
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
|
74
|
+
const sector = ((hue % 360) + 360) % 360 / 60;
|
|
75
|
+
const x = chroma * (1 - Math.abs((sector % 2) - 1));
|
|
76
|
+
const [red, green, blue] = sector < 1
|
|
77
|
+
? [chroma, x, 0]
|
|
78
|
+
: sector < 2
|
|
79
|
+
? [x, chroma, 0]
|
|
80
|
+
: sector < 3
|
|
81
|
+
? [0, chroma, x]
|
|
82
|
+
: sector < 4
|
|
83
|
+
? [0, x, chroma]
|
|
84
|
+
: sector < 5
|
|
85
|
+
? [x, 0, chroma]
|
|
86
|
+
: [chroma, 0, x];
|
|
87
|
+
const match = lightness - chroma / 2;
|
|
88
|
+
return [(red + match) * 255, (green + match) * 255, (blue + match) * 255];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function alphaComponent(raw: string | undefined): number | null {
|
|
92
|
+
if (raw == null || raw === "") return 1;
|
|
93
|
+
if (raw.endsWith("%")) {
|
|
94
|
+
const percent = Number.parseFloat(raw.slice(0, -1));
|
|
95
|
+
return Number.isFinite(percent) ? Math.min(1, Math.max(0, percent / 100)) : null;
|
|
96
|
+
}
|
|
97
|
+
const value = Number.parseFloat(raw);
|
|
98
|
+
return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseCssColor(raw: string): RgbaChannels | null {
|
|
102
|
+
const value = raw.trim();
|
|
103
|
+
if (value.startsWith("#")) return parseHexColor(value);
|
|
104
|
+
const rgb = /^rgba?\((.*)\)$/i.exec(value);
|
|
105
|
+
if (rgb) {
|
|
106
|
+
const parts = rgb[1]!.split(/[\s,/]+/).filter((part) => part && part !== "none");
|
|
107
|
+
if (parts.length < 3) return null;
|
|
108
|
+
const channels = parts.slice(0, 3).map(numericChannel);
|
|
109
|
+
if (channels.some((channel) => channel === null)) return null;
|
|
110
|
+
const alpha = alphaComponent(parts[3]);
|
|
111
|
+
if (alpha === null) return null;
|
|
112
|
+
return [...(channels as [number, number, number]), alpha];
|
|
113
|
+
}
|
|
114
|
+
const hsl = /^hsla?\((.*)\)$/i.exec(value);
|
|
115
|
+
if (hsl) {
|
|
116
|
+
const parts = hsl[1]!.split(/[\s,/]+/).filter((part) => part && part !== "none");
|
|
117
|
+
if (parts.length < 3) return null;
|
|
118
|
+
const hue = Number.parseFloat(parts[0]!);
|
|
119
|
+
const saturation = Number.parseFloat(parts[1]!);
|
|
120
|
+
const lightness = Number.parseFloat(parts[2]!);
|
|
121
|
+
if (![hue, saturation, lightness].every(Number.isFinite)) return null;
|
|
122
|
+
const alpha = alphaComponent(parts[3]);
|
|
123
|
+
if (alpha === null) return null;
|
|
124
|
+
return [
|
|
125
|
+
...hueToRgb(
|
|
126
|
+
hue,
|
|
127
|
+
Math.min(1, Math.max(0, saturation / 100)),
|
|
128
|
+
Math.min(1, Math.max(0, lightness / 100)),
|
|
129
|
+
),
|
|
130
|
+
alpha,
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveOpaqueColor(raw: string, fallback: string): string {
|
|
137
|
+
const channels = parseCssColor(raw);
|
|
138
|
+
if (!channels || channels[3] < 0.999) return fallback;
|
|
139
|
+
return `rgb(${Math.round(channels[0])} ${Math.round(channels[1])} ${Math.round(channels[2])})`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Pick the higher-contrast label colour for an opaque CSS background. */
|
|
143
|
+
export function contrastTextColor(background: string): string {
|
|
144
|
+
const channels = parseCssColor(background);
|
|
145
|
+
if (!channels || channels[3] < 0.999) return "#fff";
|
|
146
|
+
// sRGB relative luminance, with the standard gamma expansion. The two
|
|
147
|
+
// candidate text colours (#fff / #0b1220) cross at luminance ~= 0.193.
|
|
148
|
+
const linear = (channel: number) => {
|
|
149
|
+
const value = channel / 255;
|
|
150
|
+
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
151
|
+
};
|
|
152
|
+
const luminance =
|
|
153
|
+
0.2126 * linear(channels[0]) +
|
|
154
|
+
0.7152 * linear(channels[1]) +
|
|
155
|
+
0.0722 * linear(channels[2]);
|
|
156
|
+
return luminance > 0.193 ? "#0b1220" : "#fff";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const HK_TREEMAP_DEFAULT_COLORS = [
|
|
160
|
+
"#4e79a7",
|
|
161
|
+
"#f28e2b",
|
|
162
|
+
"#e15759",
|
|
163
|
+
"#76b7b2",
|
|
164
|
+
"#59a14f",
|
|
165
|
+
"#edc948",
|
|
166
|
+
"#b07aa1",
|
|
167
|
+
"#ff9da7",
|
|
168
|
+
"#9c755f",
|
|
169
|
+
"#bab0ab",
|
|
170
|
+
] as const;
|
|
171
|
+
|
|
172
|
+
const defaultValueFormatter: HkTreemapValueFormatter = (value) => String(value);
|
|
173
|
+
|
|
174
|
+
interface NumericLayoutNode {
|
|
175
|
+
id: string;
|
|
176
|
+
label: string;
|
|
177
|
+
value: number;
|
|
178
|
+
color: string;
|
|
179
|
+
children?: NumericLayoutNode[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isVisibleValue(value: number): boolean {
|
|
183
|
+
return Number.isFinite(value) && value > 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A node with a non-empty children array is a group: its own value is never
|
|
188
|
+
* trusted, and it survives only when at least one valid descendant leaf does.
|
|
189
|
+
* An empty children array is equivalent to no children and therefore a leaf.
|
|
190
|
+
*/
|
|
191
|
+
function normalizeNode(
|
|
192
|
+
node: HkTreemapNode,
|
|
193
|
+
inheritedColor: string,
|
|
194
|
+
): NumericLayoutNode | null {
|
|
195
|
+
const color = node.color
|
|
196
|
+
? resolveOpaqueColor(node.color, inheritedColor)
|
|
197
|
+
: inheritedColor;
|
|
198
|
+
if (node.children && node.children.length > 0) {
|
|
199
|
+
const children = node.children
|
|
200
|
+
.map((child) => normalizeNode(child, color))
|
|
201
|
+
.filter((child): child is NumericLayoutNode => child !== null);
|
|
202
|
+
if (children.length === 0) return null;
|
|
203
|
+
|
|
204
|
+
const value = children.reduce((sum, child) => sum + child.value, 0);
|
|
205
|
+
// Finite inputs can still overflow when summed. Such a group cannot be
|
|
206
|
+
// represented truthfully, so omit it rather than emitting Infinity/NaN.
|
|
207
|
+
if (!isVisibleValue(value)) return null;
|
|
208
|
+
return { id: node.id, label: node.label, value, color, children };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!isVisibleValue(node.value)) return null;
|
|
212
|
+
return { id: node.id, label: node.label, value: node.value, color };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function visitLeaves(nodes: NumericLayoutNode[], visit: (leaf: NumericLayoutNode) => void): void {
|
|
216
|
+
for (const node of nodes) {
|
|
217
|
+
if (node.children) visitLeaves(node.children, visit);
|
|
218
|
+
else visit(node);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Converts caller input into the only numeric model used by the chart, legend,
|
|
224
|
+
* tooltip and accessible summary. Invalid leaves are removed before any sums
|
|
225
|
+
* or percentages are calculated; caller-provided parent values are ignored.
|
|
226
|
+
*/
|
|
227
|
+
export function buildHkTreemapLayout(nodes: readonly HkTreemapNode[]): HkTreemapLayout {
|
|
228
|
+
const normalized = nodes
|
|
229
|
+
.map((node, index) =>
|
|
230
|
+
normalizeNode(
|
|
231
|
+
node,
|
|
232
|
+
HK_TREEMAP_DEFAULT_COLORS[index % HK_TREEMAP_DEFAULT_COLORS.length]!,
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
.filter((node): node is NumericLayoutNode => node !== null);
|
|
236
|
+
|
|
237
|
+
let total = 0;
|
|
238
|
+
visitLeaves(normalized, (leaf) => {
|
|
239
|
+
total += leaf.value;
|
|
240
|
+
});
|
|
241
|
+
if (!isVisibleValue(total)) return { nodes: [], total: 0 };
|
|
242
|
+
|
|
243
|
+
function addPercentages(node: NumericLayoutNode): HkTreemapLayoutNode {
|
|
244
|
+
return {
|
|
245
|
+
...node,
|
|
246
|
+
percent: (node.value / total) * 100,
|
|
247
|
+
children: node.children?.map(addPercentages),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { nodes: normalized.map(addPercentages), total };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** One decimal place, trimmed for whole numbers, without rounding tiny shares to zero. */
|
|
255
|
+
export function formatHkTreemapPercent(percent: number): string {
|
|
256
|
+
if (!Number.isFinite(percent) || percent <= 0) return "0%";
|
|
257
|
+
if (percent < 0.1) return "<0.1%";
|
|
258
|
+
return `${Number(percent.toFixed(1))}%`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
interface HkTreemapChartMeta {
|
|
262
|
+
label: string;
|
|
263
|
+
valueText: string;
|
|
264
|
+
percentText: string;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
interface HkTreemapChartDatum {
|
|
268
|
+
id: string;
|
|
269
|
+
name: string;
|
|
270
|
+
value: number;
|
|
271
|
+
itemStyle: { color: string; borderColor?: string };
|
|
272
|
+
label: { color: string; textBorderColor: string };
|
|
273
|
+
upperLabel: { color: string; textBorderColor: string };
|
|
274
|
+
hkTreemap: HkTreemapChartMeta;
|
|
275
|
+
children?: HkTreemapChartDatum[];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function toChartData(
|
|
279
|
+
node: HkTreemapLayoutNode,
|
|
280
|
+
formatValue: HkTreemapValueFormatter,
|
|
281
|
+
): HkTreemapChartDatum {
|
|
282
|
+
const labelColor = contrastTextColor(node.color);
|
|
283
|
+
const textBorderColor = labelColor === "#0b1220"
|
|
284
|
+
? "rgba(255, 255, 255, 0.6)"
|
|
285
|
+
: "rgba(0, 0, 0, 0.45)";
|
|
286
|
+
const isGroup = Boolean(node.children?.length);
|
|
287
|
+
return {
|
|
288
|
+
id: node.id,
|
|
289
|
+
name: node.label,
|
|
290
|
+
value: node.value,
|
|
291
|
+
// ECharts paints a group's upperLabel band with the group's BORDER
|
|
292
|
+
// colour. Keep that band the group's own colour so the contrast choice
|
|
293
|
+
// above describes the pixels under the title (the series default is
|
|
294
|
+
// white, which would produce white-on-white titles).
|
|
295
|
+
itemStyle: isGroup
|
|
296
|
+
? { color: node.color, borderColor: node.color }
|
|
297
|
+
: { color: node.color },
|
|
298
|
+
label: { color: labelColor, textBorderColor },
|
|
299
|
+
upperLabel: { color: labelColor, textBorderColor },
|
|
300
|
+
hkTreemap: {
|
|
301
|
+
label: node.label,
|
|
302
|
+
valueText: formatValue(node.value),
|
|
303
|
+
percentText: formatHkTreemapPercent(node.percent),
|
|
304
|
+
},
|
|
305
|
+
children: node.children?.map((child) => toChartData(child, formatValue)),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function chartMeta(params: unknown): HkTreemapChartMeta | null {
|
|
310
|
+
const data = (params as { data?: Partial<HkTreemapChartDatum> } | null)?.data;
|
|
311
|
+
return data?.hkTreemap ?? null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function leafLabelFormatter(params: unknown): string {
|
|
315
|
+
const meta = chartMeta(params);
|
|
316
|
+
return meta ? `${meta.label}\n${meta.valueText} · ${meta.percentText}` : "";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function groupLabelFormatter(params: unknown): string {
|
|
320
|
+
const meta = chartMeta(params);
|
|
321
|
+
return meta ? `${meta.label} · ${meta.valueText} · ${meta.percentText}` : "";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function escapeHtml(value: string): string {
|
|
325
|
+
return value.replace(/[&<>"']/g, (char) => {
|
|
326
|
+
switch (char) {
|
|
327
|
+
case "&": return "&";
|
|
328
|
+
case "<": return "<";
|
|
329
|
+
case ">": return ">";
|
|
330
|
+
case '"': return """;
|
|
331
|
+
default: return "'";
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function tooltipFormatter(params: unknown): string {
|
|
337
|
+
const meta = chartMeta(params);
|
|
338
|
+
if (!meta) return "";
|
|
339
|
+
return `<strong>${escapeHtml(meta.label)}</strong><br/>${escapeHtml(meta.valueText)} · ${escapeHtml(meta.percentText)}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Pure echarts option construction, exported for focused contract tests. */
|
|
343
|
+
export function createHkTreemapOption(
|
|
344
|
+
layout: HkTreemapLayout,
|
|
345
|
+
formatValue: HkTreemapValueFormatter = defaultValueFormatter,
|
|
346
|
+
): EChartsCoreOption {
|
|
347
|
+
return {
|
|
348
|
+
animation: false,
|
|
349
|
+
tooltip: {
|
|
350
|
+
trigger: "item",
|
|
351
|
+
confine: true,
|
|
352
|
+
formatter: tooltipFormatter,
|
|
353
|
+
},
|
|
354
|
+
series: [
|
|
355
|
+
{
|
|
356
|
+
type: "treemap",
|
|
357
|
+
// TreemapView reads SERIES animation, not the top-level option.
|
|
358
|
+
animation: false,
|
|
359
|
+
data: layout.nodes.map((node) => toChartData(node, formatValue)),
|
|
360
|
+
left: 0,
|
|
361
|
+
right: 0,
|
|
362
|
+
top: 0,
|
|
363
|
+
bottom: 0,
|
|
364
|
+
width: "100%",
|
|
365
|
+
height: "100%",
|
|
366
|
+
roam: false,
|
|
367
|
+
nodeClick: false,
|
|
368
|
+
// ECharts defaults to hiding cells below 10px²; our legend and
|
|
369
|
+
// accessible summary describe every leaf, so the chart must too.
|
|
370
|
+
visibleMin: 0,
|
|
371
|
+
breadcrumb: { show: false },
|
|
372
|
+
label: {
|
|
373
|
+
show: true,
|
|
374
|
+
formatter: leafLabelFormatter,
|
|
375
|
+
color: "#fff",
|
|
376
|
+
fontSize: 12,
|
|
377
|
+
lineHeight: 17,
|
|
378
|
+
overflow: "truncate",
|
|
379
|
+
textBorderColor: "rgba(0, 0, 0, 0.35)",
|
|
380
|
+
textBorderWidth: 2,
|
|
381
|
+
},
|
|
382
|
+
upperLabel: {
|
|
383
|
+
show: true,
|
|
384
|
+
formatter: groupLabelFormatter,
|
|
385
|
+
color: "#fff",
|
|
386
|
+
fontSize: 12,
|
|
387
|
+
fontWeight: 600,
|
|
388
|
+
height: 24,
|
|
389
|
+
overflow: "truncate",
|
|
390
|
+
textBorderColor: "rgba(0, 0, 0, 0.35)",
|
|
391
|
+
textBorderWidth: 2,
|
|
392
|
+
},
|
|
393
|
+
itemStyle: {
|
|
394
|
+
borderColor: "#fff",
|
|
395
|
+
borderWidth: 1,
|
|
396
|
+
gapWidth: 1,
|
|
397
|
+
},
|
|
398
|
+
levels: [
|
|
399
|
+
{
|
|
400
|
+
// The virtual root is not a visible group: without this, ECharts
|
|
401
|
+
// reserves a 24px white upperLabel band above the whole chart.
|
|
402
|
+
upperLabel: { show: false },
|
|
403
|
+
itemStyle: { borderWidth: 0, gapWidth: 0, borderColor: "transparent" },
|
|
404
|
+
},
|
|
405
|
+
{ itemStyle: { borderWidth: 2, gapWidth: 3 } },
|
|
406
|
+
{ itemStyle: { borderWidth: 1, gapWidth: 2 } },
|
|
407
|
+
{ itemStyle: { borderWidth: 1, gapWidth: 1 } },
|
|
408
|
+
],
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Flattened leaf-path text exposes nested data that a canvas cannot announce. */
|
|
415
|
+
export function createHkTreemapSummary(
|
|
416
|
+
layout: HkTreemapLayout,
|
|
417
|
+
formatValue: HkTreemapValueFormatter = defaultValueFormatter,
|
|
418
|
+
): string {
|
|
419
|
+
const entries: string[] = [];
|
|
420
|
+
function walk(nodes: HkTreemapLayoutNode[], parents: string[]): void {
|
|
421
|
+
for (const node of nodes) {
|
|
422
|
+
const path = [...parents, node.label];
|
|
423
|
+
if (node.children) walk(node.children, path);
|
|
424
|
+
else {
|
|
425
|
+
entries.push(
|
|
426
|
+
`${path.join(" / ")}: ${formatValue(node.value)} (${formatHkTreemapPercent(node.percent)})`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
walk(layout.nodes, []);
|
|
432
|
+
return entries.join("; ");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export default defineComponent({
|
|
436
|
+
name: "HkTreemap",
|
|
437
|
+
props: {
|
|
438
|
+
nodes: { type: Array as PropType<HkTreemapNode[]>, default: () => [] },
|
|
439
|
+
height: { type: [String, Number], default: "320px" },
|
|
440
|
+
showLegend: { type: Boolean, default: true },
|
|
441
|
+
valueFormatter: { type: Function as PropType<HkTreemapValueFormatter>, default: undefined },
|
|
442
|
+
emptyText: { type: String, default: "No data" },
|
|
443
|
+
ariaLabel: { type: String, default: "Treemap" },
|
|
444
|
+
},
|
|
445
|
+
setup(props) {
|
|
446
|
+
const chartEl = ref<HTMLElement>();
|
|
447
|
+
// useId is SSR-safe and unique per component instance; a module counter
|
|
448
|
+
// drifts between server and client hydration.
|
|
449
|
+
const summaryId = `hk-treemap-summary-${useId()}`;
|
|
450
|
+
let chart: ECharts | null = null;
|
|
451
|
+
let resizeCleanup: (() => void) | undefined;
|
|
452
|
+
let unmounted = false;
|
|
453
|
+
|
|
454
|
+
const layout = computed(() => buildHkTreemapLayout(props.nodes));
|
|
455
|
+
const hasData = computed(() => layout.value.nodes.length > 0);
|
|
456
|
+
const formatValue = (value: number) =>
|
|
457
|
+
(props.valueFormatter ?? defaultValueFormatter)(value);
|
|
458
|
+
const summary = computed(() => createHkTreemapSummary(layout.value, formatValue));
|
|
459
|
+
const rootStyle = computed(() => ({
|
|
460
|
+
width: "100%",
|
|
461
|
+
height: typeof props.height === "number" ? `${props.height}px` : props.height,
|
|
462
|
+
}));
|
|
463
|
+
|
|
464
|
+
function renderChart() {
|
|
465
|
+
if (!chart) return;
|
|
466
|
+
if (!hasData.value) {
|
|
467
|
+
// Data can become empty during a streaming update. Remove the previous
|
|
468
|
+
// option so a later reveal can never expose stale rectangles.
|
|
469
|
+
chart.clear();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
chart.setOption(createHkTreemapOption(layout.value, formatValue), { notMerge: true });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function onResize() {
|
|
476
|
+
chart?.resize();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
onMounted(async () => {
|
|
480
|
+
const el = chartEl.value;
|
|
481
|
+
if (!el) return;
|
|
482
|
+
|
|
483
|
+
const [echarts, { TreemapChart }, { TooltipComponent }, { CanvasRenderer }] =
|
|
484
|
+
await Promise.all([
|
|
485
|
+
import("echarts/core"),
|
|
486
|
+
import("echarts/charts"),
|
|
487
|
+
import("echarts/components"),
|
|
488
|
+
import("echarts/renderers"),
|
|
489
|
+
]);
|
|
490
|
+
|
|
491
|
+
// Dynamic imports may resolve after the host has unmounted. Never init
|
|
492
|
+
// against a detached (or subsequently replaced) element.
|
|
493
|
+
if (unmounted || chartEl.value !== el) return;
|
|
494
|
+
|
|
495
|
+
echarts.use([TreemapChart, TooltipComponent, CanvasRenderer]);
|
|
496
|
+
chart = echarts.init(el);
|
|
497
|
+
renderChart();
|
|
498
|
+
|
|
499
|
+
window.addEventListener("resize", onResize);
|
|
500
|
+
let observer: ResizeObserver | undefined;
|
|
501
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
502
|
+
observer = new ResizeObserver(onResize);
|
|
503
|
+
observer.observe(el);
|
|
504
|
+
}
|
|
505
|
+
resizeCleanup = () => {
|
|
506
|
+
window.removeEventListener("resize", onResize);
|
|
507
|
+
observer?.disconnect();
|
|
508
|
+
};
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
onUnmounted(() => {
|
|
512
|
+
unmounted = true;
|
|
513
|
+
resizeCleanup?.();
|
|
514
|
+
resizeCleanup = undefined;
|
|
515
|
+
chart?.dispose();
|
|
516
|
+
chart = null;
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
watch([layout, () => props.valueFormatter], renderChart);
|
|
520
|
+
|
|
521
|
+
return () => (
|
|
522
|
+
<div class="hk-treemap" style={rootStyle.value}>
|
|
523
|
+
{props.showLegend && hasData.value && (
|
|
524
|
+
<ul class="hk-treemap__legend">
|
|
525
|
+
{layout.value.nodes.map((node) => {
|
|
526
|
+
const valueText = formatValue(node.value);
|
|
527
|
+
const percentText = formatHkTreemapPercent(node.percent);
|
|
528
|
+
return (
|
|
529
|
+
<li
|
|
530
|
+
class="hk-treemap__legend-item"
|
|
531
|
+
key={node.id}
|
|
532
|
+
title={`${node.label}: ${valueText} (${percentText})`}
|
|
533
|
+
>
|
|
534
|
+
<span
|
|
535
|
+
class="hk-treemap__legend-swatch"
|
|
536
|
+
style={{ backgroundColor: node.color }}
|
|
537
|
+
aria-hidden="true"
|
|
538
|
+
/>
|
|
539
|
+
<span class="hk-treemap__legend-label">{node.label}</span>
|
|
540
|
+
<span class="hk-treemap__legend-value">{valueText}</span>
|
|
541
|
+
<span class="hk-treemap__legend-percent">{percentText}</span>
|
|
542
|
+
</li>
|
|
543
|
+
);
|
|
544
|
+
})}
|
|
545
|
+
</ul>
|
|
546
|
+
)}
|
|
547
|
+
<div class="hk-treemap__body">
|
|
548
|
+
<div
|
|
549
|
+
ref={chartEl}
|
|
550
|
+
class="hk-treemap__chart"
|
|
551
|
+
role="img"
|
|
552
|
+
aria-label={props.ariaLabel}
|
|
553
|
+
aria-describedby={hasData.value ? summaryId : undefined}
|
|
554
|
+
aria-hidden={!hasData.value}
|
|
555
|
+
style={{ visibility: hasData.value ? "visible" : "hidden" }}
|
|
556
|
+
/>
|
|
557
|
+
{!hasData.value && <div class="hk-treemap__empty">{props.emptyText}</div>}
|
|
558
|
+
</div>
|
|
559
|
+
{hasData.value && (
|
|
560
|
+
<p id={summaryId} class="hk-treemap__a11y-summary">
|
|
561
|
+
{summary.value}
|
|
562
|
+
</p>
|
|
563
|
+
)}
|
|
564
|
+
</div>
|
|
565
|
+
);
|
|
566
|
+
},
|
|
567
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -150,6 +150,7 @@ export { default as HMinimap } from "./components/HkMinimap";
|
|
|
150
150
|
|
|
151
151
|
// Charts
|
|
152
152
|
export { default as HTrendChart } from "./components/HkTrendChart";
|
|
153
|
+
export { default as HTreemap } from "./components/HkTreemap";
|
|
153
154
|
|
|
154
155
|
export { default as HErrorBoundary } from "./components/HkErrorBoundary";
|
|
155
156
|
export {
|
|
@@ -230,6 +231,7 @@ export {
|
|
|
230
231
|
type LightingRigOptions,
|
|
231
232
|
} from "./scene3d/lighting";
|
|
232
233
|
export { type TrendPen, type TrendPoint, type AlarmThresholds } from "./components/HkTrendChart";
|
|
234
|
+
export { type HkTreemapNode, type HkTreemapValueFormatter } from "./components/HkTreemap";
|
|
233
235
|
|
|
234
236
|
// ── Theme decor (theme-provided decorative widgets, host-placed) ──────────
|
|
235
237
|
export { default as HkThemeDecor, type HkThemeDecorSize } from "./components/HkThemeDecor";
|
|
@@ -725,6 +727,7 @@ export {
|
|
|
725
727
|
} from "./components/HkBoard3D";
|
|
726
728
|
export { default as HkMinimap3D } from "./components/HkMinimap3D";
|
|
727
729
|
export { default as HkTrendChart } from "./components/HkTrendChart";
|
|
730
|
+
export { default as HkTreemap } from "./components/HkTreemap";
|
|
728
731
|
export { default as HkErrorBoundary } from "./components/HkErrorBoundary";
|
|
729
732
|
export { default as HkDraggableList } from "./components/HkDraggableList";
|
|
730
733
|
export { default as HkDraggableGrid } from "./components/HkDraggableGrid";
|