@celestia-island/hikari 0.55.218 → 0.55.220
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 +11 -0
- package/src/components/HkTreemap.test.tsx +50 -12
- package/src/components/HkTreemap.tsx +55 -21
- package/src/components/HkTreemapStage.scss +87 -10
- package/src/components/HkTreemapStage.test.tsx +187 -2
- package/src/components/HkTreemapStage.tsx +200 -53
- package/src/i18n/locales/ar/components.json +2 -0
- package/src/i18n/locales/de/components.json +2 -0
- package/src/i18n/locales/en/components.json +2 -0
- package/src/i18n/locales/es/components.json +2 -0
- package/src/i18n/locales/fr/components.json +2 -0
- package/src/i18n/locales/ja/components.json +2 -0
- package/src/i18n/locales/ko/components.json +2 -0
- package/src/i18n/locales/pt/components.json +2 -0
- package/src/i18n/locales/ru/components.json +2 -0
- package/src/i18n/locales/zh-Hans/components.json +2 -0
- package/src/i18n/locales/zh-Hant/components.json +2 -0
package/package.json
CHANGED
|
@@ -124,6 +124,17 @@
|
|
|
124
124
|
font-weight: 600;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/* An UNKNOWN / unattributed component is not a named category; the slant
|
|
128
|
+
* keeps it visibly distinct from every localized source label. */
|
|
129
|
+
.hk-treemap__label--unknown {
|
|
130
|
+
font-style: italic;
|
|
131
|
+
opacity: 0.9;
|
|
132
|
+
/* Synthetic oblique ink leans past the last advance; the same 0.2em
|
|
133
|
+
* cushion as the family's reviewed italic-truncation rules keeps the
|
|
134
|
+
* trailing stroke inside the ellipsis box. */
|
|
135
|
+
padding-inline-end: 0.2em;
|
|
136
|
+
}
|
|
137
|
+
|
|
127
138
|
.hk-treemap__meta {
|
|
128
139
|
opacity: 0.85;
|
|
129
140
|
font-variant-numeric: tabular-nums;
|
|
@@ -229,18 +229,13 @@ describe("HkTreemap pure layout input", () => {
|
|
|
229
229
|
).toEqual({ nodes: [], total: 0 });
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
-
it("
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
expect(contrastTextColor("rgb(
|
|
237
|
-
expect(contrastTextColor("#
|
|
238
|
-
expect(contrastTextColor("
|
|
239
|
-
expect(contrastTextColor("not-a-color")).toBe("#fff");
|
|
240
|
-
// Every default palette member must clear WCAG AA with its chosen text.
|
|
241
|
-
for (const color of HK_TREEMAP_DEFAULT_COLORS) {
|
|
242
|
-
expect(["#fff", "#0b1220"]).toContain(contrastTextColor(color));
|
|
243
|
-
}
|
|
232
|
+
it("cell labels follow the theme icon/text token, not a per-block flip", () => {
|
|
233
|
+
// User direction 2026-10-28: cells read like icon glyphs — white on the
|
|
234
|
+
// dark theme via rgb(var(--color-text)), the same vocabulary as
|
|
235
|
+
// HkIconButton; the token flips the ink in the light theme.
|
|
236
|
+
expect(contrastTextColor("#edc948")).toBe("rgb(var(--color-text, 30 30 30))");
|
|
237
|
+
expect(contrastTextColor("#4e79a7")).toBe("rgb(var(--color-text, 30 30 30))");
|
|
238
|
+
expect(contrastTextColor("not-a-color")).toBe("rgb(var(--color-text, 30 30 30))");
|
|
244
239
|
});
|
|
245
240
|
|
|
246
241
|
it("builds an accessible leaf-path summary from the same layout", () => {
|
|
@@ -316,6 +311,41 @@ describe("HkTreemap component", () => {
|
|
|
316
311
|
);
|
|
317
312
|
});
|
|
318
313
|
|
|
314
|
+
it("marks an explicitly unknown label with the italic label class", async () => {
|
|
315
|
+
const mounted = mountTreemap({
|
|
316
|
+
nodes: [{ id: "unknown", label: "Unknown", value: 5, labelItalic: true }],
|
|
317
|
+
});
|
|
318
|
+
await setSize(mounted, 400, 240);
|
|
319
|
+
const label = mounted.container.querySelector(".hk-treemap__cell .hk-treemap__label--unknown");
|
|
320
|
+
expect(label).not.toBeNull();
|
|
321
|
+
expect(label!.textContent).toBe("Unknown");
|
|
322
|
+
expect(label!.closest("[data-hk-tooltip-text]")?.getAttribute("data-hk-tooltip-text"))
|
|
323
|
+
.toContain("Unknown");
|
|
324
|
+
// The default-on legend must not restore an upright reading.
|
|
325
|
+
expect(mounted.container.querySelector(".hk-treemap__legend-label.hk-treemap__label--unknown"))
|
|
326
|
+
.not.toBeNull();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("inherits labelItalic from an unknown group to its descendants", async () => {
|
|
330
|
+
const nodes: HkTreemapNode[] = [{
|
|
331
|
+
id: "unknown-group",
|
|
332
|
+
label: "Unknown group",
|
|
333
|
+
value: 0,
|
|
334
|
+
labelItalic: true,
|
|
335
|
+
children: [{ id: "child", label: "Child", value: 5 }],
|
|
336
|
+
}];
|
|
337
|
+
const layout = buildHkTreemapLayout(nodes);
|
|
338
|
+
expect(layout.nodes[0]!.labelItalic).toBe(true);
|
|
339
|
+
expect(layout.nodes[0]!.children![0]!.labelItalic).toBe(true);
|
|
340
|
+
const mounted = mountTreemap({ nodes });
|
|
341
|
+
await setSize(mounted, 400, 240);
|
|
342
|
+
const labels = [...mounted.container.querySelectorAll(".hk-treemap__label")];
|
|
343
|
+
expect(labels.length).toBeGreaterThanOrEqual(2);
|
|
344
|
+
for (const label of labels) {
|
|
345
|
+
expect(label.classList.contains("hk-treemap__label--unknown")).toBe(true);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
319
349
|
it("lays out in LOCAL px under a root zoom — never zoom× oversized (R2 S1)", async () => {
|
|
320
350
|
const mounted = mountTreemap({ nodes: [{ id: "solo", label: "Solo", value: 5 }] });
|
|
321
351
|
// 2× root: gBCR reports 800×480 drawn; the layout must still tile the
|
|
@@ -444,4 +474,12 @@ describe("HkTreemap chrome contract (SCSS pins)", () => {
|
|
|
444
474
|
expect(css).toMatch(/--color-background/);
|
|
445
475
|
expect(css).toMatch(/--color-muted/);
|
|
446
476
|
});
|
|
477
|
+
|
|
478
|
+
it("italic unknown labels carry the reviewed horizontal overhang cushion", () => {
|
|
479
|
+
const block = css.match(/\.hk-treemap__label--unknown\s*\{([\s\S]*?)\}/)?.[1] ?? "";
|
|
480
|
+
expect(block).toContain("font-style: italic");
|
|
481
|
+
// The italic scan cannot resolve a modifier class on top of the base
|
|
482
|
+
// truncation box; pin the cushion at the source level instead.
|
|
483
|
+
expect(block).toMatch(/padding-inline-end:\s*0\.2em/);
|
|
484
|
+
});
|
|
447
485
|
});
|
|
@@ -17,6 +17,10 @@ import "./HkTreemap.scss";
|
|
|
17
17
|
export interface HkTreemapNode {
|
|
18
18
|
id: string;
|
|
19
19
|
label: string;
|
|
20
|
+
/** Render the label in italics — used for an explicitly UNKNOWN /
|
|
21
|
+
* unattributed composition bucket, which must not read as a normal
|
|
22
|
+
* named category. Inherited by descendants. */
|
|
23
|
+
labelItalic?: boolean;
|
|
20
24
|
value: number;
|
|
21
25
|
/** Optional opaque sRGB colour: #rgb/#rrggbb/#rrggbbaa, rgb()/rgba() or
|
|
22
26
|
* hsl()/hsla(). Cells are DOM-painted, so any CSS colour would work at
|
|
@@ -33,6 +37,7 @@ export type HkTreemapValueFormatter = (value: number) => string;
|
|
|
33
37
|
export interface HkTreemapLayoutNode {
|
|
34
38
|
id: string;
|
|
35
39
|
label: string;
|
|
40
|
+
labelItalic?: boolean;
|
|
36
41
|
value: number;
|
|
37
42
|
color: string;
|
|
38
43
|
percent: number;
|
|
@@ -139,21 +144,18 @@ function resolveOpaqueColor(raw: string, fallback: string): string {
|
|
|
139
144
|
return `rgb(${Math.round(channels[0])} ${Math.round(channels[1])} ${Math.round(channels[2])})`;
|
|
140
145
|
}
|
|
141
146
|
|
|
142
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
0.7152 * linear(channels[1]) +
|
|
155
|
-
0.0722 * linear(channels[2]);
|
|
156
|
-
return luminance > 0.193 ? "#0b1220" : "#fff";
|
|
147
|
+
/**
|
|
148
|
+
* Cell label colour — user direction 2026-10-28: the text inside the colour
|
|
149
|
+
* blocks should read like the family's icon glyphs (WHITE on the dark
|
|
150
|
+
* theme), not a per-block black/white flip that made mid-tone cells carry
|
|
151
|
+
* dark text. So the foreground simply follows the theme's icon/text token
|
|
152
|
+
* (`--hi-icon-color` resolves to `rgb(var(--color-text))`), the same
|
|
153
|
+
* vocabulary HkIconButton uses; the light theme keeps dark ink via the same
|
|
154
|
+
* token. The background argument is kept for signature compatibility and
|
|
155
|
+
* intentionally unused.
|
|
156
|
+
*/
|
|
157
|
+
export function contrastTextColor(_background: string): string {
|
|
158
|
+
return "rgb(var(--color-text, 30 30 30))";
|
|
157
159
|
}
|
|
158
160
|
|
|
159
161
|
export const HK_TREEMAP_DEFAULT_COLORS = [
|
|
@@ -174,6 +176,7 @@ const defaultValueFormatter: HkTreemapValueFormatter = (value) => String(value);
|
|
|
174
176
|
interface NumericLayoutNode {
|
|
175
177
|
id: string;
|
|
176
178
|
label: string;
|
|
179
|
+
labelItalic?: boolean;
|
|
177
180
|
value: number;
|
|
178
181
|
color: string;
|
|
179
182
|
children?: NumericLayoutNode[];
|
|
@@ -191,13 +194,15 @@ function isVisibleValue(value: number): boolean {
|
|
|
191
194
|
function normalizeNode(
|
|
192
195
|
node: HkTreemapNode,
|
|
193
196
|
inheritedColor: string,
|
|
197
|
+
inheritedItalic = false,
|
|
194
198
|
): NumericLayoutNode | null {
|
|
195
199
|
const color = node.color
|
|
196
200
|
? resolveOpaqueColor(node.color, inheritedColor)
|
|
197
201
|
: inheritedColor;
|
|
202
|
+
const labelItalic = node.labelItalic ?? inheritedItalic;
|
|
198
203
|
if (node.children && node.children.length > 0) {
|
|
199
204
|
const children = node.children
|
|
200
|
-
.map((child) => normalizeNode(child, color))
|
|
205
|
+
.map((child) => normalizeNode(child, color, labelItalic))
|
|
201
206
|
.filter((child): child is NumericLayoutNode => child !== null);
|
|
202
207
|
if (children.length === 0) return null;
|
|
203
208
|
|
|
@@ -205,11 +210,24 @@ function normalizeNode(
|
|
|
205
210
|
// Finite inputs can still overflow when summed. Such a group cannot be
|
|
206
211
|
// represented truthfully, so omit it rather than emitting Infinity/NaN.
|
|
207
212
|
if (!isVisibleValue(value)) return null;
|
|
208
|
-
return {
|
|
213
|
+
return {
|
|
214
|
+
id: node.id,
|
|
215
|
+
label: node.label,
|
|
216
|
+
...(labelItalic ? { labelItalic: true } : {}),
|
|
217
|
+
value,
|
|
218
|
+
color,
|
|
219
|
+
children,
|
|
220
|
+
};
|
|
209
221
|
}
|
|
210
222
|
|
|
211
223
|
if (!isVisibleValue(node.value)) return null;
|
|
212
|
-
return {
|
|
224
|
+
return {
|
|
225
|
+
id: node.id,
|
|
226
|
+
label: node.label,
|
|
227
|
+
...(labelItalic ? { labelItalic: true } : {}),
|
|
228
|
+
value: node.value,
|
|
229
|
+
color,
|
|
230
|
+
};
|
|
213
231
|
}
|
|
214
232
|
|
|
215
233
|
function visitLeaves(nodes: NumericLayoutNode[], visit: (leaf: NumericLayoutNode) => void): void {
|
|
@@ -523,13 +541,24 @@ export default defineComponent({
|
|
|
523
541
|
>
|
|
524
542
|
{head ? (
|
|
525
543
|
<span class="hk-treemap__group-head">
|
|
526
|
-
<span
|
|
544
|
+
<span
|
|
545
|
+
class={{
|
|
546
|
+
"hk-treemap__label": true,
|
|
547
|
+
"hk-treemap__label--group": true,
|
|
548
|
+
"hk-treemap__label--unknown": node.labelItalic,
|
|
549
|
+
}}
|
|
550
|
+
>{label}</span>
|
|
527
551
|
{meta && <span class="hk-treemap__meta">{meta}</span>}
|
|
528
552
|
</span>
|
|
529
553
|
) : (
|
|
530
554
|
label && (
|
|
531
555
|
<span class="hk-treemap__leaf-body">
|
|
532
|
-
<span
|
|
556
|
+
<span
|
|
557
|
+
class={{
|
|
558
|
+
"hk-treemap__label": true,
|
|
559
|
+
"hk-treemap__label--unknown": node.labelItalic,
|
|
560
|
+
}}
|
|
561
|
+
>{label}</span>
|
|
533
562
|
{meta && <span class="hk-treemap__meta">{meta}</span>}
|
|
534
563
|
</span>
|
|
535
564
|
)
|
|
@@ -558,7 +587,12 @@ export default defineComponent({
|
|
|
558
587
|
style={{ backgroundColor: node.color }}
|
|
559
588
|
aria-hidden="true"
|
|
560
589
|
/>
|
|
561
|
-
<span
|
|
590
|
+
<span
|
|
591
|
+
class={{
|
|
592
|
+
"hk-treemap__legend-label": true,
|
|
593
|
+
"hk-treemap__label--unknown": node.labelItalic,
|
|
594
|
+
}}
|
|
595
|
+
>{node.label}</span>
|
|
562
596
|
<span class="hk-treemap__legend-value">{valueText}</span>
|
|
563
597
|
<span class="hk-treemap__legend-percent">{percentText}</span>
|
|
564
598
|
</li>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
/* HkTreemapStage — the interactive treemap surface:
|
|
2
|
-
*
|
|
3
|
-
* HkTreemap's chrome
|
|
4
|
-
* and the collapsible HkMinimap
|
|
1
|
+
/* HkTreemapStage — the interactive treemap surface: at root, no redundant
|
|
2
|
+
* header; once drilled, a breadcrumb plus the pan/zoom cell canvas (cells
|
|
3
|
+
* reuse HkTreemap's chrome — theme mortar, ellipsis labels), a bottom-right
|
|
4
|
+
* return button, and the collapsible HkMinimap. */
|
|
5
5
|
|
|
6
6
|
.hk-treemap-stage {
|
|
7
7
|
position: relative;
|
|
@@ -23,12 +23,6 @@
|
|
|
23
23
|
border-bottom: 1px solid var(--border-faint);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/* Keeps the path centred when the back button is absent (root level). */
|
|
27
|
-
.hk-treemap-stage__head-pad {
|
|
28
|
-
width: 24px;
|
|
29
|
-
flex: 0 0 auto;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
26
|
.hk-treemap-stage__path {
|
|
33
27
|
display: flex;
|
|
34
28
|
align-items: center;
|
|
@@ -97,6 +91,89 @@
|
|
|
97
91
|
inset: 0;
|
|
98
92
|
}
|
|
99
93
|
|
|
94
|
+
/* ── nested preview inside a big group ───────────────────────────────
|
|
95
|
+
* The group's own button remains the only interactive target; the child
|
|
96
|
+
* rectangles/labels are a non-interactive "what is inside" preview. */
|
|
97
|
+
.hk-treemap-stage__nested-cell {
|
|
98
|
+
pointer-events: none;
|
|
99
|
+
overflow: hidden;
|
|
100
|
+
font-size: var(--text-2xs, 0.6875rem);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.hk-treemap-stage__nested-head {
|
|
104
|
+
display: flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
gap: 0.25rem;
|
|
107
|
+
height: 22px;
|
|
108
|
+
padding: 0 0.25rem;
|
|
109
|
+
background: rgb(0 0 0 / 18%);
|
|
110
|
+
font-weight: 600;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.hk-treemap-stage__nested-leaf {
|
|
114
|
+
display: flex;
|
|
115
|
+
flex-direction: column;
|
|
116
|
+
justify-content: flex-end;
|
|
117
|
+
gap: 1px;
|
|
118
|
+
min-height: 0;
|
|
119
|
+
height: 100%;
|
|
120
|
+
padding: 0.125rem 0.25rem;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.hk-treemap-stage__nested-label {
|
|
124
|
+
display: block;
|
|
125
|
+
min-width: 0;
|
|
126
|
+
overflow: hidden;
|
|
127
|
+
text-overflow: ellipsis;
|
|
128
|
+
white-space: nowrap;
|
|
129
|
+
/* The text-clip contract requires ≥1.3 for descenders in clipped rows. */
|
|
130
|
+
line-height: 1.3;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.hk-treemap-stage__nested-meta {
|
|
134
|
+
flex: 0 0 auto;
|
|
135
|
+
min-width: 0;
|
|
136
|
+
/* In the horizontal group-head row the meta MUST yield to the label so
|
|
137
|
+
* its own ellipsis pair can engage; the leaf body is a column and keeps
|
|
138
|
+
* its intrinsic height. */
|
|
139
|
+
overflow: hidden;
|
|
140
|
+
text-overflow: ellipsis;
|
|
141
|
+
white-space: nowrap;
|
|
142
|
+
opacity: 0.85;
|
|
143
|
+
font-variant-numeric: tabular-nums;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.hk-treemap-stage__nested-head .hk-treemap-stage__nested-meta {
|
|
147
|
+
flex: 0 1 auto;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.hk-treemap-stage__empty {
|
|
151
|
+
position: absolute;
|
|
152
|
+
inset: 0;
|
|
153
|
+
display: grid;
|
|
154
|
+
place-items: center;
|
|
155
|
+
padding: 1rem;
|
|
156
|
+
color: rgb(var(--color-muted, 108 108 108));
|
|
157
|
+
font-size: var(--text-sm, 0.8125rem);
|
|
158
|
+
pointer-events: none;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* A drilled level's return control lives in the same bottom-right corner
|
|
162
|
+
* cluster as the minimap (user: the header-only back was not discoverable). */
|
|
163
|
+
.hk-treemap-stage__back-float {
|
|
164
|
+
position: absolute;
|
|
165
|
+
bottom: var(--space-12, 0.75rem);
|
|
166
|
+
z-index: 51;
|
|
167
|
+
width: 32px;
|
|
168
|
+
height: 32px;
|
|
169
|
+
color: rgb(var(--color-text));
|
|
170
|
+
background: rgb(var(--color-surface) / 0.82);
|
|
171
|
+
border: 1px solid var(--border-faint);
|
|
172
|
+
border-radius: var(--radius-sm);
|
|
173
|
+
backdrop-filter: blur(var(--blur-md));
|
|
174
|
+
box-shadow: var(--shadow-dropdown);
|
|
175
|
+
}
|
|
176
|
+
|
|
100
177
|
/* Cells reuse .hk-treemap__cell chrome from HkTreemap.scss; the stage
|
|
101
178
|
* adds the drill affordance on group cells. */
|
|
102
179
|
.hk-treemap-stage__cell--drillable {
|
|
@@ -123,7 +123,22 @@ describe("HkTreemapStage — the drill path", () => {
|
|
|
123
123
|
const placements = [...mounted.container.querySelectorAll("[data-hk-tooltip-placement]")]
|
|
124
124
|
.map((node) => node.getAttribute("data-hk-tooltip-placement"));
|
|
125
125
|
expect(new Set(placements)).toEqual(new Set(["bottom"]));
|
|
126
|
-
|
|
126
|
+
// No redundant root-level title/header; the canvas aria-label still
|
|
127
|
+
// names the surface for assistive tech.
|
|
128
|
+
expect(mounted.container.querySelector(".hk-treemap-stage__head")).toBeNull();
|
|
129
|
+
expect(mounted.container.querySelector('[aria-label="Back"]')).toBeNull();
|
|
130
|
+
// Big groups preview their children immediately (no click required).
|
|
131
|
+
const nested = [...mounted.container.querySelectorAll(".hk-treemap-stage__nested-cell")];
|
|
132
|
+
expect(nested.length).toBeGreaterThanOrEqual(2);
|
|
133
|
+
const nestedLabels = [...mounted.container.querySelectorAll(".hk-treemap-stage__nested-label")]
|
|
134
|
+
.map((node) => node.textContent);
|
|
135
|
+
expect(nestedLabels).toContain("Alpha");
|
|
136
|
+
expect(nestedLabels).toContain("Beta");
|
|
137
|
+
// Previews live INSIDE the drillable cell: parent hover/focus chrome
|
|
138
|
+
// and the click surface stay above them without a second z-layer.
|
|
139
|
+
for (const preview of nested) {
|
|
140
|
+
expect(preview.closest(".hk-treemap-stage__cell--drillable")).not.toBeNull();
|
|
141
|
+
}
|
|
127
142
|
});
|
|
128
143
|
|
|
129
144
|
it("keeps the tooltip-owned cell frames inside a small canvas at 2×/3× DPI", async () => {
|
|
@@ -216,7 +231,69 @@ describe("HkTreemapStage — the drill path", () => {
|
|
|
216
231
|
expect(container.querySelector('[aria-label="Back"]')).toBeNull();
|
|
217
232
|
});
|
|
218
233
|
|
|
219
|
-
it("
|
|
234
|
+
it("a same-shape live refresh preserves the drill path and camera", async () => {
|
|
235
|
+
const mounted = mountStage({ nodes: NODES });
|
|
236
|
+
await setSize(mounted);
|
|
237
|
+
const group = [...mounted.container.querySelectorAll(".hk-treemap-stage__cell--drillable")]
|
|
238
|
+
.find((c) => (c.getAttribute("aria-label") ?? "").startsWith("Group:")) as HTMLElement;
|
|
239
|
+
group.click();
|
|
240
|
+
await nextTick();
|
|
241
|
+
expect(mounted.container.querySelector('[aria-label="Back"]')).toBeTruthy();
|
|
242
|
+
const back = mounted.container.querySelector<HTMLElement>('[aria-label="Back"]')!;
|
|
243
|
+
expect(back.classList.contains("hk-treemap-stage__back-float")).toBe(true);
|
|
244
|
+
|
|
245
|
+
// A camera change made at this level must survive the refresh too.
|
|
246
|
+
const canvas = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__canvas")!;
|
|
247
|
+
canvas.dispatchEvent(new WheelEvent("wheel", {
|
|
248
|
+
deltaY: -100, bubbles: true, cancelable: true,
|
|
249
|
+
}));
|
|
250
|
+
await nextTick();
|
|
251
|
+
const surface = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__surface")!;
|
|
252
|
+
const camera = surface.style.transform;
|
|
253
|
+
expect(camera).toContain("scale(1.1)");
|
|
254
|
+
|
|
255
|
+
// Live polling hands the stage a NEW array with the same ids (updated
|
|
256
|
+
// weights). The user's drill must survive; only the values move.
|
|
257
|
+
mounted.update({
|
|
258
|
+
nodes: [
|
|
259
|
+
{
|
|
260
|
+
id: "group",
|
|
261
|
+
label: "Group",
|
|
262
|
+
value: 0,
|
|
263
|
+
children: [
|
|
264
|
+
{ id: "a", label: "Alpha updated", value: 5 },
|
|
265
|
+
{ id: "b", label: "Beta", value: 7 },
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
{ id: "other", label: "Other", value: 4 },
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
await nextTick();
|
|
272
|
+
expect(mounted.container.querySelector('[aria-label="Back"]')).toBeTruthy();
|
|
273
|
+
expect(mounted.container.textContent).toContain("Alpha updated");
|
|
274
|
+
expect(mounted.container.textContent).toContain("Beta");
|
|
275
|
+
expect(mounted.container.querySelector(".hk-treemap-stage__head")).toBeTruthy();
|
|
276
|
+
expect(mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__surface")!.style.transform)
|
|
277
|
+
.toBe(camera);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("keeps a drilled level with a localized empty message when its children vanish", async () => {
|
|
281
|
+
const mounted = mountStage({ nodes: NODES, emptyText: "Nothing inside" });
|
|
282
|
+
await setSize(mounted);
|
|
283
|
+
const group = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__cell--drillable")!;
|
|
284
|
+
group.click();
|
|
285
|
+
await nextTick();
|
|
286
|
+
|
|
287
|
+
// Same group id, refreshed into a leaf: no auto-return, no blank canvas.
|
|
288
|
+
mounted.update({ nodes: [{ id: "group", label: "Group", value: 5 }] });
|
|
289
|
+
await nextTick();
|
|
290
|
+
expect(mounted.container.querySelector('[aria-label="Back"]')).toBeTruthy();
|
|
291
|
+
expect(mounted.container.querySelector(".hk-treemap-stage__head")).toBeTruthy();
|
|
292
|
+
expect(mounted.container.querySelector(".hk-treemap-stage__empty")?.textContent)
|
|
293
|
+
.toBe("Nothing inside");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("removing the drilled group falls back to root and clears the back control", async () => {
|
|
220
297
|
const mounted = mountStage({ nodes: NODES });
|
|
221
298
|
await setSize(mounted);
|
|
222
299
|
const group = [...mounted.container.querySelectorAll(".hk-treemap-stage__cell--drillable")]
|
|
@@ -228,8 +305,116 @@ describe("HkTreemapStage — the drill path", () => {
|
|
|
228
305
|
mounted.update({ nodes: [{ id: "fresh", label: "Fresh", value: 3 }] });
|
|
229
306
|
await nextTick();
|
|
230
307
|
expect(mounted.container.querySelector('[aria-label="Back"]')).toBeNull();
|
|
308
|
+
expect(mounted.container.querySelector(".hk-treemap-stage__head")).toBeNull();
|
|
231
309
|
expect(mounted.container.textContent).toContain("Fresh");
|
|
232
310
|
});
|
|
311
|
+
|
|
312
|
+
it("reveals the nested preview only when zoom makes the group large enough", async () => {
|
|
313
|
+
const nestedNodes: HkTreemapNode[] = [
|
|
314
|
+
{
|
|
315
|
+
id: "outer",
|
|
316
|
+
label: "Outer",
|
|
317
|
+
value: 0,
|
|
318
|
+
children: [{
|
|
319
|
+
id: "inner",
|
|
320
|
+
label: "Inner",
|
|
321
|
+
value: 0,
|
|
322
|
+
children: [
|
|
323
|
+
{ id: "inner-a", label: "Inner A", value: 1 },
|
|
324
|
+
{ id: "inner-b", label: "Inner B", value: 1 },
|
|
325
|
+
],
|
|
326
|
+
}],
|
|
327
|
+
},
|
|
328
|
+
{ id: "other", label: "Other", value: 1 },
|
|
329
|
+
];
|
|
330
|
+
const mounted = mountStage({ nodes: nestedNodes, zoomPercent: 100 });
|
|
331
|
+
await setSize(mounted, 100, 240);
|
|
332
|
+
// The group cell is 100px wide at 100% — below the 120×90 preview
|
|
333
|
+
// gate, even though it is tall.
|
|
334
|
+
expect(mounted.container.querySelectorAll(".hk-treemap-stage__nested-cell")).toHaveLength(0);
|
|
335
|
+
|
|
336
|
+
mounted.update({ zoomPercent: 150 });
|
|
337
|
+
await nextTick();
|
|
338
|
+
const previews = [...mounted.container.querySelectorAll<HTMLElement>(".hk-treemap-stage__nested-cell")];
|
|
339
|
+
expect(previews.length).toBeGreaterThan(0);
|
|
340
|
+
// Recursion reaches the grandchild level (outer → inner → leaves) and
|
|
341
|
+
// the nested group's own label passes the zoomed label ladder.
|
|
342
|
+
expect(previews.some((cell) => cell.dataset.depth === "1")).toBe(true);
|
|
343
|
+
expect(previews.every((cell) => cell.getAttribute("aria-hidden") === "true")).toBe(true);
|
|
344
|
+
// Flat VNodes all live under the top-level cell: the grandchild must sit
|
|
345
|
+
// below/right of its nested parent by the header/pad inset. Re-basing on
|
|
346
|
+
// the nested cell's own x/y would collapse this delta to zero.
|
|
347
|
+
const depth0 = previews.find((cell) => cell.dataset.depth === "0")!;
|
|
348
|
+
const depth1 = previews.find((cell) => cell.dataset.depth === "1")!;
|
|
349
|
+
expect(Number.parseFloat(depth1.style.top) - Number.parseFloat(depth0.style.top))
|
|
350
|
+
.toBeGreaterThanOrEqual(22);
|
|
351
|
+
expect(Number.parseFloat(depth1.style.left) - Number.parseFloat(depth0.style.left))
|
|
352
|
+
.toBeGreaterThanOrEqual(2);
|
|
353
|
+
expect(mounted.container.textContent).toContain("Inner");
|
|
354
|
+
// Previews are descendants of the group button, not siblings.
|
|
355
|
+
for (const preview of previews) {
|
|
356
|
+
expect(preview.closest(".hk-treemap-stage__cell--drillable")).not.toBeNull();
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("also gates the preview on the zoomed HEIGHT, not width alone", async () => {
|
|
361
|
+
const wideShort = mountStage({
|
|
362
|
+
nodes: [{
|
|
363
|
+
id: "wide",
|
|
364
|
+
label: "Wide",
|
|
365
|
+
value: 0,
|
|
366
|
+
children: [
|
|
367
|
+
{ id: "wide-a", label: "Wide A", value: 1 },
|
|
368
|
+
{ id: "wide-b", label: "Wide B", value: 1 },
|
|
369
|
+
],
|
|
370
|
+
}],
|
|
371
|
+
zoomPercent: 100,
|
|
372
|
+
});
|
|
373
|
+
await setSize(wideShort, 200, 80);
|
|
374
|
+
// Full-canvas group: 200×80 at 100% — wide enough, too short.
|
|
375
|
+
expect(wideShort.container.querySelectorAll(".hk-treemap-stage__nested-cell")).toHaveLength(0);
|
|
376
|
+
wideShort.update({ zoomPercent: 150 });
|
|
377
|
+
await nextTick();
|
|
378
|
+
expect(wideShort.container.querySelectorAll(".hk-treemap-stage__nested-cell").length)
|
|
379
|
+
.toBeGreaterThan(0);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("parks the floating return control beside the minimap when it expands", async () => {
|
|
383
|
+
const mounted = mountStage({ nodes: NODES });
|
|
384
|
+
await setSize(mounted);
|
|
385
|
+
const group = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__cell--drillable")!;
|
|
386
|
+
group.click();
|
|
387
|
+
await nextTick();
|
|
388
|
+
const back = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__back-float")!;
|
|
389
|
+
const endOf = () => back.style.getPropertyValue("inset-inline-end");
|
|
390
|
+
expect(endOf()).toBe("50px"); // 12px gutter + 30px collapsed map + 8px gap
|
|
391
|
+
|
|
392
|
+
(mounted.container.querySelector(".hk-minimap--collapsed") as HTMLElement).click();
|
|
393
|
+
await nextTick();
|
|
394
|
+
expect(endOf()).toBe("180px"); // expanded map is 160px wide
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("renders an unknown child label in italics", async () => {
|
|
398
|
+
const mounted = mountStage({
|
|
399
|
+
nodes: [{
|
|
400
|
+
id: "group",
|
|
401
|
+
label: "Group",
|
|
402
|
+
value: 0,
|
|
403
|
+
children: [
|
|
404
|
+
{ id: "known", label: "Known", value: 3 },
|
|
405
|
+
{ id: "unknown", label: "Unknown", value: 9, labelItalic: true },
|
|
406
|
+
],
|
|
407
|
+
}],
|
|
408
|
+
});
|
|
409
|
+
await setSize(mounted);
|
|
410
|
+
// A large unknown leaf also gets a top-level italic label when drilled.
|
|
411
|
+
const group = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__cell--drillable")!;
|
|
412
|
+
group.click();
|
|
413
|
+
await nextTick();
|
|
414
|
+
const unknown = mounted.container.querySelector(".hk-treemap__label--unknown");
|
|
415
|
+
expect(unknown).not.toBeNull();
|
|
416
|
+
expect(unknown!.textContent).toBe("Unknown");
|
|
417
|
+
});
|
|
233
418
|
});
|
|
234
419
|
|
|
235
420
|
describe("HkTreemapStage — the camera + minimap", () => {
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch,
|
|
2
|
-
type PropType } from "vue";
|
|
2
|
+
type PropType, type VNode } from "vue";
|
|
3
3
|
import { ChevronLeft } from "lucide-vue-next";
|
|
4
4
|
|
|
5
5
|
import HkIconButton from "./HkIconButton";
|
|
6
6
|
import HkMinimap, { type MinimapBox } from "./HkMinimap";
|
|
7
7
|
import HkTooltip from "./HkTooltip";
|
|
8
8
|
import { drawnScale } from "../composables/layoutGeometry";
|
|
9
|
+
import { useI18n } from "../i18n/context";
|
|
9
10
|
import {
|
|
10
11
|
buildHkTreemapLayout,
|
|
11
12
|
contrastTextColor,
|
|
@@ -21,13 +22,13 @@ import "./HkTreemapStage.scss";
|
|
|
21
22
|
|
|
22
23
|
const defaultValueFormatter: HkTreemapValueFormatter = (value) => String(value);
|
|
23
24
|
|
|
24
|
-
/**
|
|
25
|
-
interface
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
/** The live resolution of the drill path against the CURRENT layout. */
|
|
26
|
+
interface ResolvedDrill {
|
|
27
|
+
/** Valid ids (a refresh that removed a hop truncates here). */
|
|
28
|
+
ids: string[];
|
|
29
|
+
/** The nodes along the path, for the breadcrumb. */
|
|
30
|
+
nodes: HkTreemapLayoutNode[];
|
|
31
|
+
/** The children shown on the canvas at this level. */
|
|
31
32
|
siblings: HkTreemapLayoutNode[];
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -37,9 +38,13 @@ interface DrillStep {
|
|
|
37
38
|
*
|
|
38
39
|
* - DRILL: clicking a GROUP cell zooms into it — its children re-lay out
|
|
39
40
|
* at the full canvas, so cells that were slivers reveal their labels,
|
|
40
|
-
* meta and nested structure
|
|
41
|
-
*
|
|
42
|
-
*
|
|
41
|
+
* meta and nested structure. The drill path survives live data refreshes
|
|
42
|
+
* (ids, not snapshots); at root there is NO header/title, and a drilled
|
|
43
|
+
* level shows the breadcrumb plus a floating return button in the
|
|
44
|
+
* bottom-right control cluster beside the minimap.
|
|
45
|
+
* - PREVIEW: a group large enough to subdivide paints its child cells in
|
|
46
|
+
* place (non-interactive) — the operator sees some of the composition
|
|
47
|
+
* before choosing to drill.
|
|
43
48
|
* - PAN/ZOOM: the camera scales/translates the cell layer; the ±/slider
|
|
44
49
|
* zoom bar (via the collapsible HkMinimap pinned bottom-right) steps
|
|
45
50
|
* 50–200%, and dragging the minimap pans. Larger zoom rungs enlarge
|
|
@@ -73,6 +78,7 @@ export default defineComponent({
|
|
|
73
78
|
select: (_id: string) => true,
|
|
74
79
|
},
|
|
75
80
|
setup(props, { emit }) {
|
|
81
|
+
const { t } = useI18n();
|
|
76
82
|
const hostRef = ref<HTMLElement | null>(null);
|
|
77
83
|
const size = ref<{ w: number; h: number } | null>(null);
|
|
78
84
|
let observer: ResizeObserver | undefined;
|
|
@@ -161,13 +167,33 @@ export default defineComponent({
|
|
|
161
167
|
panY.value = 0;
|
|
162
168
|
}
|
|
163
169
|
|
|
164
|
-
// ── the drill
|
|
170
|
+
// ── the drill path ────────────────────────────────────────────────
|
|
171
|
+
// Store IDS, never a children snapshot. Live parent refreshes allocate
|
|
172
|
+
// a new tree (same ids, updated tokens) on every poll; resolving the
|
|
173
|
+
// path against the current layout keeps the operator's drill/camera
|
|
174
|
+
// instead of silently snapping back to root (the reported regression).
|
|
165
175
|
const layout = computed(() => buildHkTreemapLayout(props.nodes));
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
176
|
+
const drillPath = ref<string[]>([]);
|
|
177
|
+
const resolvedDrill = computed<ResolvedDrill>(() => {
|
|
178
|
+
let siblings = layout.value.nodes;
|
|
179
|
+
const nodes: HkTreemapLayoutNode[] = [];
|
|
180
|
+
let valid = 0;
|
|
181
|
+
for (const id of drillPath.value) {
|
|
182
|
+
const next = siblings.find((node) => node.id === id);
|
|
183
|
+
if (!next) break;
|
|
184
|
+
nodes.push(next);
|
|
185
|
+
siblings = next.children ?? [];
|
|
186
|
+
valid += 1;
|
|
187
|
+
}
|
|
188
|
+
return { ids: drillPath.value.slice(0, valid), nodes, siblings };
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Only an actually invalidated hop changes the path. A same-shape data
|
|
192
|
+
// refresh does not touch state at all.
|
|
193
|
+
watch(resolvedDrill, (resolved) => {
|
|
194
|
+
if (resolved.ids.length === drillPath.value.length) return;
|
|
195
|
+
drillPath.value = resolved.ids;
|
|
196
|
+
resetCamera();
|
|
171
197
|
});
|
|
172
198
|
|
|
173
199
|
function pushDrill(node: HkTreemapLayoutNode): void {
|
|
@@ -175,10 +201,7 @@ export default defineComponent({
|
|
|
175
201
|
emit("select", node.id);
|
|
176
202
|
return;
|
|
177
203
|
}
|
|
178
|
-
|
|
179
|
-
...drill.value,
|
|
180
|
-
{ id: node.id, label: node.label, siblings: node.children },
|
|
181
|
-
];
|
|
204
|
+
drillPath.value = [...drillPath.value, node.id];
|
|
182
205
|
// Drilling is itself a zoom: reset the pan so the new level starts
|
|
183
206
|
// framed; the zoom rung stays (the user picked it).
|
|
184
207
|
panX.value = 0;
|
|
@@ -186,22 +209,11 @@ export default defineComponent({
|
|
|
186
209
|
}
|
|
187
210
|
|
|
188
211
|
function popTo(index: number): void {
|
|
189
|
-
|
|
212
|
+
drillPath.value = drillPath.value.slice(0, index);
|
|
190
213
|
panX.value = 0;
|
|
191
214
|
panY.value = 0;
|
|
192
215
|
}
|
|
193
216
|
|
|
194
|
-
// A changed LAYOUT invalidates the drill stack. Watching the layout
|
|
195
|
-
// (not the array identity) catches the in-place mutation a consumer
|
|
196
|
-
// can make (the computed recomputes off contents). Inline
|
|
197
|
-
// `:nodes="[...]"` literals still reset on every parent re-render —
|
|
198
|
-
// the layout allocates a fresh tree per recompute — so pass a stable
|
|
199
|
-
// reference from hot parents (known limitation, delta review F3).
|
|
200
|
-
watch(layout, () => {
|
|
201
|
-
drill.value = [];
|
|
202
|
-
resetCamera();
|
|
203
|
-
});
|
|
204
|
-
|
|
205
217
|
// ── measurement (LOCAL px — the dpiPrefs root-zoom lesson) ────────
|
|
206
218
|
function measure() {
|
|
207
219
|
const el = hostRef.value;
|
|
@@ -228,14 +240,20 @@ export default defineComponent({
|
|
|
228
240
|
resizeCleanup?.();
|
|
229
241
|
resizeCleanup = undefined;
|
|
230
242
|
});
|
|
231
|
-
watch([layout,
|
|
243
|
+
watch([layout, resolvedDrill], measure);
|
|
232
244
|
|
|
233
245
|
// ── cells at the current level ────────────────────────────────────
|
|
234
246
|
const cells = computed<HkTreemapCell[]>(() => {
|
|
235
247
|
const dims = size.value;
|
|
236
248
|
if (!dims || layout.value.nodes.length === 0) return [];
|
|
237
|
-
return layoutHkTreemapCells(
|
|
249
|
+
return layoutHkTreemapCells(resolvedDrill.value.siblings, 0, 0, dims.w, dims.h);
|
|
238
250
|
});
|
|
251
|
+
/** A drilled node can be refreshed into a leaf with no visible
|
|
252
|
+
* children. Keep the level (and its return control) instead of
|
|
253
|
+
* silently bouncing home, but explain the empty canvas. */
|
|
254
|
+
const levelEmpty = computed(() =>
|
|
255
|
+
hasData.value && resolvedDrill.value.nodes.length > 0 && cells.value.length === 0,
|
|
256
|
+
);
|
|
239
257
|
|
|
240
258
|
const formatValue = (value: number) =>
|
|
241
259
|
(props.valueFormatter ?? defaultValueFormatter)(value);
|
|
@@ -266,6 +284,13 @@ export default defineComponent({
|
|
|
266
284
|
internalMinimapCollapsed.value = v;
|
|
267
285
|
emit("update:minimapCollapsed", v);
|
|
268
286
|
}
|
|
287
|
+
/** Park the floating return button beside the minimap, whether folded
|
|
288
|
+
* (30px) or expanded (160px), so the control stays in the same corner
|
|
289
|
+
* cluster instead of jumping to the top header. */
|
|
290
|
+
const backInsetEnd = computed(() => {
|
|
291
|
+
const minimapWidth = minimapIsCollapsed.value ? 30 : 160;
|
|
292
|
+
return `${12 + minimapWidth + 8}px`;
|
|
293
|
+
});
|
|
269
294
|
|
|
270
295
|
// ── render ────────────────────────────────────────────────────────
|
|
271
296
|
function cellText(node: HkTreemapLayoutNode): string {
|
|
@@ -278,23 +303,117 @@ export default defineComponent({
|
|
|
278
303
|
transformOrigin: "0 0",
|
|
279
304
|
}));
|
|
280
305
|
|
|
306
|
+
// ── nested preview ────────────────────────────────────────────────
|
|
307
|
+
// A big group already contains a laid-out child tree. Paint it in
|
|
308
|
+
// place (labels/meta allowed to degrade, exact like the standalone
|
|
309
|
+
// treemap) so the operator sees WHICH parts make up the block before
|
|
310
|
+
// drilling. Purely visual: pointer-events:none keeps the whole group
|
|
311
|
+
// one click target.
|
|
312
|
+
const NESTED_MIN_W = 120;
|
|
313
|
+
const NESTED_MIN_H = 90;
|
|
314
|
+
const NESTED_RECURSE_W = 90;
|
|
315
|
+
const NESTED_RECURSE_H = 70;
|
|
316
|
+
const NESTED_MAX_DEPTH = 3;
|
|
317
|
+
|
|
318
|
+
function renderNestedCell(
|
|
319
|
+
cell: HkTreemapCell,
|
|
320
|
+
key: string,
|
|
321
|
+
depth: number,
|
|
322
|
+
originX: number,
|
|
323
|
+
originY: number,
|
|
324
|
+
): VNode[] {
|
|
325
|
+
const { node, x, y, w, h } = cell;
|
|
326
|
+
if (w < 2 || h < 2) return [];
|
|
327
|
+
const zoom = zoomPct.value / 100;
|
|
328
|
+
const zw = w * zoom;
|
|
329
|
+
const zh = h * zoom;
|
|
330
|
+
const isGroup = cell.children.length > 0;
|
|
331
|
+
const showLabel = zh >= 18 && zw >= 24;
|
|
332
|
+
const showValue = zh >= 40 && zw >= 56;
|
|
333
|
+
const meta = showValue
|
|
334
|
+
? `${formatValue(node.value)} · ${formatHkTreemapPercent(node.percent)}`
|
|
335
|
+
: null;
|
|
336
|
+
const label = (
|
|
337
|
+
<span
|
|
338
|
+
class={{
|
|
339
|
+
"hk-treemap-stage__nested-label": true,
|
|
340
|
+
"hk-treemap__label--group": isGroup,
|
|
341
|
+
"hk-treemap__label--unknown": node.labelItalic,
|
|
342
|
+
}}
|
|
343
|
+
>{node.label}</span>
|
|
344
|
+
);
|
|
345
|
+
const out: VNode[] = [
|
|
346
|
+
<div
|
|
347
|
+
key={key}
|
|
348
|
+
class={{
|
|
349
|
+
"hk-treemap__cell": true,
|
|
350
|
+
"hk-treemap-stage__nested-cell": true,
|
|
351
|
+
}}
|
|
352
|
+
style={{
|
|
353
|
+
position: "absolute",
|
|
354
|
+
left: `${x - originX}px`,
|
|
355
|
+
top: `${y - originY}px`,
|
|
356
|
+
width: `${w}px`,
|
|
357
|
+
height: `${h}px`,
|
|
358
|
+
background: node.color,
|
|
359
|
+
"--hk-treemap-fg": contrastTextColor(node.color),
|
|
360
|
+
}}
|
|
361
|
+
data-depth={depth}
|
|
362
|
+
aria-hidden="true"
|
|
363
|
+
>
|
|
364
|
+
{isGroup ? (
|
|
365
|
+
showLabel && (
|
|
366
|
+
<span class="hk-treemap-stage__nested-head">
|
|
367
|
+
{label}
|
|
368
|
+
{meta && <span class="hk-treemap-stage__nested-meta">{meta}</span>}
|
|
369
|
+
</span>
|
|
370
|
+
)
|
|
371
|
+
) : (
|
|
372
|
+
showLabel && (
|
|
373
|
+
<span class="hk-treemap-stage__nested-leaf">
|
|
374
|
+
{label}
|
|
375
|
+
{meta && <span class="hk-treemap-stage__nested-meta">{meta}</span>}
|
|
376
|
+
</span>
|
|
377
|
+
)
|
|
378
|
+
)}
|
|
379
|
+
</div>,
|
|
380
|
+
];
|
|
381
|
+
if (
|
|
382
|
+
isGroup
|
|
383
|
+
&& depth < NESTED_MAX_DEPTH
|
|
384
|
+
&& w * zoom >= NESTED_RECURSE_W
|
|
385
|
+
&& h * zoom >= NESTED_RECURSE_H
|
|
386
|
+
) {
|
|
387
|
+
for (const child of cell.children) {
|
|
388
|
+
// The returned VNodes are FLAT siblings under the top-level group
|
|
389
|
+
// cell (not DOM children of this nested cell), so every depth keeps
|
|
390
|
+
// the entry origin. Passing this cell's x/y shifts grandchildren up
|
|
391
|
+
// and left by the accumulated padding/header inset.
|
|
392
|
+
out.push(...renderNestedCell(child, `${key}/${child.node.id}`, depth + 1, originX, originY));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Child previews rendered INSIDE the top-level group cell, so the
|
|
399
|
+
* cell's hover/focus ring and click surface stay above them without a
|
|
400
|
+
* second stacking layer. */
|
|
401
|
+
function renderNestedPreview(cell: HkTreemapCell): VNode[] {
|
|
402
|
+
if (cell.children.length === 0) return [];
|
|
403
|
+
// The same zoom-aware ladder as the labels: a group that becomes
|
|
404
|
+
// visually large at a higher rung reveals its inside too.
|
|
405
|
+
const zoom = zoomPct.value / 100;
|
|
406
|
+
if (cell.w * zoom < NESTED_MIN_W || cell.h * zoom < NESTED_MIN_H) return [];
|
|
407
|
+
return cell.children.flatMap((child) =>
|
|
408
|
+
renderNestedCell(child, `nested:${cell.node.id}/${child.node.id}`, 0, cell.x, cell.y),
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
281
412
|
return () => (
|
|
282
413
|
<div class="hk-treemap-stage">
|
|
283
|
-
{hasData.value && (
|
|
414
|
+
{hasData.value && resolvedDrill.value.nodes.length > 0 && (
|
|
284
415
|
<header class="hk-treemap-stage__head">
|
|
285
|
-
{
|
|
286
|
-
<HkIconButton
|
|
287
|
-
size={24}
|
|
288
|
-
variant="ghost"
|
|
289
|
-
aria-label="Back"
|
|
290
|
-
onClick={() => popTo(drill.value.length - 1)}
|
|
291
|
-
>
|
|
292
|
-
{{ icon: () => <ChevronLeft size={15} /> }}
|
|
293
|
-
</HkIconButton>
|
|
294
|
-
) : (
|
|
295
|
-
<span class="hk-treemap-stage__head-pad" aria-hidden="true" />
|
|
296
|
-
)}
|
|
297
|
-
<nav class="hk-treemap-stage__path" aria-label="Drill path">
|
|
416
|
+
<nav class="hk-treemap-stage__path" aria-label={t("hikari::treemap.drillPath", "Drill path")}>
|
|
298
417
|
<button
|
|
299
418
|
type="button"
|
|
300
419
|
class="hk-treemap-stage__crumb"
|
|
@@ -303,13 +422,14 @@ export default defineComponent({
|
|
|
303
422
|
>
|
|
304
423
|
{props.ariaLabel}
|
|
305
424
|
</button>
|
|
306
|
-
{
|
|
425
|
+
{resolvedDrill.value.nodes.map((step, i) => (
|
|
307
426
|
<span key={step.id} class="hk-treemap-stage__crumb-seg">
|
|
308
427
|
<span class="hk-treemap-stage__crumb-sep" aria-hidden="true">/</span>
|
|
309
428
|
<button
|
|
310
429
|
type="button"
|
|
311
430
|
class="hk-treemap-stage__crumb"
|
|
312
|
-
data-last={i ===
|
|
431
|
+
data-last={i === resolvedDrill.value.nodes.length - 1 || undefined}
|
|
432
|
+
aria-current={i === resolvedDrill.value.nodes.length - 1 ? "page" : undefined}
|
|
313
433
|
onClick={() => popTo(i + 1)}
|
|
314
434
|
>
|
|
315
435
|
{step.label}
|
|
@@ -377,7 +497,13 @@ export default defineComponent({
|
|
|
377
497
|
>
|
|
378
498
|
{isGroup ? (
|
|
379
499
|
<span class="hk-treemap__group-head">
|
|
380
|
-
<span
|
|
500
|
+
<span
|
|
501
|
+
class={{
|
|
502
|
+
"hk-treemap__label": true,
|
|
503
|
+
"hk-treemap__label--group": true,
|
|
504
|
+
"hk-treemap__label--unknown": node.labelItalic,
|
|
505
|
+
}}
|
|
506
|
+
>
|
|
381
507
|
{node.label}
|
|
382
508
|
</span>
|
|
383
509
|
{showValue && (
|
|
@@ -389,7 +515,12 @@ export default defineComponent({
|
|
|
389
515
|
) : (
|
|
390
516
|
showLabel && (
|
|
391
517
|
<span class="hk-treemap__leaf-body">
|
|
392
|
-
<span
|
|
518
|
+
<span
|
|
519
|
+
class={{
|
|
520
|
+
"hk-treemap__label": true,
|
|
521
|
+
"hk-treemap__label--unknown": node.labelItalic,
|
|
522
|
+
}}
|
|
523
|
+
>{node.label}</span>
|
|
393
524
|
{showValue && (
|
|
394
525
|
<span class="hk-treemap__meta">
|
|
395
526
|
{formatValue(node.value)} · {formatHkTreemapPercent(node.percent)}
|
|
@@ -398,6 +529,7 @@ export default defineComponent({
|
|
|
398
529
|
</span>
|
|
399
530
|
)
|
|
400
531
|
)}
|
|
532
|
+
{isGroup && renderNestedPreview(cell)}
|
|
401
533
|
</div>
|
|
402
534
|
)}
|
|
403
535
|
</HkTooltip>
|
|
@@ -409,6 +541,9 @@ export default defineComponent({
|
|
|
409
541
|
<div class="hk-treemap__empty">{props.emptyText}</div>
|
|
410
542
|
)}
|
|
411
543
|
</div>
|
|
544
|
+
{levelEmpty.value && (
|
|
545
|
+
<div class="hk-treemap-stage__empty">{props.emptyText}</div>
|
|
546
|
+
)}
|
|
412
547
|
{hasData.value && (
|
|
413
548
|
<HkMinimap
|
|
414
549
|
collapsible
|
|
@@ -429,6 +564,18 @@ export default defineComponent({
|
|
|
429
564
|
onPanDelta={(dx: number, dy: number) => applyPanDelta(dx, dy)}
|
|
430
565
|
/>
|
|
431
566
|
)}
|
|
567
|
+
{hasData.value && resolvedDrill.value.nodes.length > 0 && (
|
|
568
|
+
<HkIconButton
|
|
569
|
+
class="hk-treemap-stage__back-float"
|
|
570
|
+
style={{ insetInlineEnd: backInsetEnd.value }}
|
|
571
|
+
size={32}
|
|
572
|
+
variant="ghost"
|
|
573
|
+
aria-label={t("hikari::treemap.back", "Back")}
|
|
574
|
+
onClick={() => popTo(resolvedDrill.value.nodes.length - 1)}
|
|
575
|
+
>
|
|
576
|
+
{{ icon: () => <ChevronLeft size={16} /> }}
|
|
577
|
+
</HkIconButton>
|
|
578
|
+
)}
|
|
432
579
|
</div>
|
|
433
580
|
</div>
|
|
434
581
|
);
|
|
@@ -199,6 +199,8 @@
|
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "مستوى التكبير",
|
|
200
200
|
"hikari::zoomToolbar.expandMap": "إظهار الخريطة المصغّرة",
|
|
201
201
|
"hikari::zoomToolbar.collapseMap": "إخفاء الخريطة المصغّرة",
|
|
202
|
+
"hikari::treemap.back": "رجوع",
|
|
203
|
+
"hikari::treemap.drillPath": "مسار التفصيل",
|
|
202
204
|
"hikari::filterBar.trigger": "عوامل التصفية",
|
|
203
205
|
"hikari::filterBar.apply": "تطبيق",
|
|
204
206
|
"hikari::filterPanel.all": "الكل",
|
|
@@ -199,6 +199,8 @@
|
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "Zoom-Stufe",
|
|
200
200
|
"hikari::zoomToolbar.expandMap": "Minikarte anzeigen",
|
|
201
201
|
"hikari::zoomToolbar.collapseMap": "Minikarte ausblenden",
|
|
202
|
+
"hikari::treemap.back": "Zurück",
|
|
203
|
+
"hikari::treemap.drillPath": "Drill-Pfad",
|
|
202
204
|
"hikari::filterBar.trigger": "Filter",
|
|
203
205
|
"hikari::filterBar.apply": "Anwenden",
|
|
204
206
|
"hikari::filterPanel.all": "Alle",
|
|
@@ -199,6 +199,8 @@
|
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "Zoom level",
|
|
200
200
|
"hikari::zoomToolbar.expandMap": "Show minimap",
|
|
201
201
|
"hikari::zoomToolbar.collapseMap": "Hide minimap",
|
|
202
|
+
"hikari::treemap.back": "Back",
|
|
203
|
+
"hikari::treemap.drillPath": "Drill path",
|
|
202
204
|
"hikari::filterBar.trigger": "Filters",
|
|
203
205
|
"hikari::filterBar.apply": "Apply",
|
|
204
206
|
"hikari::filterPanel.all": "All",
|
|
@@ -199,6 +199,8 @@
|
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "Nivel de zoom",
|
|
200
200
|
"hikari::zoomToolbar.expandMap": "Mostrar minimapa",
|
|
201
201
|
"hikari::zoomToolbar.collapseMap": "Ocultar minimapa",
|
|
202
|
+
"hikari::treemap.back": "Atrás",
|
|
203
|
+
"hikari::treemap.drillPath": "Ruta de exploración",
|
|
202
204
|
"hikari::filterBar.trigger": "Filtros",
|
|
203
205
|
"hikari::filterBar.apply": "Aplicar",
|
|
204
206
|
"hikari::filterPanel.all": "Todos",
|
|
@@ -199,6 +199,8 @@
|
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "Niveau de zoom",
|
|
200
200
|
"hikari::zoomToolbar.expandMap": "Afficher la mini-carte",
|
|
201
201
|
"hikari::zoomToolbar.collapseMap": "Masquer la mini-carte",
|
|
202
|
+
"hikari::treemap.back": "Retour",
|
|
203
|
+
"hikari::treemap.drillPath": "Chemin d’exploration",
|
|
202
204
|
"hikari::filterBar.trigger": "Filtres",
|
|
203
205
|
"hikari::filterBar.apply": "Appliquer",
|
|
204
206
|
"hikari::filterPanel.all": "Tous",
|
|
@@ -199,6 +199,8 @@
|
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "ズーム倍率",
|
|
200
200
|
"hikari::zoomToolbar.expandMap": "ミニマップを表示",
|
|
201
201
|
"hikari::zoomToolbar.collapseMap": "ミニマップを隠す",
|
|
202
|
+
"hikari::treemap.back": "戻る",
|
|
203
|
+
"hikari::treemap.drillPath": "ドリルパス",
|
|
202
204
|
"hikari::filterBar.trigger": "絞り込み",
|
|
203
205
|
"hikari::filterBar.apply": "適用",
|
|
204
206
|
"hikari::filterPanel.all": "すべて",
|
|
@@ -199,6 +199,8 @@
|
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "확대 수준",
|
|
200
200
|
"hikari::zoomToolbar.expandMap": "미니맵 표시",
|
|
201
201
|
"hikari::zoomToolbar.collapseMap": "미니맵 숨기기",
|
|
202
|
+
"hikari::treemap.back": "뒤로",
|
|
203
|
+
"hikari::treemap.drillPath": "드릴 경로",
|
|
202
204
|
"hikari::filterBar.trigger": "필터",
|
|
203
205
|
"hikari::filterBar.apply": "적용",
|
|
204
206
|
"hikari::filterPanel.all": "전체",
|
|
@@ -199,6 +199,8 @@
|
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "Nível de zoom",
|
|
200
200
|
"hikari::zoomToolbar.expandMap": "Mostrar minimapa",
|
|
201
201
|
"hikari::zoomToolbar.collapseMap": "Ocultar minimapa",
|
|
202
|
+
"hikari::treemap.back": "Voltar",
|
|
203
|
+
"hikari::treemap.drillPath": "Caminho de detalhamento",
|
|
202
204
|
"hikari::filterBar.trigger": "Filtros",
|
|
203
205
|
"hikari::filterBar.apply": "Aplicar",
|
|
204
206
|
"hikari::filterPanel.all": "Todos",
|
|
@@ -199,6 +199,8 @@
|
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "Уровень масштаба",
|
|
200
200
|
"hikari::zoomToolbar.expandMap": "Показать мини-карту",
|
|
201
201
|
"hikari::zoomToolbar.collapseMap": "Скрыть мини-карту",
|
|
202
|
+
"hikari::treemap.back": "Назад",
|
|
203
|
+
"hikari::treemap.drillPath": "Путь детализации",
|
|
202
204
|
"hikari::filterBar.trigger": "Фильтры",
|
|
203
205
|
"hikari::filterBar.apply": "Применить",
|
|
204
206
|
"hikari::filterPanel.all": "Все",
|
|
@@ -199,6 +199,8 @@
|
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "缩放级别",
|
|
200
200
|
"hikari::zoomToolbar.expandMap": "显示小地图",
|
|
201
201
|
"hikari::zoomToolbar.collapseMap": "隐藏小地图",
|
|
202
|
+
"hikari::treemap.back": "返回",
|
|
203
|
+
"hikari::treemap.drillPath": "下钻路径",
|
|
202
204
|
"hikari::statusBar.region.CN": "中国大陆",
|
|
203
205
|
"hikari::statusBar.region.HK": "中国香港",
|
|
204
206
|
"hikari::statusBar.region.MO": "中国澳门",
|
|
@@ -199,6 +199,8 @@
|
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "縮放級別",
|
|
200
200
|
"hikari::zoomToolbar.expandMap": "顯示小地圖",
|
|
201
201
|
"hikari::zoomToolbar.collapseMap": "隱藏小地圖",
|
|
202
|
+
"hikari::treemap.back": "返回",
|
|
203
|
+
"hikari::treemap.drillPath": "下鑽路徑",
|
|
202
204
|
"hikari::statusBar.region.CN": "中國大陸",
|
|
203
205
|
"hikari::statusBar.region.HK": "中國香港",
|
|
204
206
|
"hikari::statusBar.region.MO": "中國澳門",
|