@celestia-island/hikari 0.55.211 → 0.55.212
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/HkMinimap.collapse.test.tsx +149 -0
- package/src/components/HkMinimap.scss +34 -0
- package/src/components/HkMinimap.tsx +70 -18
- package/src/components/HkTreemapStage.scss +114 -0
- package/src/components/HkTreemapStage.test.tsx +283 -0
- package/src/components/HkTreemapStage.tsx +382 -0
- 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/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkMinimap from "./HkMinimap";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mount(props: Record<string, unknown>) {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({ render: () => h(HkMinimap, props) });
|
|
12
|
+
app.mount(container);
|
|
13
|
+
mounts.push({ app, container });
|
|
14
|
+
return container;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
19
|
+
app.unmount();
|
|
20
|
+
container.remove();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const BOXES = [
|
|
25
|
+
{ id: "a", bounds: { x: 0, y: 0, w: 10, h: 10 }, color: "#f00" },
|
|
26
|
+
{ id: "b", bounds: { x: 10, y: 0, w: 10, h: 10 }, color: "#0f0" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
describe("HkMinimap collapse (user direction 2026-10-27)", () => {
|
|
30
|
+
it("a non-collapsible minimap renders the card and NO minimize button", () => {
|
|
31
|
+
const c = mount({ boxes: BOXES });
|
|
32
|
+
expect(c.querySelector(".hk-minimap--collapsed")).toBeNull();
|
|
33
|
+
expect(c.querySelector(".hk-mm-collapse-btn")).toBeNull();
|
|
34
|
+
expect(c.querySelector("svg.hk-minimap-svg")).toBeTruthy();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("collapsible renders the minimize button in the zoom bar", () => {
|
|
38
|
+
const c = mount({ boxes: BOXES, collapsible: true });
|
|
39
|
+
expect(c.querySelector(".hk-mm-collapse-btn")).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("collapsing swaps the card for the semi-transparent map-icon button", async () => {
|
|
43
|
+
const c = mount({ boxes: BOXES, collapsible: true });
|
|
44
|
+
(c.querySelector(".hk-mm-collapse-btn") as HTMLElement).click();
|
|
45
|
+
await nextTick();
|
|
46
|
+
expect(c.querySelector("svg.hk-minimap-svg")).toBeNull();
|
|
47
|
+
const glyph = c.querySelector<HTMLButtonElement>(".hk-minimap--collapsed");
|
|
48
|
+
expect(glyph).toBeTruthy();
|
|
49
|
+
// The collapsed glyph announces itself and is translucent at rest.
|
|
50
|
+
expect(glyph!.getAttribute("aria-label")).toBe("Show minimap");
|
|
51
|
+
expect(glyph!.className).toContain("hk-minimap--collapsed");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("clicking the collapsed glyph expands the card again (uncontrolled)", async () => {
|
|
55
|
+
const c = mount({ boxes: BOXES, collapsible: true });
|
|
56
|
+
(c.querySelector(".hk-mm-collapse-btn") as HTMLElement).click();
|
|
57
|
+
await nextTick();
|
|
58
|
+
(c.querySelector(".hk-minimap--collapsed") as HTMLElement).click();
|
|
59
|
+
await nextTick();
|
|
60
|
+
expect(c.querySelector("svg.hk-minimap-svg")).toBeTruthy();
|
|
61
|
+
expect(c.querySelector(".hk-minimap--collapsed")).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("the controlled mode honours the collapsed prop and emits updates", async () => {
|
|
65
|
+
const seen: boolean[] = [];
|
|
66
|
+
const container = document.createElement("div");
|
|
67
|
+
document.body.appendChild(container);
|
|
68
|
+
const app = createApp({
|
|
69
|
+
data: () => ({ collapsed: false }),
|
|
70
|
+
render() {
|
|
71
|
+
return h(HkMinimap, {
|
|
72
|
+
boxes: BOXES,
|
|
73
|
+
collapsible: true,
|
|
74
|
+
collapsed: (this as { collapsed: boolean }).collapsed,
|
|
75
|
+
"onUpdate:collapsed": (v: boolean) => {
|
|
76
|
+
seen.push(v);
|
|
77
|
+
(this as { collapsed: boolean }).collapsed = v;
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
app.mount(container);
|
|
83
|
+
mounts.push({ app, container });
|
|
84
|
+
|
|
85
|
+
(container.querySelector(".hk-mm-collapse-btn") as HTMLElement).click();
|
|
86
|
+
await nextTick();
|
|
87
|
+
expect(seen).toEqual([true]);
|
|
88
|
+
// The controlled prop drove the swap.
|
|
89
|
+
expect(container.querySelector(".hk-minimap--collapsed")).toBeTruthy();
|
|
90
|
+
|
|
91
|
+
(container.querySelector(".hk-minimap--collapsed") as HTMLElement).click();
|
|
92
|
+
await nextTick();
|
|
93
|
+
expect(seen).toEqual([true, false]);
|
|
94
|
+
expect(container.querySelector("svg.hk-minimap-svg")).toBeTruthy();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("drag-to-pan SURVIVES a collapse→expand cycle (the R2/R3 S1)", async () => {
|
|
98
|
+
// The old imperative listeners bound once at mount to the mount-time
|
|
99
|
+
// element; the collapsed swap replaced the node and the re-expanded
|
|
100
|
+
// card never got them back. The handlers now ride the vnodes, so the
|
|
101
|
+
// gesture works on whatever element renders.
|
|
102
|
+
const c = mount({ boxes: BOXES, collapsible: true });
|
|
103
|
+
const card = c.querySelector(".hk-minimap") as HTMLElement;
|
|
104
|
+
const before = c.querySelector("svg.hk-minimap-svg")!;
|
|
105
|
+
card.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 10, clientY: 10 }));
|
|
106
|
+
card.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 20, clientY: 14 }));
|
|
107
|
+
card.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
108
|
+
expect(c.querySelector(".hk-minimap")).toBeTruthy(); // gesture ran
|
|
109
|
+
void before;
|
|
110
|
+
// Round-trip: collapse then expand.
|
|
111
|
+
(c.querySelector(".hk-mm-collapse-btn") as HTMLElement).click();
|
|
112
|
+
await nextTick();
|
|
113
|
+
(c.querySelector(".hk-minimap--collapsed") as HTMLElement).click();
|
|
114
|
+
await nextTick();
|
|
115
|
+
// The re-expanded card is a NEW element — the drag handlers must be
|
|
116
|
+
// on IT. A pointer sequence must reach the pan machinery: assert via
|
|
117
|
+
// the dragging data flag, which only onDown sets.
|
|
118
|
+
const card2 = c.querySelector(".hk-minimap") as HTMLElement;
|
|
119
|
+
expect(card2).not.toBe(card); // the node really was replaced
|
|
120
|
+
card2.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 5, clientY: 5 }));
|
|
121
|
+
await nextTick(); // the data flag renders on the next patch
|
|
122
|
+
expect(card2.getAttribute("data-dragging")).toBe("");
|
|
123
|
+
card2.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
124
|
+
await nextTick();
|
|
125
|
+
expect(card2.getAttribute("data-dragging")).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("the collapsed glyph is a button, never a drag surface (delta F2)", async () => {
|
|
129
|
+
const c = mount({ boxes: BOXES, collapsible: true, collapsed: true });
|
|
130
|
+
const glyph = c.querySelector(".hk-minimap--collapsed") as HTMLElement;
|
|
131
|
+
glyph.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 5, clientY: 5 }));
|
|
132
|
+
glyph.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 25, clientY: 20 }));
|
|
133
|
+
await nextTick();
|
|
134
|
+
// The drag machinery never engaged: the swap element carries no
|
|
135
|
+
// data-dragging and a later expand starts clean.
|
|
136
|
+
expect(glyph.getAttribute("data-dragging")).toBeNull();
|
|
137
|
+
glyph.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
138
|
+
glyph.click();
|
|
139
|
+
await nextTick();
|
|
140
|
+
const card = c.querySelector(".hk-minimap") as HTMLElement;
|
|
141
|
+
expect(card.getAttribute("data-dragging")).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("a controlled `collapsed: true` starts collapsed even without a click", () => {
|
|
145
|
+
const c = mount({ boxes: BOXES, collapsible: true, collapsed: true });
|
|
146
|
+
expect(c.querySelector(".hk-minimap--collapsed")).toBeTruthy();
|
|
147
|
+
expect(c.querySelector("svg.hk-minimap-svg")).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -149,3 +149,37 @@
|
|
|
149
149
|
font-variant-numeric: tabular-nums;
|
|
150
150
|
color: rgb(var(--color-muted));
|
|
151
151
|
}
|
|
152
|
+
|
|
153
|
+
/* ── The collapsed affordance (user direction 2026-10-27) ─────────────
|
|
154
|
+
* The card folds down to a small map-icon button pinned where the card
|
|
155
|
+
* sat. It is deliberately SEMI-TRANSPARENT at rest — collapsed means
|
|
156
|
+
* "out of the way" and it must not compete with the content underneath —
|
|
157
|
+
* and only reaches full opacity on hover. */
|
|
158
|
+
.hk-minimap--collapsed {
|
|
159
|
+
display: inline-flex;
|
|
160
|
+
align-items: center;
|
|
161
|
+
justify-content: center;
|
|
162
|
+
width: 30px;
|
|
163
|
+
height: 30px;
|
|
164
|
+
padding: 0;
|
|
165
|
+
cursor: pointer;
|
|
166
|
+
color: rgb(var(--color-text) / 70%);
|
|
167
|
+
background: rgb(var(--color-surface) / 0.55);
|
|
168
|
+
border: 1px solid var(--border-faint);
|
|
169
|
+
border-radius: var(--radius-sm);
|
|
170
|
+
opacity: 0.55;
|
|
171
|
+
transition: opacity var(--duration-short, 0.15s), background var(--duration-short, 0.15s);
|
|
172
|
+
|
|
173
|
+
&:hover,
|
|
174
|
+
&:focus-visible {
|
|
175
|
+
opacity: 1;
|
|
176
|
+
background: rgb(var(--color-surface) / 0.9);
|
|
177
|
+
color: rgb(var(--color-text));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* The minimize button rides the zoom bar's far end — visually set
|
|
182
|
+
* apart from the zoom pair it does not belong to. */
|
|
183
|
+
.hk-mm-collapse-btn {
|
|
184
|
+
margin-inline-start: auto;
|
|
185
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Maximize2, ZoomIn, ZoomOut } from "lucide-vue-next";
|
|
2
|
-
import { computed, defineComponent, onBeforeUnmount,
|
|
1
|
+
import { Map as MapIcon, Maximize2, Minimize2, ZoomIn, ZoomOut } from "lucide-vue-next";
|
|
2
|
+
import { computed, defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
|
|
3
3
|
|
|
4
4
|
import HSlider from "./HkSlider";
|
|
5
5
|
|
|
@@ -88,6 +88,14 @@ export default defineComponent({
|
|
|
88
88
|
* visible; the map is expected to reappear once it has something to
|
|
89
89
|
* say (the viewer switches this off when zoomed past fit). */
|
|
90
90
|
toolbarOnly: { type: Boolean, default: false },
|
|
91
|
+
/** Enable the collapse affordance (user direction 2026-10-27): the
|
|
92
|
+
* card grows a minimize button in its zoom bar, and the collapsed
|
|
93
|
+
* state renders as the small semi-transparent map-icon button
|
|
94
|
+
* (fully opaque on hover). */
|
|
95
|
+
collapsible: { type: Boolean, default: false },
|
|
96
|
+
/** The collapsed state (v-model:collapsed). `undefined` = uncontrolled
|
|
97
|
+
* (the component starts expanded and owns the flag). */
|
|
98
|
+
collapsed: { type: Boolean, default: undefined },
|
|
91
99
|
/** Optional prop-callback surface (alternative to the emits). */
|
|
92
100
|
onZoomTo: {
|
|
93
101
|
type: Function as PropType<(percent: number, source?: "slider" | "step") => void>,
|
|
@@ -97,6 +105,8 @@ export default defineComponent({
|
|
|
97
105
|
onPanDelta: { type: Function as PropType<(dx: number, dy: number) => void>, default: undefined },
|
|
98
106
|
},
|
|
99
107
|
emits: {
|
|
108
|
+
/** Collapse toggle (only emitted when `collapsible`). */
|
|
109
|
+
"update:collapsed": (_collapsed: boolean) => true,
|
|
100
110
|
/** Zoom request carrying the clamped target percent plus the gesture
|
|
101
111
|
* source — `"step"` (± buttons, relative one-rung request) vs
|
|
102
112
|
* `"slider"` (dropdown slider, ABSOLUTE target rung). */
|
|
@@ -109,9 +119,23 @@ export default defineComponent({
|
|
|
109
119
|
const svgW = 160;
|
|
110
120
|
const svgH = 110;
|
|
111
121
|
const rootRef = ref<HTMLElement | null>(null);
|
|
122
|
+
const internalCollapsed = ref(false);
|
|
123
|
+
const sliderOpen = ref(false);
|
|
124
|
+
const isCollapsed = computed(() =>
|
|
125
|
+
props.collapsed !== undefined ? props.collapsed : internalCollapsed.value);
|
|
126
|
+
function toggleCollapsed(next: boolean): void {
|
|
127
|
+
internalCollapsed.value = next;
|
|
128
|
+
if (next) {
|
|
129
|
+
sliderOpen.value = false;
|
|
130
|
+
// A mid-drag collapse swaps the element: no pointerup can reach
|
|
131
|
+
// the new root, so end the drag HERE or unpressed hovers would
|
|
132
|
+
// keep panning (delta S3 F4).
|
|
133
|
+
dragging.value = false;
|
|
134
|
+
}
|
|
135
|
+
emit("update:collapsed", next);
|
|
136
|
+
}
|
|
112
137
|
const dragging = ref(false);
|
|
113
138
|
const dragStart = ref({ x: 0, y: 0 });
|
|
114
|
-
const sliderOpen = ref(false);
|
|
115
139
|
|
|
116
140
|
const cb = computed(() => props.contentBounds);
|
|
117
141
|
const overpanW = computed(() => Math.max(props.contentBounds.w * 0.5, props.viewportWidth * 0.3));
|
|
@@ -166,6 +190,11 @@ export default defineComponent({
|
|
|
166
190
|
}
|
|
167
191
|
|
|
168
192
|
function onDown(e: PointerEvent) {
|
|
193
|
+
// The collapsed glyph is a plain button, never a drag surface: its
|
|
194
|
+
// only svg is the 16px lucide icon, which onMove's unit conversion
|
|
195
|
+
// would measure as a ~10x-amplified map (delta S2 F2 — a sloppy
|
|
196
|
+
// press-move on the default-state control jolted the camera).
|
|
197
|
+
if (isCollapsed.value) return;
|
|
169
198
|
const target = e.target as HTMLElement;
|
|
170
199
|
// Zoom chrome (bar + slider pop) must never start a map drag — the
|
|
171
200
|
// root's setPointerCapture would swallow the slider's gestures.
|
|
@@ -229,22 +258,7 @@ export default defineComponent({
|
|
|
229
258
|
}
|
|
230
259
|
});
|
|
231
260
|
|
|
232
|
-
onMounted(() => {
|
|
233
|
-
const el = rootRef.value;
|
|
234
|
-
if (!el) return;
|
|
235
|
-
el.addEventListener("pointerdown", onDown);
|
|
236
|
-
el.addEventListener("pointermove", onMove);
|
|
237
|
-
el.addEventListener("pointerup", onUp);
|
|
238
|
-
el.addEventListener("pointercancel", onUp);
|
|
239
|
-
});
|
|
240
261
|
onBeforeUnmount(() => {
|
|
241
|
-
const el = rootRef.value;
|
|
242
|
-
if (el) {
|
|
243
|
-
el.removeEventListener("pointerdown", onDown);
|
|
244
|
-
el.removeEventListener("pointermove", onMove);
|
|
245
|
-
el.removeEventListener("pointerup", onUp);
|
|
246
|
-
el.removeEventListener("pointercancel", onUp);
|
|
247
|
-
}
|
|
248
262
|
document.removeEventListener("pointerdown", onDocPointerDown, true);
|
|
249
263
|
document.removeEventListener("keydown", onDocKeydown, true);
|
|
250
264
|
});
|
|
@@ -279,12 +293,39 @@ export default defineComponent({
|
|
|
279
293
|
|
|
280
294
|
const hubP = props.hubPos ? toMap(props.hubPos.x, props.hubPos.y) : null;
|
|
281
295
|
|
|
296
|
+
if (isCollapsed.value) {
|
|
297
|
+
// The collapsed affordance: a small map-icon button pinned where
|
|
298
|
+
// the card sits — semi-transparent until hovered (it must not
|
|
299
|
+
// compete with the content underneath), fully opaque on hover.
|
|
300
|
+
return (
|
|
301
|
+
<button
|
|
302
|
+
ref={rootRef}
|
|
303
|
+
type="button"
|
|
304
|
+
class="hk-minimap hk-minimap--collapsed"
|
|
305
|
+
aria-label={t("hikari::zoomToolbar.expandMap", "Show minimap")}
|
|
306
|
+
title={t("hikari::zoomToolbar.expandMap", "Show minimap")}
|
|
307
|
+
onClick={() => toggleCollapsed(false)}
|
|
308
|
+
onPointerdown={onDown}
|
|
309
|
+
onPointermove={onMove}
|
|
310
|
+
onPointerup={onUp}
|
|
311
|
+
onPointercancel={onUp}
|
|
312
|
+
>
|
|
313
|
+
<MapIcon size={16} />
|
|
314
|
+
</button>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
282
318
|
return (
|
|
283
319
|
<div
|
|
284
320
|
ref={rootRef}
|
|
285
321
|
class="hk-minimap"
|
|
286
322
|
data-dragging={dragging.value ? "" : undefined}
|
|
287
323
|
data-toolbar-only={props.toolbarOnly ? "" : undefined}
|
|
324
|
+
data-collapsible={props.collapsible ? "" : undefined}
|
|
325
|
+
onPointerdown={onDown}
|
|
326
|
+
onPointermove={onMove}
|
|
327
|
+
onPointerup={onUp}
|
|
328
|
+
onPointercancel={onUp}
|
|
288
329
|
>
|
|
289
330
|
{!props.toolbarOnly && (
|
|
290
331
|
<svg viewBox={`0 0 ${svgW} ${svgH}`} width={svgW} height={svgH} class="hk-minimap-svg">
|
|
@@ -369,6 +410,17 @@ export default defineComponent({
|
|
|
369
410
|
<Maximize2 size={11} />
|
|
370
411
|
</button>
|
|
371
412
|
)}
|
|
413
|
+
{props.collapsible && (
|
|
414
|
+
<button
|
|
415
|
+
class="hk-mm-zoom-btn hk-mm-collapse-btn"
|
|
416
|
+
type="button"
|
|
417
|
+
onClick={() => toggleCollapsed(true)}
|
|
418
|
+
aria-label={t("hikari::zoomToolbar.collapseMap", "Hide minimap")}
|
|
419
|
+
title={t("hikari::zoomToolbar.collapseMap", "Hide minimap")}
|
|
420
|
+
>
|
|
421
|
+
<Minimize2 size={11} />
|
|
422
|
+
</button>
|
|
423
|
+
)}
|
|
372
424
|
</div>
|
|
373
425
|
{sliderOpen.value && (
|
|
374
426
|
<div class="hk-mm-zoom-pop">
|
|
@@ -0,0 +1,114 @@
|
|
|
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. */
|
|
5
|
+
|
|
6
|
+
.hk-treemap-stage {
|
|
7
|
+
position: relative;
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
width: 100%;
|
|
11
|
+
height: 100%;
|
|
12
|
+
min-width: 0;
|
|
13
|
+
min-height: 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.hk-treemap-stage__head {
|
|
17
|
+
display: flex;
|
|
18
|
+
flex: 0 0 auto;
|
|
19
|
+
align-items: center;
|
|
20
|
+
gap: var(--space-6, 6px);
|
|
21
|
+
padding: var(--space-4, 4px) var(--space-8, 8px);
|
|
22
|
+
min-height: 32px;
|
|
23
|
+
border-bottom: 1px solid var(--border-faint);
|
|
24
|
+
}
|
|
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
|
+
.hk-treemap-stage__path {
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
min-width: 0;
|
|
36
|
+
gap: var(--space-4, 4px);
|
|
37
|
+
font-size: var(--text-xs, 0.75rem);
|
|
38
|
+
overflow: hidden;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.hk-treemap-stage__crumb {
|
|
42
|
+
appearance: none;
|
|
43
|
+
border: 0;
|
|
44
|
+
padding: 2px 4px;
|
|
45
|
+
background: transparent;
|
|
46
|
+
color: rgb(var(--color-muted));
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
border-radius: var(--radius-xs, 3px);
|
|
49
|
+
white-space: nowrap;
|
|
50
|
+
overflow: hidden;
|
|
51
|
+
text-overflow: ellipsis;
|
|
52
|
+
max-width: 12rem;
|
|
53
|
+
|
|
54
|
+
&:hover {
|
|
55
|
+
color: rgb(var(--color-text));
|
|
56
|
+
background: rgb(var(--color-primary) / 6%);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
&[data-last] {
|
|
60
|
+
color: rgb(var(--color-text));
|
|
61
|
+
font-weight: 600;
|
|
62
|
+
cursor: default;
|
|
63
|
+
|
|
64
|
+
&:hover {
|
|
65
|
+
background: transparent;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.hk-treemap-stage__crumb-seg {
|
|
71
|
+
display: inline-flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
min-width: 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.hk-treemap-stage__crumb-sep {
|
|
77
|
+
color: rgb(var(--color-text) / 35%);
|
|
78
|
+
flex: 0 0 auto;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.hk-treemap-stage__body {
|
|
82
|
+
position: relative;
|
|
83
|
+
flex: 1 1 auto;
|
|
84
|
+
min-height: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.hk-treemap-stage__canvas {
|
|
88
|
+
position: absolute;
|
|
89
|
+
inset: 0;
|
|
90
|
+
overflow: hidden;
|
|
91
|
+
background: rgb(var(--color-muted, 108 108 108) / 12%);
|
|
92
|
+
border-radius: var(--radius-sm, 4px);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.hk-treemap-stage__surface {
|
|
96
|
+
position: absolute;
|
|
97
|
+
inset: 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Cells reuse .hk-treemap__cell chrome from HkTreemap.scss; the stage
|
|
101
|
+
* adds the drill affordance on group cells. */
|
|
102
|
+
.hk-treemap-stage__cell--drillable {
|
|
103
|
+
cursor: zoom-in;
|
|
104
|
+
|
|
105
|
+
&:hover {
|
|
106
|
+
outline: 2px solid rgb(var(--color-primary) / 70%);
|
|
107
|
+
outline-offset: -2px;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
&:focus-visible {
|
|
111
|
+
outline: 2px solid rgb(var(--color-primary));
|
|
112
|
+
outline-offset: -2px;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
|
3
|
+
|
|
4
|
+
// Same stub pattern as HkTreemap.test.tsx: assert the tooltip wiring
|
|
5
|
+
// without the popup manager.
|
|
6
|
+
vi.mock("./HkTooltip", () => ({
|
|
7
|
+
default: {
|
|
8
|
+
name: "HkTooltipStub",
|
|
9
|
+
props: { text: { type: String, required: true } },
|
|
10
|
+
setup(props: { text: string }, { slots }: { slots: { default?: (p: unknown) => unknown } }) {
|
|
11
|
+
return () => h("span", { "data-hk-tooltip-text": props.text }, slots.default?.({}) as never);
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import HkTreemapStage from "./HkTreemapStage";
|
|
17
|
+
import type { HkTreemapNode } from "./HkTreemap";
|
|
18
|
+
|
|
19
|
+
class FakeResizeObserver {
|
|
20
|
+
static instances: FakeResizeObserver[] = [];
|
|
21
|
+
readonly observed: Element[] = [];
|
|
22
|
+
disconnected = false;
|
|
23
|
+
constructor(readonly callback: () => void) {
|
|
24
|
+
FakeResizeObserver.instances.push(this);
|
|
25
|
+
}
|
|
26
|
+
observe(element: Element): void {
|
|
27
|
+
this.observed.push(element);
|
|
28
|
+
}
|
|
29
|
+
disconnect(): void {
|
|
30
|
+
this.disconnected = true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
35
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
36
|
+
|
|
37
|
+
const NODES: HkTreemapNode[] = [
|
|
38
|
+
{
|
|
39
|
+
id: "group",
|
|
40
|
+
label: "Group",
|
|
41
|
+
value: 0,
|
|
42
|
+
children: [
|
|
43
|
+
{ id: "a", label: "Alpha", value: 2 },
|
|
44
|
+
{ id: "b", label: "Beta", value: 4 },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{ id: "other", label: "Other", value: 4 },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function fakeRect(width: number, height: number): DOMRect {
|
|
51
|
+
return {
|
|
52
|
+
x: 0, y: 0, top: 0, left: 0, right: width, bottom: height,
|
|
53
|
+
width, height, toJSON: () => ({}),
|
|
54
|
+
} as DOMRect;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mountStage(initialProps: Record<string, unknown> = { nodes: NODES }) {
|
|
58
|
+
const props = reactive<Record<string, unknown>>({ ...initialProps });
|
|
59
|
+
const container = document.createElement("div");
|
|
60
|
+
document.body.appendChild(container);
|
|
61
|
+
const app = createApp(
|
|
62
|
+
defineComponent({
|
|
63
|
+
setup() {
|
|
64
|
+
return () => h(HkTreemapStage, { ...props });
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
app.mount(container);
|
|
69
|
+
const mounted = { app, container };
|
|
70
|
+
mounts.push(mounted);
|
|
71
|
+
return {
|
|
72
|
+
...mounted,
|
|
73
|
+
update(next: Record<string, unknown>) {
|
|
74
|
+
Object.assign(props, next);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function setSize(mounted: { container: HTMLElement }, width = 400, height = 240) {
|
|
80
|
+
const el = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__canvas")!;
|
|
81
|
+
el.getBoundingClientRect = () => fakeRect(width, height);
|
|
82
|
+
Object.defineProperty(el, "clientWidth", { value: width, configurable: true });
|
|
83
|
+
Object.defineProperty(el, "clientHeight", { value: height, configurable: true });
|
|
84
|
+
FakeResizeObserver.instances[0]?.callback();
|
|
85
|
+
await nextTick();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
vi.clearAllMocks();
|
|
90
|
+
FakeResizeObserver.instances = [];
|
|
91
|
+
globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
for (const mounted of mounts.splice(0)) {
|
|
96
|
+
mounted.app.unmount();
|
|
97
|
+
mounted.container.remove();
|
|
98
|
+
}
|
|
99
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("HkTreemapStage — the drill path", () => {
|
|
103
|
+
it("renders the root level cells with tooltips and no back button at root", async () => {
|
|
104
|
+
const mounted = mountStage({ nodes: NODES, ariaLabel: "Usage" });
|
|
105
|
+
await setSize(mounted);
|
|
106
|
+
const cells = mounted.container.querySelectorAll(".hk-treemap-stage__cell");
|
|
107
|
+
// Root level renders TOP-LEVEL cells only — a group is ONE drillable
|
|
108
|
+
// cell by design (click to enter): group + other = 2.
|
|
109
|
+
expect(cells.length).toBe(2);
|
|
110
|
+
expect(mounted.container.querySelector('[aria-label="Back"]')).toBeNull();
|
|
111
|
+
const tooltips = [...mounted.container.querySelectorAll("[data-hk-tooltip-text]")].map(
|
|
112
|
+
(n) => n.getAttribute("data-hk-tooltip-text"),
|
|
113
|
+
);
|
|
114
|
+
expect(tooltips.some((t) => t!.startsWith("Group:"))).toBe(true);
|
|
115
|
+
expect(mounted.container.textContent).toContain("Usage"); // the root crumb
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("clicking a GROUP cell drills in: children fill the canvas, back button appears", async () => {
|
|
119
|
+
const mounted = mountStage({ nodes: NODES });
|
|
120
|
+
await setSize(mounted);
|
|
121
|
+
const group = [...mounted.container.querySelectorAll(".hk-treemap-stage__cell--drillable")]
|
|
122
|
+
.find((c) => (c.getAttribute("aria-label") ?? "").startsWith("Group:")) as HTMLElement;
|
|
123
|
+
expect(group).toBeTruthy();
|
|
124
|
+
group.click();
|
|
125
|
+
await nextTick();
|
|
126
|
+
// Back button + the drilled crumb appear.
|
|
127
|
+
expect(mounted.container.querySelector('[aria-label="Back"]')).toBeTruthy();
|
|
128
|
+
expect(mounted.container.textContent).toContain("Group");
|
|
129
|
+
// The children now lay out at the full canvas: both leaves present.
|
|
130
|
+
const labels = [...mounted.container.querySelectorAll(".hk-treemap__label")].map(
|
|
131
|
+
(n) => n.textContent,
|
|
132
|
+
);
|
|
133
|
+
expect(labels).toContain("Alpha");
|
|
134
|
+
expect(labels).toContain("Beta");
|
|
135
|
+
// "Other" is gone at this level.
|
|
136
|
+
expect(labels).not.toContain("Other");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("the back button pops one level; the root crumb pops all the way", async () => {
|
|
140
|
+
const mounted = mountStage({ nodes: NODES });
|
|
141
|
+
await setSize(mounted);
|
|
142
|
+
const group = [...mounted.container.querySelectorAll(".hk-treemap-stage__cell--drillable")]
|
|
143
|
+
.find((c) => (c.getAttribute("aria-label") ?? "").startsWith("Group:")) as HTMLElement;
|
|
144
|
+
group.click();
|
|
145
|
+
await nextTick();
|
|
146
|
+
(mounted.container.querySelector('[aria-label="Back"]') as HTMLElement).click();
|
|
147
|
+
await nextTick();
|
|
148
|
+
expect(mounted.container.querySelector('[aria-label="Back"]')).toBeNull();
|
|
149
|
+
// Back at root: "Other" is back.
|
|
150
|
+
expect(mounted.container.textContent).toContain("Other");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("clicking a LEAF emits select, never drills", async () => {
|
|
154
|
+
const mounted = mountStage({ nodes: [{ id: "leaf", label: "Leaf", value: 1 }] });
|
|
155
|
+
await setSize(mounted);
|
|
156
|
+
const seen: string[] = [];
|
|
157
|
+
// Re-mount with the listener (simpler than reactive fn props here).
|
|
158
|
+
mounted.app.unmount();
|
|
159
|
+
mounts.splice(mounts.indexOf(mounted), 1);
|
|
160
|
+
const container = document.createElement("div");
|
|
161
|
+
document.body.appendChild(container);
|
|
162
|
+
const app = createApp({
|
|
163
|
+
render: () =>
|
|
164
|
+
h(HkTreemapStage, {
|
|
165
|
+
nodes: [{ id: "leaf", label: "Leaf", value: 1 }],
|
|
166
|
+
onSelect: (id: string) => seen.push(id),
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
app.mount(container);
|
|
170
|
+
mounts.push({ app, container });
|
|
171
|
+
const el = container.querySelector<HTMLElement>(".hk-treemap-stage__canvas")!;
|
|
172
|
+
el.getBoundingClientRect = () => fakeRect(400, 240);
|
|
173
|
+
Object.defineProperty(el, "clientWidth", { value: 400, configurable: true });
|
|
174
|
+
Object.defineProperty(el, "clientHeight", { value: 240, configurable: true });
|
|
175
|
+
// The re-mount's observer is the LAST instance (the first app's still
|
|
176
|
+
// sits in the array, disconnected).
|
|
177
|
+
FakeResizeObserver.instances[FakeResizeObserver.instances.length - 1]?.callback();
|
|
178
|
+
await nextTick();
|
|
179
|
+
const leaf = container.querySelector(".hk-treemap-stage__cell") as HTMLElement;
|
|
180
|
+
leaf.click();
|
|
181
|
+
await nextTick();
|
|
182
|
+
expect(seen).toEqual(["leaf"]);
|
|
183
|
+
expect(container.querySelector('[aria-label="Back"]')).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("changing the data resets the drill stack and the camera", async () => {
|
|
187
|
+
const mounted = mountStage({ nodes: NODES });
|
|
188
|
+
await setSize(mounted);
|
|
189
|
+
const group = [...mounted.container.querySelectorAll(".hk-treemap-stage__cell--drillable")]
|
|
190
|
+
.find((c) => (c.getAttribute("aria-label") ?? "").startsWith("Group:")) as HTMLElement;
|
|
191
|
+
group.click();
|
|
192
|
+
await nextTick();
|
|
193
|
+
expect(mounted.container.querySelector('[aria-label="Back"]')).toBeTruthy();
|
|
194
|
+
|
|
195
|
+
mounted.update({ nodes: [{ id: "fresh", label: "Fresh", value: 3 }] });
|
|
196
|
+
await nextTick();
|
|
197
|
+
expect(mounted.container.querySelector('[aria-label="Back"]')).toBeNull();
|
|
198
|
+
expect(mounted.container.textContent).toContain("Fresh");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("HkTreemapStage — the camera + minimap", () => {
|
|
203
|
+
it("the minimap defaults COLLAPSED to the semi-transparent map-icon button", async () => {
|
|
204
|
+
const mounted = mountStage({ nodes: NODES });
|
|
205
|
+
await setSize(mounted);
|
|
206
|
+
expect(mounted.container.querySelector(".hk-minimap--collapsed")).toBeTruthy();
|
|
207
|
+
expect(mounted.container.querySelector(".hk-mm-zoom-bar")).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("expanding reveals the zoom bar; a step zoom emits and scales the surface", async () => {
|
|
211
|
+
const mounted = mountStage({ nodes: NODES });
|
|
212
|
+
await setSize(mounted);
|
|
213
|
+
(mounted.container.querySelector(".hk-minimap--collapsed") as HTMLElement).click();
|
|
214
|
+
await nextTick();
|
|
215
|
+
const zoomIn = [...mounted.container.querySelectorAll(".hk-mm-zoom-btn")]
|
|
216
|
+
.filter((b) => b.getAttribute("aria-label") === "Zoom in")[0] as HTMLElement;
|
|
217
|
+
expect(zoomIn).toBeTruthy();
|
|
218
|
+
zoomIn.click();
|
|
219
|
+
await nextTick();
|
|
220
|
+
const surface = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__surface")!;
|
|
221
|
+
expect(surface.style.transform).toContain("scale(1.1)");
|
|
222
|
+
// The zoom label reads the new rung.
|
|
223
|
+
expect(mounted.container.querySelector(".hk-mm-zoom-label")!.textContent).toContain("110");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("zoom reveals detail: a small cell's label appears at a higher rung", async () => {
|
|
227
|
+
// One dominant leaf + crumbs: at 100% the crumbs sit under the label
|
|
228
|
+
// floor; zoom in and the effective size crosses it.
|
|
229
|
+
// Shares tuned for the ladder: each crumb ≈ 15×15px at 100% (under
|
|
230
|
+
// the 18px label floor) → ≈ 30×30 at 200% (over it).
|
|
231
|
+
const crumbs: HkTreemapNode[] = Array.from({ length: 30 }, (_, i) => ({
|
|
232
|
+
id: `c${i}`,
|
|
233
|
+
label: `Crumble-${i}`,
|
|
234
|
+
value: 5,
|
|
235
|
+
}));
|
|
236
|
+
const mounted = mountStage({ nodes: [{ id: "big", label: "Big", value: 2000 }, ...crumbs] });
|
|
237
|
+
await setSize(mounted, 400, 240);
|
|
238
|
+
const labelsAt = () =>
|
|
239
|
+
[...mounted.container.querySelectorAll(".hk-treemap__label")].map((n) => n.textContent);
|
|
240
|
+
const before = labelsAt().filter((t) => t?.startsWith("Crumble-")).length;
|
|
241
|
+
(mounted.container.querySelector(".hk-minimap--collapsed") as HTMLElement).click();
|
|
242
|
+
await nextTick();
|
|
243
|
+
// Step to 200%: each crumb's ZOOMED size doubles.
|
|
244
|
+
for (let i = 0; i < 10; i += 1) {
|
|
245
|
+
const zoomIn = [...mounted.container.querySelectorAll(".hk-mm-zoom-btn")]
|
|
246
|
+
.filter((b) => b.getAttribute("aria-label") === "Zoom in")[0] as HTMLElement;
|
|
247
|
+
zoomIn.click();
|
|
248
|
+
await nextTick();
|
|
249
|
+
}
|
|
250
|
+
const after = labelsAt().filter((t) => t?.startsWith("Crumble-")).length;
|
|
251
|
+
expect(after).toBeGreaterThan(before);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("minimap drag-to-pan works in the DEFAULT collapsed→expanded flow (the R2/R3 S1)", async () => {
|
|
255
|
+
const mounted = mountStage({ nodes: NODES });
|
|
256
|
+
await setSize(mounted);
|
|
257
|
+
// Expand from the default-collapsed glyph.
|
|
258
|
+
(mounted.container.querySelector(".hk-minimap--collapsed") as HTMLElement).click();
|
|
259
|
+
await nextTick();
|
|
260
|
+
const card = mounted.container.querySelector(".hk-minimap") as HTMLElement;
|
|
261
|
+
expect(card).toBeTruthy();
|
|
262
|
+
// The old imperative binding missed the FIRST-render-null minimap
|
|
263
|
+
// entirely; with vnode-bound handlers the gesture must reach the
|
|
264
|
+
// camera: a drag on the map moves the surface transform.
|
|
265
|
+
card.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 80, clientY: 55 }));
|
|
266
|
+
await nextTick();
|
|
267
|
+
expect(card.getAttribute("data-dragging")).toBe("");
|
|
268
|
+
card.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 100, clientY: 65 }));
|
|
269
|
+
card.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
270
|
+
await nextTick();
|
|
271
|
+
const surface = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__surface")!;
|
|
272
|
+
expect(surface.style.transform).not.toBe("translate(0px, 0px) scale(1)");
|
|
273
|
+
expect(surface.style.transform).toMatch(/translate\(-?\d+/);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("an empty tree shows the empty state and no minimap", async () => {
|
|
277
|
+
const mounted = mountStage({ nodes: [], emptyText: "Nothing" });
|
|
278
|
+
await setSize(mounted);
|
|
279
|
+
expect(mounted.container.querySelector(".hk-treemap__empty")!.textContent).toBe("Nothing");
|
|
280
|
+
expect(mounted.container.querySelector(".hk-minimap")).toBeNull();
|
|
281
|
+
expect(mounted.container.querySelectorAll(".hk-treemap-stage__cell")).toHaveLength(0);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch,
|
|
2
|
+
type PropType } from "vue";
|
|
3
|
+
import { ChevronLeft } from "lucide-vue-next";
|
|
4
|
+
|
|
5
|
+
import HkIconButton from "./HkIconButton";
|
|
6
|
+
import HkMinimap, { type MinimapBox } from "./HkMinimap";
|
|
7
|
+
import HkTooltip from "./HkTooltip";
|
|
8
|
+
import {
|
|
9
|
+
buildHkTreemapLayout,
|
|
10
|
+
contrastTextColor,
|
|
11
|
+
formatHkTreemapPercent,
|
|
12
|
+
layoutHkTreemapCells,
|
|
13
|
+
type HkTreemapCell,
|
|
14
|
+
type HkTreemapLayoutNode,
|
|
15
|
+
type HkTreemapNode,
|
|
16
|
+
type HkTreemapValueFormatter,
|
|
17
|
+
} from "./HkTreemap";
|
|
18
|
+
|
|
19
|
+
import "./HkTreemapStage.scss";
|
|
20
|
+
|
|
21
|
+
const defaultValueFormatter: HkTreemapValueFormatter = (value) => String(value);
|
|
22
|
+
|
|
23
|
+
/** One step of the drill path: the node plus its layout at that level. */
|
|
24
|
+
interface DrillStep {
|
|
25
|
+
/** The node id at this level ("root" for the top). */
|
|
26
|
+
id: string;
|
|
27
|
+
label: string;
|
|
28
|
+
/** The layout nodes visible at this step (root's children, or the drilled
|
|
29
|
+
* group's children). */
|
|
30
|
+
siblings: HkTreemapLayoutNode[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* HkTreemapStage — the interactive treemap surface (user direction
|
|
35
|
+
* 2026-10-27): the block renderer you can CLICK to zoom into.
|
|
36
|
+
*
|
|
37
|
+
* - DRILL: clicking a GROUP cell zooms into it — its children re-lay out
|
|
38
|
+
* at the full canvas, so cells that were slivers reveal their labels,
|
|
39
|
+
* meta and nested structure ("部分方块在放大的时候才会显示更多的细
|
|
40
|
+
* 节"). The header carries a BACK button (ChevronLeft, the family icon
|
|
41
|
+
* chrome) plus the drill path; clicking an earlier hop pops back to it.
|
|
42
|
+
* - PAN/ZOOM: the camera scales/translates the cell layer; the ±/slider
|
|
43
|
+
* zoom bar (via the collapsible HkMinimap pinned bottom-right) steps
|
|
44
|
+
* 50–200%, and dragging the minimap pans. Larger zoom rungs enlarge
|
|
45
|
+
* cells, pushing more of them over the label/meta ladder floors.
|
|
46
|
+
* - COLLAPSE: the minimap defaults collapsed to its semi-transparent
|
|
47
|
+
* map-icon button (hover = opaque); the minimize button in its zoom
|
|
48
|
+
* bar folds it back.
|
|
49
|
+
*
|
|
50
|
+
* The cell rendering reuses HkTreemap's pure layout (squarified, theme
|
|
51
|
+
* mortar) and its HkTooltip-per-cell contract verbatim — same family, no
|
|
52
|
+
* second renderer.
|
|
53
|
+
*/
|
|
54
|
+
export default defineComponent({
|
|
55
|
+
name: "HkTreemapStage",
|
|
56
|
+
props: {
|
|
57
|
+
nodes: { type: Array as PropType<HkTreemapNode[]>, default: () => [] },
|
|
58
|
+
valueFormatter: { type: Function as PropType<HkTreemapValueFormatter>, default: undefined },
|
|
59
|
+
emptyText: { type: String, default: "No data" },
|
|
60
|
+
ariaLabel: { type: String, default: "Treemap" },
|
|
61
|
+
/** The camera's zoom percent; uncontrolled when omitted (50–200). */
|
|
62
|
+
zoomPercent: { type: Number, default: undefined },
|
|
63
|
+
/** The minimap's collapsed flag; uncontrolled when omitted (defaults
|
|
64
|
+
* collapsed — the user direction's "at a glance it stays out of the
|
|
65
|
+
* way"; expand on demand). */
|
|
66
|
+
minimapCollapsed: { type: Boolean, default: undefined },
|
|
67
|
+
},
|
|
68
|
+
emits: {
|
|
69
|
+
"update:zoomPercent": (_percent: number) => true,
|
|
70
|
+
"update:minimapCollapsed": (_collapsed: boolean) => true,
|
|
71
|
+
/** A leaf cell was activated (click). */
|
|
72
|
+
select: (_id: string) => true,
|
|
73
|
+
},
|
|
74
|
+
setup(props, { emit }) {
|
|
75
|
+
const hostRef = ref<HTMLElement | null>(null);
|
|
76
|
+
const size = ref<{ w: number; h: number } | null>(null);
|
|
77
|
+
let observer: ResizeObserver | undefined;
|
|
78
|
+
let resizeCleanup: (() => void) | undefined;
|
|
79
|
+
|
|
80
|
+
// ── the camera ────────────────────────────────────────────────────
|
|
81
|
+
const MIN_ZOOM = 50;
|
|
82
|
+
const MAX_ZOOM = 200;
|
|
83
|
+
const STEP_ZOOM = 10;
|
|
84
|
+
const internalZoom = ref(100);
|
|
85
|
+
const zoomPct = computed(() => props.zoomPercent ?? internalZoom.value);
|
|
86
|
+
const panX = ref(0);
|
|
87
|
+
const panY = ref(0);
|
|
88
|
+
|
|
89
|
+
function applyZoom(target: number): void {
|
|
90
|
+
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.round(target)));
|
|
91
|
+
// Capture the PREVIOUS rung before writing the new one — reading
|
|
92
|
+
// zoomPct after the write always yields `next` and turns the pan
|
|
93
|
+
// rescale below into a silent no-op (R2 finding).
|
|
94
|
+
const prev = zoomPct.value;
|
|
95
|
+
internalZoom.value = next;
|
|
96
|
+
emit("update:zoomPercent", next);
|
|
97
|
+
// The camera anchors the ORIGIN (transform-origin: 0 0), so a rung
|
|
98
|
+
// change rescales any accumulated pan by the same ratio to keep the
|
|
99
|
+
// content under the cursor tracking — an origin-anchored re-scale,
|
|
100
|
+
// not a centre-anchored one.
|
|
101
|
+
if (prev > 0 && next !== prev) {
|
|
102
|
+
const ratio = next / prev;
|
|
103
|
+
panX.value *= ratio;
|
|
104
|
+
panY.value *= ratio;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function applyPanDelta(dx: number, dy: number): void {
|
|
109
|
+
panX.value += dx;
|
|
110
|
+
panY.value += dy;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resetCamera(): void {
|
|
114
|
+
internalZoom.value = 100;
|
|
115
|
+
emit("update:zoomPercent", 100);
|
|
116
|
+
panX.value = 0;
|
|
117
|
+
panY.value = 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── the drill stack ───────────────────────────────────────────────
|
|
121
|
+
const layout = computed(() => buildHkTreemapLayout(props.nodes));
|
|
122
|
+
const drill = ref<DrillStep[]>([]);
|
|
123
|
+
const current = computed<DrillStep>(() => {
|
|
124
|
+
const step = drill.value[drill.value.length - 1];
|
|
125
|
+
if (step) return step;
|
|
126
|
+
return { id: "root", label: "", siblings: layout.value.nodes };
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function pushDrill(node: HkTreemapLayoutNode): void {
|
|
130
|
+
if (!node.children || node.children.length === 0) {
|
|
131
|
+
emit("select", node.id);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
drill.value = [
|
|
135
|
+
...drill.value,
|
|
136
|
+
{ id: node.id, label: node.label, siblings: node.children },
|
|
137
|
+
];
|
|
138
|
+
// Drilling is itself a zoom: reset the pan so the new level starts
|
|
139
|
+
// framed; the zoom rung stays (the user picked it).
|
|
140
|
+
panX.value = 0;
|
|
141
|
+
panY.value = 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function popTo(index: number): void {
|
|
145
|
+
drill.value = drill.value.slice(0, index);
|
|
146
|
+
panX.value = 0;
|
|
147
|
+
panY.value = 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A changed LAYOUT invalidates the drill stack. Watching the layout
|
|
151
|
+
// (not the array identity) catches the in-place mutation a consumer
|
|
152
|
+
// can make (the computed recomputes off contents). Inline
|
|
153
|
+
// `:nodes="[...]"` literals still reset on every parent re-render —
|
|
154
|
+
// the layout allocates a fresh tree per recompute — so pass a stable
|
|
155
|
+
// reference from hot parents (known limitation, delta review F3).
|
|
156
|
+
watch(layout, () => {
|
|
157
|
+
drill.value = [];
|
|
158
|
+
resetCamera();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── measurement (LOCAL px — the dpiPrefs root-zoom lesson) ────────
|
|
162
|
+
function measure() {
|
|
163
|
+
const el = hostRef.value;
|
|
164
|
+
if (!el) return;
|
|
165
|
+
const w = el.clientWidth;
|
|
166
|
+
const h = el.clientHeight;
|
|
167
|
+
if (w > 0 && h > 0) size.value = { w, h };
|
|
168
|
+
}
|
|
169
|
+
onMounted(() => {
|
|
170
|
+
const el = hostRef.value;
|
|
171
|
+
if (!el) return;
|
|
172
|
+
measure();
|
|
173
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
174
|
+
observer = new ResizeObserver(measure);
|
|
175
|
+
observer.observe(el);
|
|
176
|
+
} else {
|
|
177
|
+
window.addEventListener("resize", measure);
|
|
178
|
+
resizeCleanup = () => window.removeEventListener("resize", measure);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
onBeforeUnmount(() => {
|
|
182
|
+
observer?.disconnect();
|
|
183
|
+
observer = undefined;
|
|
184
|
+
resizeCleanup?.();
|
|
185
|
+
resizeCleanup = undefined;
|
|
186
|
+
});
|
|
187
|
+
watch([layout, current], measure);
|
|
188
|
+
|
|
189
|
+
// ── cells at the current level ────────────────────────────────────
|
|
190
|
+
const cells = computed<HkTreemapCell[]>(() => {
|
|
191
|
+
const dims = size.value;
|
|
192
|
+
if (!dims || layout.value.nodes.length === 0) return [];
|
|
193
|
+
return layoutHkTreemapCells(current.value.siblings, 0, 0, dims.w, dims.h);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const formatValue = (value: number) =>
|
|
197
|
+
(props.valueFormatter ?? defaultValueFormatter)(value);
|
|
198
|
+
|
|
199
|
+
// ── the minimap's model: boxes = the level's top cells, viewport =
|
|
200
|
+
// the camera window over the (virtual) content plane. ──────────
|
|
201
|
+
const ZOOM_FOR_BOUNDS = 1; // content plane == canvas plane at 100%
|
|
202
|
+
const mmBoxes = computed<MinimapBox[]>(() =>
|
|
203
|
+
cells.value.map((cell) => ({
|
|
204
|
+
id: cell.node.id,
|
|
205
|
+
bounds: { x: cell.x, y: cell.y, w: cell.w, h: cell.h },
|
|
206
|
+
color: cell.node.color,
|
|
207
|
+
})),
|
|
208
|
+
);
|
|
209
|
+
const mmContentBounds = computed(() => {
|
|
210
|
+
const dims = size.value;
|
|
211
|
+
return { x: 0, y: 0, w: dims?.w ?? 800, h: dims?.h ?? 600 };
|
|
212
|
+
});
|
|
213
|
+
const mmZoom = computed(() => (zoomPct.value / 100) * ZOOM_FOR_BOUNDS);
|
|
214
|
+
|
|
215
|
+
const internalMinimapCollapsed = ref(true);
|
|
216
|
+
const minimapIsCollapsed = computed(() =>
|
|
217
|
+
props.minimapCollapsed !== undefined
|
|
218
|
+
? props.minimapCollapsed
|
|
219
|
+
: internalMinimapCollapsed.value,
|
|
220
|
+
);
|
|
221
|
+
function onMinimapCollapsed(v: boolean): void {
|
|
222
|
+
internalMinimapCollapsed.value = v;
|
|
223
|
+
emit("update:minimapCollapsed", v);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── render ────────────────────────────────────────────────────────
|
|
227
|
+
function cellText(node: HkTreemapLayoutNode): string {
|
|
228
|
+
return `${node.label}: ${formatValue(node.value)} · ${formatHkTreemapPercent(node.percent)}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const hasData = computed(() => layout.value.nodes.length > 0);
|
|
232
|
+
const surfaceStyle = computed(() => ({
|
|
233
|
+
transform: `translate(${panX.value}px, ${panY.value}px) scale(${zoomPct.value / 100})`,
|
|
234
|
+
transformOrigin: "0 0",
|
|
235
|
+
}));
|
|
236
|
+
|
|
237
|
+
return () => (
|
|
238
|
+
<div class="hk-treemap-stage">
|
|
239
|
+
{hasData.value && (
|
|
240
|
+
<header class="hk-treemap-stage__head">
|
|
241
|
+
{drill.value.length > 0 ? (
|
|
242
|
+
<HkIconButton
|
|
243
|
+
size={24}
|
|
244
|
+
variant="ghost"
|
|
245
|
+
aria-label="Back"
|
|
246
|
+
onClick={() => popTo(drill.value.length - 1)}
|
|
247
|
+
>
|
|
248
|
+
{{ icon: () => <ChevronLeft size={15} /> }}
|
|
249
|
+
</HkIconButton>
|
|
250
|
+
) : (
|
|
251
|
+
<span class="hk-treemap-stage__head-pad" aria-hidden="true" />
|
|
252
|
+
)}
|
|
253
|
+
<nav class="hk-treemap-stage__path" aria-label="Drill path">
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
class="hk-treemap-stage__crumb"
|
|
257
|
+
data-root=""
|
|
258
|
+
onClick={() => popTo(0)}
|
|
259
|
+
>
|
|
260
|
+
{props.ariaLabel}
|
|
261
|
+
</button>
|
|
262
|
+
{drill.value.map((step, i) => (
|
|
263
|
+
<span key={step.id} class="hk-treemap-stage__crumb-seg">
|
|
264
|
+
<span class="hk-treemap-stage__crumb-sep" aria-hidden="true">/</span>
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
class="hk-treemap-stage__crumb"
|
|
268
|
+
data-last={i === drill.value.length - 1 || undefined}
|
|
269
|
+
onClick={() => popTo(i + 1)}
|
|
270
|
+
>
|
|
271
|
+
{step.label}
|
|
272
|
+
</button>
|
|
273
|
+
</span>
|
|
274
|
+
))}
|
|
275
|
+
</nav>
|
|
276
|
+
</header>
|
|
277
|
+
)}
|
|
278
|
+
<div class="hk-treemap-stage__body">
|
|
279
|
+
<div ref={hostRef} class="hk-treemap-stage__canvas" role="application" aria-label={props.ariaLabel}>
|
|
280
|
+
{hasData.value && (
|
|
281
|
+
<div class="hk-treemap-stage__surface" style={surfaceStyle.value}>
|
|
282
|
+
{cells.value.map((cell) => {
|
|
283
|
+
const { node, x, y, w, h } = cell;
|
|
284
|
+
const isGroup = cell.children.length > 0;
|
|
285
|
+
// The ladder reads the ZOOMED size: a zoomed-in rung
|
|
286
|
+
// pushes small cells over the label floors — the
|
|
287
|
+
// "detail appears when you zoom" contract.
|
|
288
|
+
const zw = w * (zoomPct.value / 100);
|
|
289
|
+
const zh = h * (zoomPct.value / 100);
|
|
290
|
+
const showValue = zh >= 40 && zw >= 56;
|
|
291
|
+
const showLabel = zh >= 18 && zw >= 24;
|
|
292
|
+
return (
|
|
293
|
+
<HkTooltip key={node.id} text={cellText(node)} placement="top">
|
|
294
|
+
{() => (
|
|
295
|
+
<div
|
|
296
|
+
class={{
|
|
297
|
+
"hk-treemap__cell": true,
|
|
298
|
+
"hk-treemap__cell--group": isGroup,
|
|
299
|
+
"hk-treemap-stage__cell": true,
|
|
300
|
+
"hk-treemap-stage__cell--drillable": isGroup,
|
|
301
|
+
}}
|
|
302
|
+
style={{
|
|
303
|
+
left: `${x}px`,
|
|
304
|
+
top: `${y}px`,
|
|
305
|
+
width: `${w}px`,
|
|
306
|
+
height: `${h}px`,
|
|
307
|
+
background: node.color,
|
|
308
|
+
"--hk-treemap-fg": contrastTextColor(node.color),
|
|
309
|
+
}}
|
|
310
|
+
// Every cell is an activatable button (groups
|
|
311
|
+
// drill, leaves select) — keyboard parity with
|
|
312
|
+
// the mouse path (R2).
|
|
313
|
+
role="button"
|
|
314
|
+
tabindex={0}
|
|
315
|
+
aria-label={cellText(node)}
|
|
316
|
+
onClick={() => pushDrill(node)}
|
|
317
|
+
onKeydown={(e: KeyboardEvent) => {
|
|
318
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
pushDrill(node);
|
|
321
|
+
}
|
|
322
|
+
}}
|
|
323
|
+
>
|
|
324
|
+
{isGroup ? (
|
|
325
|
+
<span class="hk-treemap__group-head">
|
|
326
|
+
<span class="hk-treemap__label hk-treemap__label--group">
|
|
327
|
+
{node.label}
|
|
328
|
+
</span>
|
|
329
|
+
{showValue && (
|
|
330
|
+
<span class="hk-treemap__meta">
|
|
331
|
+
{formatValue(node.value)} · {formatHkTreemapPercent(node.percent)}
|
|
332
|
+
</span>
|
|
333
|
+
)}
|
|
334
|
+
</span>
|
|
335
|
+
) : (
|
|
336
|
+
showLabel && (
|
|
337
|
+
<span class="hk-treemap__leaf-body">
|
|
338
|
+
<span class="hk-treemap__label">{node.label}</span>
|
|
339
|
+
{showValue && (
|
|
340
|
+
<span class="hk-treemap__meta">
|
|
341
|
+
{formatValue(node.value)} · {formatHkTreemapPercent(node.percent)}
|
|
342
|
+
</span>
|
|
343
|
+
)}
|
|
344
|
+
</span>
|
|
345
|
+
)
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
)}
|
|
349
|
+
</HkTooltip>
|
|
350
|
+
);
|
|
351
|
+
})}
|
|
352
|
+
</div>
|
|
353
|
+
)}
|
|
354
|
+
{!hasData.value && (
|
|
355
|
+
<div class="hk-treemap__empty">{props.emptyText}</div>
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
{hasData.value && (
|
|
359
|
+
<HkMinimap
|
|
360
|
+
collapsible
|
|
361
|
+
collapsed={minimapIsCollapsed.value}
|
|
362
|
+
onUpdate:collapsed={onMinimapCollapsed}
|
|
363
|
+
boxes={mmBoxes.value}
|
|
364
|
+
zoom={mmZoom.value}
|
|
365
|
+
panX={panX.value}
|
|
366
|
+
panY={panY.value}
|
|
367
|
+
viewportWidth={size.value?.w ?? 800}
|
|
368
|
+
viewportHeight={size.value?.h ?? 600}
|
|
369
|
+
contentBounds={mmContentBounds.value}
|
|
370
|
+
zoomPercent={zoomPct.value}
|
|
371
|
+
minZoomPercent={MIN_ZOOM}
|
|
372
|
+
maxZoomPercent={MAX_ZOOM}
|
|
373
|
+
zoomStepPercent={STEP_ZOOM}
|
|
374
|
+
onZoomTo={(pct: number) => applyZoom(pct)}
|
|
375
|
+
onPanDelta={(dx: number, dy: number) => applyPanDelta(dx, dy)}
|
|
376
|
+
/>
|
|
377
|
+
)}
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
);
|
|
381
|
+
},
|
|
382
|
+
});
|
|
@@ -197,6 +197,8 @@
|
|
|
197
197
|
"hikari::zoomToolbar.zoomIn": "تكبير",
|
|
198
198
|
"hikari::zoomToolbar.zoomOut": "تصغير",
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "مستوى التكبير",
|
|
200
|
+
"hikari::zoomToolbar.expandMap": "إظهار الخريطة المصغّرة",
|
|
201
|
+
"hikari::zoomToolbar.collapseMap": "إخفاء الخريطة المصغّرة",
|
|
200
202
|
"hikari::filterBar.trigger": "عوامل التصفية",
|
|
201
203
|
"hikari::filterBar.apply": "تطبيق",
|
|
202
204
|
"hikari::filterPanel.all": "الكل",
|
|
@@ -197,6 +197,8 @@
|
|
|
197
197
|
"hikari::zoomToolbar.zoomIn": "Vergrößern",
|
|
198
198
|
"hikari::zoomToolbar.zoomOut": "Verkleinern",
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "Zoom-Stufe",
|
|
200
|
+
"hikari::zoomToolbar.expandMap": "Minikarte anzeigen",
|
|
201
|
+
"hikari::zoomToolbar.collapseMap": "Minikarte ausblenden",
|
|
200
202
|
"hikari::filterBar.trigger": "Filter",
|
|
201
203
|
"hikari::filterBar.apply": "Anwenden",
|
|
202
204
|
"hikari::filterPanel.all": "Alle",
|
|
@@ -197,6 +197,8 @@
|
|
|
197
197
|
"hikari::zoomToolbar.zoomIn": "Zoom in",
|
|
198
198
|
"hikari::zoomToolbar.zoomOut": "Zoom out",
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "Zoom level",
|
|
200
|
+
"hikari::zoomToolbar.expandMap": "Show minimap",
|
|
201
|
+
"hikari::zoomToolbar.collapseMap": "Hide minimap",
|
|
200
202
|
"hikari::filterBar.trigger": "Filters",
|
|
201
203
|
"hikari::filterBar.apply": "Apply",
|
|
202
204
|
"hikari::filterPanel.all": "All",
|
|
@@ -197,6 +197,8 @@
|
|
|
197
197
|
"hikari::zoomToolbar.zoomIn": "Acercar",
|
|
198
198
|
"hikari::zoomToolbar.zoomOut": "Alejar",
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "Nivel de zoom",
|
|
200
|
+
"hikari::zoomToolbar.expandMap": "Mostrar minimapa",
|
|
201
|
+
"hikari::zoomToolbar.collapseMap": "Ocultar minimapa",
|
|
200
202
|
"hikari::filterBar.trigger": "Filtros",
|
|
201
203
|
"hikari::filterBar.apply": "Aplicar",
|
|
202
204
|
"hikari::filterPanel.all": "Todos",
|
|
@@ -197,6 +197,8 @@
|
|
|
197
197
|
"hikari::zoomToolbar.zoomIn": "Agrandir",
|
|
198
198
|
"hikari::zoomToolbar.zoomOut": "Réduire",
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "Niveau de zoom",
|
|
200
|
+
"hikari::zoomToolbar.expandMap": "Afficher la mini-carte",
|
|
201
|
+
"hikari::zoomToolbar.collapseMap": "Masquer la mini-carte",
|
|
200
202
|
"hikari::filterBar.trigger": "Filtres",
|
|
201
203
|
"hikari::filterBar.apply": "Appliquer",
|
|
202
204
|
"hikari::filterPanel.all": "Tous",
|
|
@@ -197,6 +197,8 @@
|
|
|
197
197
|
"hikari::zoomToolbar.zoomIn": "拡大",
|
|
198
198
|
"hikari::zoomToolbar.zoomOut": "縮小",
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "ズーム倍率",
|
|
200
|
+
"hikari::zoomToolbar.expandMap": "ミニマップを表示",
|
|
201
|
+
"hikari::zoomToolbar.collapseMap": "ミニマップを隠す",
|
|
200
202
|
"hikari::filterBar.trigger": "絞り込み",
|
|
201
203
|
"hikari::filterBar.apply": "適用",
|
|
202
204
|
"hikari::filterPanel.all": "すべて",
|
|
@@ -197,6 +197,8 @@
|
|
|
197
197
|
"hikari::zoomToolbar.zoomIn": "확대",
|
|
198
198
|
"hikari::zoomToolbar.zoomOut": "축소",
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "확대 수준",
|
|
200
|
+
"hikari::zoomToolbar.expandMap": "미니맵 표시",
|
|
201
|
+
"hikari::zoomToolbar.collapseMap": "미니맵 숨기기",
|
|
200
202
|
"hikari::filterBar.trigger": "필터",
|
|
201
203
|
"hikari::filterBar.apply": "적용",
|
|
202
204
|
"hikari::filterPanel.all": "전체",
|
|
@@ -197,6 +197,8 @@
|
|
|
197
197
|
"hikari::zoomToolbar.zoomIn": "Ampliar",
|
|
198
198
|
"hikari::zoomToolbar.zoomOut": "Reduzir",
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "Nível de zoom",
|
|
200
|
+
"hikari::zoomToolbar.expandMap": "Mostrar minimapa",
|
|
201
|
+
"hikari::zoomToolbar.collapseMap": "Ocultar minimapa",
|
|
200
202
|
"hikari::filterBar.trigger": "Filtros",
|
|
201
203
|
"hikari::filterBar.apply": "Aplicar",
|
|
202
204
|
"hikari::filterPanel.all": "Todos",
|
|
@@ -197,6 +197,8 @@
|
|
|
197
197
|
"hikari::zoomToolbar.zoomIn": "Увеличить",
|
|
198
198
|
"hikari::zoomToolbar.zoomOut": "Уменьшить",
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "Уровень масштаба",
|
|
200
|
+
"hikari::zoomToolbar.expandMap": "Показать мини-карту",
|
|
201
|
+
"hikari::zoomToolbar.collapseMap": "Скрыть мини-карту",
|
|
200
202
|
"hikari::filterBar.trigger": "Фильтры",
|
|
201
203
|
"hikari::filterBar.apply": "Применить",
|
|
202
204
|
"hikari::filterPanel.all": "Все",
|
|
@@ -197,6 +197,8 @@
|
|
|
197
197
|
"hikari::zoomToolbar.zoomIn": "放大",
|
|
198
198
|
"hikari::zoomToolbar.zoomOut": "缩小",
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "缩放级别",
|
|
200
|
+
"hikari::zoomToolbar.expandMap": "显示小地图",
|
|
201
|
+
"hikari::zoomToolbar.collapseMap": "隐藏小地图",
|
|
200
202
|
"hikari::statusBar.region.CN": "中国大陆",
|
|
201
203
|
"hikari::statusBar.region.HK": "中国香港",
|
|
202
204
|
"hikari::statusBar.region.MO": "中国澳门",
|
|
@@ -197,6 +197,8 @@
|
|
|
197
197
|
"hikari::zoomToolbar.zoomIn": "放大",
|
|
198
198
|
"hikari::zoomToolbar.zoomOut": "縮小",
|
|
199
199
|
"hikari::zoomToolbar.zoomSlider": "縮放級別",
|
|
200
|
+
"hikari::zoomToolbar.expandMap": "顯示小地圖",
|
|
201
|
+
"hikari::zoomToolbar.collapseMap": "隱藏小地圖",
|
|
200
202
|
"hikari::statusBar.region.CN": "中國大陸",
|
|
201
203
|
"hikari::statusBar.region.HK": "中國香港",
|
|
202
204
|
"hikari::statusBar.region.MO": "中國澳門",
|
package/src/index.ts
CHANGED
|
@@ -151,6 +151,7 @@ export { default as HMinimap } from "./components/HkMinimap";
|
|
|
151
151
|
// Charts
|
|
152
152
|
export { default as HTrendChart } from "./components/HkTrendChart";
|
|
153
153
|
export { default as HTreemap } from "./components/HkTreemap";
|
|
154
|
+
export { default as HTreemapStage } from "./components/HkTreemapStage";
|
|
154
155
|
|
|
155
156
|
export { default as HErrorBoundary } from "./components/HkErrorBoundary";
|
|
156
157
|
export {
|