@celestia-island/hikari 0.55.207 → 0.55.209
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
CHANGED
|
@@ -0,0 +1,153 @@
|
|
|
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: var(--radius-xs, 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
|
+
/* The mortar between cells reads the THEME surface — dark mode gets a
|
|
61
|
+
* dark mortar, light mode a light one. The old canvas renderer painted
|
|
62
|
+
* fixed white gutters + white-bordered labels that glared in dark
|
|
63
|
+
* themes (user report 2026-10-27: 亮瞎眼). */
|
|
64
|
+
.hk-treemap__chart {
|
|
65
|
+
position: absolute;
|
|
66
|
+
inset: 0;
|
|
67
|
+
width: 100%;
|
|
68
|
+
height: 100%;
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
border-radius: var(--radius-sm, 0.25rem);
|
|
71
|
+
background: rgb(var(--color-muted, 108 108 108) / 12%);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.hk-treemap__cell {
|
|
75
|
+
position: absolute;
|
|
76
|
+
overflow: hidden;
|
|
77
|
+
display: flex;
|
|
78
|
+
flex-direction: column;
|
|
79
|
+
border-radius: var(--radius-xs, 0.1875rem);
|
|
80
|
+
color: var(--hk-treemap-fg, white);
|
|
81
|
+
/* The 1px gutter between cells is the theme mortar showing through. */
|
|
82
|
+
outline: 1px solid rgb(var(--color-bg, 255 255 255) / 55%);
|
|
83
|
+
outline-offset: -1px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.hk-treemap__group-head {
|
|
87
|
+
display: flex;
|
|
88
|
+
flex: 0 0 auto;
|
|
89
|
+
align-items: center;
|
|
90
|
+
justify-content: space-between;
|
|
91
|
+
gap: 0.375rem;
|
|
92
|
+
height: 22px;
|
|
93
|
+
padding: 0 0.375rem;
|
|
94
|
+
background: rgb(0 0 0 / 18%);
|
|
95
|
+
font-weight: 600;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.hk-treemap__leaf-body {
|
|
99
|
+
display: flex;
|
|
100
|
+
flex: 1 1 auto;
|
|
101
|
+
flex-direction: column;
|
|
102
|
+
justify-content: flex-end;
|
|
103
|
+
gap: 1px;
|
|
104
|
+
min-height: 0;
|
|
105
|
+
padding: 0.25rem 0.375rem;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* THE ellipsis contract (user report 2026-10-27): an overflowing label
|
|
109
|
+
* degrades to "…" from the FIRST clipped character — a hard mid-word cut
|
|
110
|
+
* reads as a rendering bug; the ellipsis reads as a deliberate
|
|
111
|
+
* affordance pointing at the tooltip. Same for the meta line. */
|
|
112
|
+
.hk-treemap__label,
|
|
113
|
+
.hk-treemap__meta {
|
|
114
|
+
display: block;
|
|
115
|
+
min-width: 0;
|
|
116
|
+
overflow: hidden;
|
|
117
|
+
text-overflow: ellipsis;
|
|
118
|
+
white-space: nowrap;
|
|
119
|
+
font-size: var(--text-xs, 0.75rem);
|
|
120
|
+
line-height: 1.3;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.hk-treemap__label--group {
|
|
124
|
+
font-weight: 600;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.hk-treemap__meta {
|
|
128
|
+
opacity: 0.85;
|
|
129
|
+
font-variant-numeric: tabular-nums;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.hk-treemap__empty {
|
|
133
|
+
position: absolute;
|
|
134
|
+
inset: 0;
|
|
135
|
+
display: grid;
|
|
136
|
+
place-items: center;
|
|
137
|
+
padding: 1rem;
|
|
138
|
+
color: rgb(var(--color-muted, 108 108 108));
|
|
139
|
+
font-size: var(--text-sm, 0.8125rem);
|
|
140
|
+
text-align: center;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.hk-treemap__a11y-summary {
|
|
144
|
+
position: absolute;
|
|
145
|
+
width: 1px;
|
|
146
|
+
height: 1px;
|
|
147
|
+
padding: 0;
|
|
148
|
+
margin: -1px;
|
|
149
|
+
overflow: hidden;
|
|
150
|
+
clip-path: inset(50%);
|
|
151
|
+
white-space: nowrap;
|
|
152
|
+
border: 0;
|
|
153
|
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
|
7
|
+
|
|
8
|
+
// HkTooltip is stubbed so the per-cell wiring is assertable without the
|
|
9
|
+
// popup manager: the stub renders its trigger slot and exposes the bound
|
|
10
|
+
// text as [data-hk-tooltip-text].
|
|
11
|
+
vi.mock("./HkTooltip", () => ({
|
|
12
|
+
default: {
|
|
13
|
+
name: "HkTooltipStub",
|
|
14
|
+
props: { text: { type: String, required: true } },
|
|
15
|
+
setup(props: { text: string }, { slots }: { slots: { default?: (p: unknown) => unknown } }) {
|
|
16
|
+
return () => h("span", { "data-hk-tooltip-text": props.text }, slots.default?.({}) as never);
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import HkTreemap, {
|
|
22
|
+
HK_TREEMAP_DEFAULT_COLORS,
|
|
23
|
+
buildHkTreemapLayout,
|
|
24
|
+
contrastTextColor,
|
|
25
|
+
createHkTreemapSummary,
|
|
26
|
+
formatHkTreemapPercent,
|
|
27
|
+
layoutHkTreemapCells,
|
|
28
|
+
type HkTreemapNode,
|
|
29
|
+
} from "./HkTreemap";
|
|
30
|
+
|
|
31
|
+
class FakeResizeObserver {
|
|
32
|
+
static instances: FakeResizeObserver[] = [];
|
|
33
|
+
readonly observed: Element[] = [];
|
|
34
|
+
disconnected = false;
|
|
35
|
+
|
|
36
|
+
constructor(readonly callback: () => void) {
|
|
37
|
+
FakeResizeObserver.instances.push(this);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
observe(element: Element): void {
|
|
41
|
+
this.observed.push(element);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
disconnect(): void {
|
|
45
|
+
this.disconnected = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
50
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
51
|
+
|
|
52
|
+
const NODES: HkTreemapNode[] = [
|
|
53
|
+
{
|
|
54
|
+
id: "group",
|
|
55
|
+
label: "Group",
|
|
56
|
+
value: 999,
|
|
57
|
+
children: [
|
|
58
|
+
{ id: "a", label: "Alpha", value: 2 },
|
|
59
|
+
{ id: "b", label: "Beta", value: 4 },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{ id: "other", label: "Other", value: 4 },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function fakeRect(width: number, height: number): DOMRect {
|
|
66
|
+
return {
|
|
67
|
+
x: 0, y: 0, top: 0, left: 0, right: width, bottom: height,
|
|
68
|
+
width, height, toJSON: () => ({}),
|
|
69
|
+
} as DOMRect;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mountTreemap(initialProps: Record<string, unknown> = { nodes: NODES }) {
|
|
73
|
+
const props = reactive<Record<string, unknown>>({ ...initialProps });
|
|
74
|
+
const container = document.createElement("div");
|
|
75
|
+
document.body.appendChild(container);
|
|
76
|
+
const app = createApp(
|
|
77
|
+
defineComponent({
|
|
78
|
+
setup() {
|
|
79
|
+
return () => h(HkTreemap, { ...props });
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
app.mount(container);
|
|
84
|
+
const mounted = { app, container };
|
|
85
|
+
mounts.push(mounted);
|
|
86
|
+
return {
|
|
87
|
+
...mounted,
|
|
88
|
+
update(next: Record<string, unknown>) {
|
|
89
|
+
Object.assign(props, next);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function unmount(mounted: { app: ReturnType<typeof createApp>; container: HTMLElement }) {
|
|
95
|
+
const index = mounts.findIndex((item) => item.app === mounted.app);
|
|
96
|
+
if (index >= 0) mounts.splice(index, 1);
|
|
97
|
+
mounted.app.unmount();
|
|
98
|
+
mounted.container.remove();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Give the chart element a concrete size and let the observer re-measure.
|
|
102
|
+
*
|
|
103
|
+
* `zoom` simulates the app's root CSS zoom (dpiPrefs writes 100–300% on
|
|
104
|
+
* <html>): getBoundingClientRect answers in DRAWN px (zoom×), while
|
|
105
|
+
* clientWidth/Height and the cells' inline px live in LOCAL px. */
|
|
106
|
+
async function setSize(
|
|
107
|
+
mounted: { container: HTMLElement },
|
|
108
|
+
width: number,
|
|
109
|
+
height: number,
|
|
110
|
+
zoom = 1,
|
|
111
|
+
) {
|
|
112
|
+
const el = mounted.container.querySelector<HTMLElement>(".hk-treemap__chart")!;
|
|
113
|
+
el.getBoundingClientRect = () => fakeRect(width * zoom, height * zoom);
|
|
114
|
+
Object.defineProperty(el, "clientWidth", { value: width, configurable: true });
|
|
115
|
+
Object.defineProperty(el, "clientHeight", { value: height, configurable: true });
|
|
116
|
+
FakeResizeObserver.instances[0]?.callback();
|
|
117
|
+
await nextTick();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
vi.clearAllMocks();
|
|
122
|
+
FakeResizeObserver.instances = [];
|
|
123
|
+
globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterEach(() => {
|
|
127
|
+
for (const mounted of mounts.splice(0)) {
|
|
128
|
+
mounted.app.unmount();
|
|
129
|
+
mounted.container.remove();
|
|
130
|
+
}
|
|
131
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("HkTreemap pure layout input", () => {
|
|
135
|
+
it("sums nested groups from leaves and ignores caller-provided parent values", () => {
|
|
136
|
+
const layout = buildHkTreemapLayout(NODES);
|
|
137
|
+
expect(layout.total).toBe(10);
|
|
138
|
+
expect(layout.nodes[0]!.value).toBe(6);
|
|
139
|
+
expect(layout.nodes[0]!.children).toHaveLength(2);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("filters zero, negative and non-finite values before calculating totals", () => {
|
|
143
|
+
const layout = buildHkTreemapLayout([
|
|
144
|
+
{ id: "ok", label: "Ok", value: 3 },
|
|
145
|
+
{ id: "zero", label: "Zero", value: 0 },
|
|
146
|
+
{ id: "neg", label: "Neg", value: -2 },
|
|
147
|
+
{ id: "nan", label: "NaN", value: Number.NaN },
|
|
148
|
+
{ id: "inf", label: "Inf", value: Number.POSITIVE_INFINITY },
|
|
149
|
+
]);
|
|
150
|
+
expect(layout.total).toBe(3);
|
|
151
|
+
expect(layout.nodes.map((node) => node.id)).toEqual(["ok"]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("uses all visible leaves in the chart as the percentage denominator", () => {
|
|
155
|
+
const layout = buildHkTreemapLayout(NODES);
|
|
156
|
+
const leaves: number[] = [];
|
|
157
|
+
const walk = (nodes: typeof layout.nodes): void => {
|
|
158
|
+
for (const node of nodes) {
|
|
159
|
+
if (node.children) walk(node.children);
|
|
160
|
+
else leaves.push(node.percent);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
walk(layout.nodes);
|
|
164
|
+
expect(leaves.reduce((sum, value) => sum + value, 0)).toBeCloseTo(100, 6);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("assigns deterministic top-level palette colors and honors opaque overrides", () => {
|
|
168
|
+
const layout = buildHkTreemapLayout([
|
|
169
|
+
{ id: "a", label: "A", value: 1 },
|
|
170
|
+
{ id: "b", label: "B", value: 1, color: "#123456" },
|
|
171
|
+
{ id: "c", label: "C", value: 1, color: "rgba(1,2,3,0.5)" },
|
|
172
|
+
{ id: "d", label: "D", value: 1, color: "var(--unparseable)" },
|
|
173
|
+
]);
|
|
174
|
+
expect(layout.nodes[0]!.color).toBe(HK_TREEMAP_DEFAULT_COLORS[0]);
|
|
175
|
+
expect(layout.nodes[1]!.color).toBe("rgb(18 52 86)");
|
|
176
|
+
// Translucent and unparseable overrides fall back to the palette slot.
|
|
177
|
+
expect(layout.nodes[2]!.color).toBe(HK_TREEMAP_DEFAULT_COLORS[2]);
|
|
178
|
+
expect(layout.nodes[3]!.color).toBe(HK_TREEMAP_DEFAULT_COLORS[3]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("sums three levels deep and prunes groups with no valid descendants", () => {
|
|
182
|
+
const layout = buildHkTreemapLayout([
|
|
183
|
+
{
|
|
184
|
+
id: "mid", label: "Mid", value: 0,
|
|
185
|
+
children: [
|
|
186
|
+
{
|
|
187
|
+
id: "deep", label: "Deep", value: 0,
|
|
188
|
+
children: [
|
|
189
|
+
{ id: "leaf", label: "Leaf", value: 4 },
|
|
190
|
+
{ id: "dead", label: "Dead", value: 0 },
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
{ id: "deadgroup", label: "DeadGroup", value: 1, children: [{ id: "x", label: "X", value: -1 }] },
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
expect(layout.total).toBe(4);
|
|
198
|
+
const mid = layout.nodes[0]!;
|
|
199
|
+
expect(mid.children!).toHaveLength(1);
|
|
200
|
+
expect(mid.children![0]!.children!).toHaveLength(1);
|
|
201
|
+
expect(mid.children![0]!.children![0]!.id).toBe("leaf");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("is deterministic and never mutates the caller's node objects", () => {
|
|
205
|
+
const input: HkTreemapNode[] = [
|
|
206
|
+
{ id: "a", label: "A", value: 3 },
|
|
207
|
+
{ id: "b", label: "B", value: 2 },
|
|
208
|
+
];
|
|
209
|
+
const snapshot = JSON.stringify(input);
|
|
210
|
+
const first = buildHkTreemapLayout(input);
|
|
211
|
+
const second = buildHkTreemapLayout(input);
|
|
212
|
+
expect(second).toEqual(first);
|
|
213
|
+
expect(JSON.stringify(input)).toBe(snapshot);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("returns a genuinely empty model for absent or entirely invalid data", () => {
|
|
217
|
+
expect(buildHkTreemapLayout([])).toEqual({ nodes: [], total: 0 });
|
|
218
|
+
expect(
|
|
219
|
+
buildHkTreemapLayout([
|
|
220
|
+
{ id: "zero", label: "Zero", value: 0 },
|
|
221
|
+
{ id: "nan", label: "NaN", value: Number.NaN },
|
|
222
|
+
]),
|
|
223
|
+
).toEqual({ nodes: [], total: 0 });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("chooses readable label colours across pale, mid-tone and dark backgrounds", () => {
|
|
227
|
+
expect(contrastTextColor("#edc948")).toBe("#0b1220");
|
|
228
|
+
expect(contrastTextColor("#4e79a7")).toBe("#fff");
|
|
229
|
+
expect(contrastTextColor("#76b7b2")).toBe("#0b1220");
|
|
230
|
+
expect(contrastTextColor("rgb(242, 142, 43)")).toBe("#0b1220");
|
|
231
|
+
expect(contrastTextColor("#abc")).toBe("#0b1220");
|
|
232
|
+
expect(contrastTextColor("hsl(0, 0%, 100%)")).toBe("#0b1220");
|
|
233
|
+
expect(contrastTextColor("not-a-color")).toBe("#fff");
|
|
234
|
+
// Every default palette member must clear WCAG AA with its chosen text.
|
|
235
|
+
for (const color of HK_TREEMAP_DEFAULT_COLORS) {
|
|
236
|
+
expect(["#fff", "#0b1220"]).toContain(contrastTextColor(color));
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("builds an accessible leaf-path summary from the same layout", () => {
|
|
241
|
+
const summary = createHkTreemapSummary(buildHkTreemapLayout(NODES), (value) => `${value}u`);
|
|
242
|
+
expect(summary).toBe("Group / Alpha: 2u (20%); Group / Beta: 4u (40%); Other: 4u (40%)");
|
|
243
|
+
expect(formatHkTreemapPercent(0.04)).toBe("<0.1%");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("HkTreemap native cells (layoutHkTreemapCells)", () => {
|
|
248
|
+
it("reserves a header band for groups and subdivides the remainder", () => {
|
|
249
|
+
const layout = buildHkTreemapLayout(NODES);
|
|
250
|
+
const cells = layoutHkTreemapCells(layout.nodes, 0, 0, 400, 240);
|
|
251
|
+
expect(cells).toHaveLength(2);
|
|
252
|
+
const group = cells.find((cell) => cell.node.id === "group")!;
|
|
253
|
+
expect(group.children).toHaveLength(2);
|
|
254
|
+
// Children live strictly inside the group's rect, below its header.
|
|
255
|
+
for (const child of group.children) {
|
|
256
|
+
expect(child.x).toBeGreaterThanOrEqual(group.x);
|
|
257
|
+
expect(child.y).toBeGreaterThanOrEqual(group.y + 22);
|
|
258
|
+
expect(child.x + child.w).toBeLessThanOrEqual(group.x + group.w + 0.001);
|
|
259
|
+
expect(child.y + child.h).toBeLessThanOrEqual(group.y + group.h + 0.001);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("cells tile their canvas with no area drift", () => {
|
|
264
|
+
const layout = buildHkTreemapLayout(NODES);
|
|
265
|
+
const cells = layoutHkTreemapCells(layout.nodes, 0, 0, 320, 200);
|
|
266
|
+
const area = cells.reduce((sum, cell) => sum + cell.w * cell.h, 0);
|
|
267
|
+
expect(area).toBeCloseTo(320 * 200, 6);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("a starved group renders as a plain cell instead of a broken nest", () => {
|
|
271
|
+
const layout = buildHkTreemapLayout([
|
|
272
|
+
{
|
|
273
|
+
id: "g", label: "G", value: 1, color: "#4e79a7",
|
|
274
|
+
children: [{ id: "l", label: "L", value: 1 }],
|
|
275
|
+
},
|
|
276
|
+
{ id: "big", label: "Big", value: 9999 },
|
|
277
|
+
]);
|
|
278
|
+
const cells = layoutHkTreemapCells(layout.nodes, 0, 0, 100, 60);
|
|
279
|
+
const group = cells.find((cell) => cell.node.id === "g")!;
|
|
280
|
+
// Whatever the split, the group never emits children outside its rect.
|
|
281
|
+
for (const child of group.children) {
|
|
282
|
+
expect(child.x + child.w).toBeLessThanOrEqual(group.x + group.w + 0.001);
|
|
283
|
+
expect(child.y + child.h).toBeLessThanOrEqual(group.y + group.h + 0.001);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("HkTreemap component", () => {
|
|
289
|
+
it("renders legend, cells with theme mortar and accessible text after measuring", async () => {
|
|
290
|
+
const mounted = mountTreemap({
|
|
291
|
+
nodes: NODES,
|
|
292
|
+
ariaLabel: "Resource usage",
|
|
293
|
+
valueFormatter: (value: number) => `${value} units`,
|
|
294
|
+
});
|
|
295
|
+
await setSize(mounted, 400, 240);
|
|
296
|
+
|
|
297
|
+
expect(mounted.container.querySelector<HTMLElement>(".hk-treemap")!.style.height).toBe("320px");
|
|
298
|
+
expect(mounted.container.querySelectorAll(".hk-treemap__legend-item")).toHaveLength(2);
|
|
299
|
+
// Group header + leaf cells all rendered as DOM.
|
|
300
|
+
const cells = mounted.container.querySelectorAll(".hk-treemap__cell");
|
|
301
|
+
expect(cells.length).toBeGreaterThanOrEqual(4);
|
|
302
|
+
expect(mounted.container.textContent).toContain("6 units");
|
|
303
|
+
|
|
304
|
+
const chart = mounted.container.querySelector<HTMLElement>(".hk-treemap__chart")!;
|
|
305
|
+
expect(chart.getAttribute("role")).toBe("img");
|
|
306
|
+
expect(chart.getAttribute("aria-label")).toBe("Resource usage");
|
|
307
|
+
const summaryId = chart.getAttribute("aria-describedby")!;
|
|
308
|
+
expect(mounted.container.querySelector(`#${summaryId}`)!.textContent).toBe(
|
|
309
|
+
"Group / Alpha: 2 units (20%); Group / Beta: 4 units (40%); Other: 4 units (40%)",
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("lays out in LOCAL px under a root zoom — never zoom× oversized (R2 S1)", async () => {
|
|
314
|
+
const mounted = mountTreemap({ nodes: [{ id: "solo", label: "Solo", value: 5 }] });
|
|
315
|
+
// 2× root: gBCR reports 800×480 drawn; the layout must still tile the
|
|
316
|
+
// 400×240 LOCAL space (the old gBCR-based measure painted 2× cells).
|
|
317
|
+
await setSize(mounted, 400, 240, 2);
|
|
318
|
+
const cell = mounted.container.querySelector<HTMLElement>(".hk-treemap__cell")!;
|
|
319
|
+
expect(cell.style.width).toBe("400px");
|
|
320
|
+
expect(cell.style.height).toBe("240px");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("every cell carries the standard HkTooltip with label, value and share", async () => {
|
|
324
|
+
const mounted = mountTreemap({
|
|
325
|
+
nodes: NODES,
|
|
326
|
+
valueFormatter: (value: number) => `${value}u`,
|
|
327
|
+
});
|
|
328
|
+
await setSize(mounted, 400, 240);
|
|
329
|
+
const tooltips = [
|
|
330
|
+
...mounted.container.querySelectorAll("[data-hk-tooltip-text]"),
|
|
331
|
+
].map((node) => node.getAttribute("data-hk-tooltip-text"));
|
|
332
|
+
expect(tooltips).toContain("Group: 6u · 60%");
|
|
333
|
+
expect(tooltips).toContain("Alpha: 2u · 20%");
|
|
334
|
+
expect(tooltips).toContain("Other: 4u · 40%");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("re-renders cells when data changes on a same-size container", async () => {
|
|
338
|
+
const mounted = mountTreemap();
|
|
339
|
+
await setSize(mounted, 400, 240);
|
|
340
|
+
expect(mounted.container.querySelectorAll(".hk-treemap__cell").length).toBeGreaterThanOrEqual(4);
|
|
341
|
+
|
|
342
|
+
mounted.update({ nodes: [{ id: "new", label: "New", value: 7 }] });
|
|
343
|
+
await nextTick();
|
|
344
|
+
const cells = [...mounted.container.querySelectorAll(".hk-treemap__cell")];
|
|
345
|
+
expect(cells).toHaveLength(1);
|
|
346
|
+
expect(cells[0]!.getAttribute("aria-label")).toBe("New: 7 · 100%");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("disconnects its observer on unmount", async () => {
|
|
350
|
+
const mounted = mountTreemap();
|
|
351
|
+
await setSize(mounted, 400, 240);
|
|
352
|
+
const observer = FakeResizeObserver.instances[0]!;
|
|
353
|
+
expect(observer.observed[0]).toBe(mounted.container.querySelector(".hk-treemap__chart"));
|
|
354
|
+
unmount(mounted);
|
|
355
|
+
expect(observer.disconnected).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("shows an honest empty state and no cells without data", async () => {
|
|
359
|
+
const mounted = mountTreemap({ nodes: [], emptyText: "Nothing visible" });
|
|
360
|
+
await setSize(mounted, 400, 240);
|
|
361
|
+
expect(mounted.container.querySelector(".hk-treemap__empty")!.textContent).toBe("Nothing visible");
|
|
362
|
+
expect(mounted.container.querySelector(".hk-treemap__legend")).toBeNull();
|
|
363
|
+
expect(mounted.container.querySelectorAll(".hk-treemap__cell")).toHaveLength(0);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("supports hiding the legend and numeric pixel heights", async () => {
|
|
367
|
+
const mounted = mountTreemap({ showLegend: false, height: 280 });
|
|
368
|
+
await setSize(mounted, 400, 240);
|
|
369
|
+
expect(mounted.container.querySelector(".hk-treemap__legend")).toBeNull();
|
|
370
|
+
expect(mounted.container.querySelector<HTMLElement>(".hk-treemap")!.style.height).toBe("280px");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("small cells degrade to colour blocks (the tooltip still speaks)", async () => {
|
|
374
|
+
// One dominant leaf + many crumbs: the crumbs get centi-pixel cells —
|
|
375
|
+
// far below the label ladder — and must render bare (no label spans).
|
|
376
|
+
const crumbs: HkTreemapNode[] = Array.from({ length: 24 }, (_, i) => ({
|
|
377
|
+
id: `c${i}`,
|
|
378
|
+
label: `Crumble-${i}`,
|
|
379
|
+
value: 1,
|
|
380
|
+
}));
|
|
381
|
+
const mounted = mountTreemap({
|
|
382
|
+
nodes: [{ id: "big", label: "Big", value: 100000 }, ...crumbs],
|
|
383
|
+
});
|
|
384
|
+
await setSize(mounted, 400, 240);
|
|
385
|
+
const bare = [...mounted.container.querySelectorAll(".hk-treemap__cell")].filter(
|
|
386
|
+
(cell) => cell.querySelector(".hk-treemap__label") === null,
|
|
387
|
+
);
|
|
388
|
+
expect(bare.length).toBeGreaterThan(0);
|
|
389
|
+
// …and every bare cell still carries its full tooltip text.
|
|
390
|
+
const bareWithTooltip = bare.filter((cell) => {
|
|
391
|
+
const host = cell.closest("[data-hk-tooltip-text]");
|
|
392
|
+
return host !== null && host.getAttribute("data-hk-tooltip-text")!.includes("Crumble-");
|
|
393
|
+
});
|
|
394
|
+
expect(bareWithTooltip.length).toBe(bare.length);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe("HkTreemap chrome contract (SCSS pins)", () => {
|
|
399
|
+
const css = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "./HkTreemap.scss"), "utf8");
|
|
400
|
+
|
|
401
|
+
it("overflowing labels ellipsize from the first clipped character", () => {
|
|
402
|
+
expect(css.match(/text-overflow: ellipsis/g)?.length).toBeGreaterThanOrEqual(2);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("the cell mortar reads the theme — no fixed white gutters (the dark-mode glare)", () => {
|
|
406
|
+
expect(css).not.toContain("#fff");
|
|
407
|
+
expect(css).toMatch(/--color-bg/);
|
|
408
|
+
expect(css).toMatch(/--color-muted/);
|
|
409
|
+
});
|
|
410
|
+
});
|