@celestia-island/hikari 0.55.218 → 0.55.219

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.218",
3
+ "version": "0.55.219",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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;
@@ -316,6 +316,41 @@ describe("HkTreemap component", () => {
316
316
  );
317
317
  });
318
318
 
319
+ it("marks an explicitly unknown label with the italic label class", async () => {
320
+ const mounted = mountTreemap({
321
+ nodes: [{ id: "unknown", label: "Unknown", value: 5, labelItalic: true }],
322
+ });
323
+ await setSize(mounted, 400, 240);
324
+ const label = mounted.container.querySelector(".hk-treemap__cell .hk-treemap__label--unknown");
325
+ expect(label).not.toBeNull();
326
+ expect(label!.textContent).toBe("Unknown");
327
+ expect(label!.closest("[data-hk-tooltip-text]")?.getAttribute("data-hk-tooltip-text"))
328
+ .toContain("Unknown");
329
+ // The default-on legend must not restore an upright reading.
330
+ expect(mounted.container.querySelector(".hk-treemap__legend-label.hk-treemap__label--unknown"))
331
+ .not.toBeNull();
332
+ });
333
+
334
+ it("inherits labelItalic from an unknown group to its descendants", async () => {
335
+ const nodes: HkTreemapNode[] = [{
336
+ id: "unknown-group",
337
+ label: "Unknown group",
338
+ value: 0,
339
+ labelItalic: true,
340
+ children: [{ id: "child", label: "Child", value: 5 }],
341
+ }];
342
+ const layout = buildHkTreemapLayout(nodes);
343
+ expect(layout.nodes[0]!.labelItalic).toBe(true);
344
+ expect(layout.nodes[0]!.children![0]!.labelItalic).toBe(true);
345
+ const mounted = mountTreemap({ nodes });
346
+ await setSize(mounted, 400, 240);
347
+ const labels = [...mounted.container.querySelectorAll(".hk-treemap__label")];
348
+ expect(labels.length).toBeGreaterThanOrEqual(2);
349
+ for (const label of labels) {
350
+ expect(label.classList.contains("hk-treemap__label--unknown")).toBe(true);
351
+ }
352
+ });
353
+
319
354
  it("lays out in LOCAL px under a root zoom — never zoom× oversized (R2 S1)", async () => {
320
355
  const mounted = mountTreemap({ nodes: [{ id: "solo", label: "Solo", value: 5 }] });
321
356
  // 2× root: gBCR reports 800×480 drawn; the layout must still tile the
@@ -444,4 +479,12 @@ describe("HkTreemap chrome contract (SCSS pins)", () => {
444
479
  expect(css).toMatch(/--color-background/);
445
480
  expect(css).toMatch(/--color-muted/);
446
481
  });
482
+
483
+ it("italic unknown labels carry the reviewed horizontal overhang cushion", () => {
484
+ const block = css.match(/\.hk-treemap__label--unknown\s*\{([\s\S]*?)\}/)?.[1] ?? "";
485
+ expect(block).toContain("font-style: italic");
486
+ // The italic scan cannot resolve a modifier class on top of the base
487
+ // truncation box; pin the cushion at the source level instead.
488
+ expect(block).toMatch(/padding-inline-end:\s*0\.2em/);
489
+ });
447
490
  });
@@ -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;
@@ -174,6 +179,7 @@ const defaultValueFormatter: HkTreemapValueFormatter = (value) => String(value);
174
179
  interface NumericLayoutNode {
175
180
  id: string;
176
181
  label: string;
182
+ labelItalic?: boolean;
177
183
  value: number;
178
184
  color: string;
179
185
  children?: NumericLayoutNode[];
@@ -191,13 +197,15 @@ function isVisibleValue(value: number): boolean {
191
197
  function normalizeNode(
192
198
  node: HkTreemapNode,
193
199
  inheritedColor: string,
200
+ inheritedItalic = false,
194
201
  ): NumericLayoutNode | null {
195
202
  const color = node.color
196
203
  ? resolveOpaqueColor(node.color, inheritedColor)
197
204
  : inheritedColor;
205
+ const labelItalic = node.labelItalic ?? inheritedItalic;
198
206
  if (node.children && node.children.length > 0) {
199
207
  const children = node.children
200
- .map((child) => normalizeNode(child, color))
208
+ .map((child) => normalizeNode(child, color, labelItalic))
201
209
  .filter((child): child is NumericLayoutNode => child !== null);
202
210
  if (children.length === 0) return null;
203
211
 
@@ -205,11 +213,24 @@ function normalizeNode(
205
213
  // Finite inputs can still overflow when summed. Such a group cannot be
206
214
  // represented truthfully, so omit it rather than emitting Infinity/NaN.
207
215
  if (!isVisibleValue(value)) return null;
208
- return { id: node.id, label: node.label, value, color, children };
216
+ return {
217
+ id: node.id,
218
+ label: node.label,
219
+ ...(labelItalic ? { labelItalic: true } : {}),
220
+ value,
221
+ color,
222
+ children,
223
+ };
209
224
  }
210
225
 
211
226
  if (!isVisibleValue(node.value)) return null;
212
- return { id: node.id, label: node.label, value: node.value, color };
227
+ return {
228
+ id: node.id,
229
+ label: node.label,
230
+ ...(labelItalic ? { labelItalic: true } : {}),
231
+ value: node.value,
232
+ color,
233
+ };
213
234
  }
214
235
 
215
236
  function visitLeaves(nodes: NumericLayoutNode[], visit: (leaf: NumericLayoutNode) => void): void {
@@ -523,13 +544,24 @@ export default defineComponent({
523
544
  >
524
545
  {head ? (
525
546
  <span class="hk-treemap__group-head">
526
- <span class="hk-treemap__label hk-treemap__label--group">{label}</span>
547
+ <span
548
+ class={{
549
+ "hk-treemap__label": true,
550
+ "hk-treemap__label--group": true,
551
+ "hk-treemap__label--unknown": node.labelItalic,
552
+ }}
553
+ >{label}</span>
527
554
  {meta && <span class="hk-treemap__meta">{meta}</span>}
528
555
  </span>
529
556
  ) : (
530
557
  label && (
531
558
  <span class="hk-treemap__leaf-body">
532
- <span class="hk-treemap__label">{label}</span>
559
+ <span
560
+ class={{
561
+ "hk-treemap__label": true,
562
+ "hk-treemap__label--unknown": node.labelItalic,
563
+ }}
564
+ >{label}</span>
533
565
  {meta && <span class="hk-treemap__meta">{meta}</span>}
534
566
  </span>
535
567
  )
@@ -558,7 +590,12 @@ export default defineComponent({
558
590
  style={{ backgroundColor: node.color }}
559
591
  aria-hidden="true"
560
592
  />
561
- <span class="hk-treemap__legend-label">{node.label}</span>
593
+ <span
594
+ class={{
595
+ "hk-treemap__legend-label": true,
596
+ "hk-treemap__label--unknown": node.labelItalic,
597
+ }}
598
+ >{node.label}</span>
562
599
  <span class="hk-treemap__legend-value">{valueText}</span>
563
600
  <span class="hk-treemap__legend-percent">{percentText}</span>
564
601
  </li>
@@ -1,7 +1,7 @@
1
- /* HkTreemapStage — the interactive treemap surface: a header with the
2
- * back button + drill path, the pan/zoom cell canvas (the cells reuse
3
- * HkTreemap's chrome classes verbatim — theme mortar, ellipsis labels),
4
- * and the collapsible HkMinimap pinned bottom-right. */
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
- expect(mounted.container.textContent).toContain("Usage"); // the root crumb
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("changing the data resets the drill stack and the camera", async () => {
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
- /** One step of the drill path: the node plus its layout at that level. */
25
- interface DrillStep {
26
- /** The node id at this level ("root" for the top). */
27
- id: string;
28
- label: string;
29
- /** The layout nodes visible at this step (root's children, or the drilled
30
- * group's children). */
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
- * 节"). The header carries a BACK button (ChevronLeft, the family icon
42
- * chrome) plus the drill path; clicking an earlier hop pops back to it.
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 stack ───────────────────────────────────────────────
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 drill = ref<DrillStep[]>([]);
167
- const current = computed<DrillStep>(() => {
168
- const step = drill.value[drill.value.length - 1];
169
- if (step) return step;
170
- return { id: "root", label: "", siblings: layout.value.nodes };
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
- drill.value = [
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
- drill.value = drill.value.slice(0, index);
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, current], measure);
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(current.value.siblings, 0, 0, dims.w, dims.h);
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
- {drill.value.length > 0 ? (
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
- {drill.value.map((step, i) => (
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 === drill.value.length - 1 || undefined}
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 class="hk-treemap__label hk-treemap__label--group">
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 class="hk-treemap__label">{node.label}</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": "中國澳門",