@celestia-island/hikari 0.55.253 → 0.55.255
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/board3dSurface.test.ts +11 -1
- package/src/components/HkAffixPicker.scss +4 -3
- package/src/components/HkLocalePicker.scss +29 -32
- package/src/components/HkLocalePicker.tsx +35 -94
- package/src/components/HkLocalePickerPopup.tsx +3 -1
- package/src/components/HkMinimap.tsx +2 -2
- package/src/components/HkMinimap3D.test.tsx +43 -1
- package/src/components/HkMinimap3D.tsx +31 -6
- package/src/components/HkStarSystem.scss +29 -0
- package/src/components/HkStarSystem.styles.test.ts +14 -0
- package/src/components/HkStarSystem.test.tsx +163 -0
- package/src/components/HkStarSystem.tsx +172 -0
- package/src/components/HkTimeZoneGlobe.test.tsx +9 -0
- package/src/components/HkTitleBar.scss +28 -12
- package/src/components/HkTitleBar.test.tsx +35 -1
- package/src/components/HkZoomToolbar.tsx +2 -2
- package/src/components/_menu-item.contract.test.ts +80 -0
- package/src/index.ts +6 -0
- package/src/scene3d/holographicFleetBody.test.ts +89 -0
- package/src/scene3d/holographicFleetBody.ts +256 -0
- package/src/scene3d/holographicFleetScene.test.ts +1076 -0
- package/src/scene3d/holographicFleetScene.ts +651 -0
- package/src/scene3d/holographicFleetSpec.test.ts +474 -0
- package/src/scene3d/holographicFleetSpec.ts +419 -0
package/package.json
CHANGED
|
@@ -24,6 +24,8 @@ type ModelOpts = barrel.Board3DModelOptions;
|
|
|
24
24
|
type CameraCfg = barrel.Board3DCameraConfig;
|
|
25
25
|
type FocusOpts = barrel.Board3DFocusOptions;
|
|
26
26
|
type ViewModeT = barrel.Board3DViewMode;
|
|
27
|
+
type StarBody = barrel.HkStarSystemBodyInput;
|
|
28
|
+
type StarSpec = barrel.HkStarSystemBodySpec;
|
|
27
29
|
|
|
28
30
|
// Type-only probes: these lines exist so the checker resolves the three
|
|
29
31
|
// exported types through the barrel itself.
|
|
@@ -34,6 +36,12 @@ const modelProbe: ModelOpts | null = null;
|
|
|
34
36
|
const cameraProbe: CameraCfg = { fov: 45 };
|
|
35
37
|
const focusProbe: FocusOpts = { padding: 1.6, lateralBias: 0 };
|
|
36
38
|
const viewModeProbe: ViewModeT = "orbit";
|
|
39
|
+
const starBodyProbe: StarBody = {
|
|
40
|
+
id: "node-1", label: "Node One", role: "planet", status: "Online", online: true,
|
|
41
|
+
};
|
|
42
|
+
const starSpecProbe: StarSpec | null = null;
|
|
43
|
+
void starBodyProbe;
|
|
44
|
+
void starSpecProbe;
|
|
37
45
|
void engineProbe;
|
|
38
46
|
void defProbe;
|
|
39
47
|
void vecProbe;
|
|
@@ -43,9 +51,11 @@ void focusProbe;
|
|
|
43
51
|
void viewModeProbe;
|
|
44
52
|
|
|
45
53
|
describe("hikari barrel — 3D board surface", () => {
|
|
46
|
-
it("exports
|
|
54
|
+
it("exports the board, minimap and star-system business component", () => {
|
|
47
55
|
expect(barrel.HkBoard3D).toBeTruthy();
|
|
48
56
|
expect(barrel.HkMinimap3D).toBeTruthy();
|
|
57
|
+
expect(barrel.HkStarSystem).toBeTruthy();
|
|
58
|
+
expect(barrel.HStarSystem).toBe(barrel.HkStarSystem);
|
|
49
59
|
});
|
|
50
60
|
|
|
51
61
|
it("exports the helper-layer constant consumers need for own decoration", () => {
|
|
@@ -251,9 +251,10 @@
|
|
|
251
251
|
* comes from the registration list, not from here. */
|
|
252
252
|
@include mi.item;
|
|
253
253
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
254
|
+
/* data-disabled rides the mixin's face whole (dim + not-allowed +
|
|
255
|
+
guarded hover/focus): the sibling tag-input catalog rows already
|
|
256
|
+
did, and a family-local cursor:default split the two choosers'
|
|
257
|
+
disabled language. */
|
|
257
258
|
|
|
258
259
|
/* The "Use <query>" free-text row reads as an add affordance. Truncation
|
|
259
260
|
* comes from the base .hk-affix-row-label — only the emphasis is new. */
|
|
@@ -1,37 +1,34 @@
|
|
|
1
|
-
/* HkLocalePicker — the
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
.hk-locale-picker-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
/* HkLocalePicker — only the TRIGGER is component-owned now; the dropdown
|
|
2
|
+
* is HkLocalePickerPopup (HkMenu) and inherits the menu grammar + pop
|
|
3
|
+
* motion from there. The hand-rolled dropdown sheet (and its pop-motion
|
|
4
|
+
* classes) was removed with the 2026-10-31 sweep rewrite. */
|
|
5
|
+
.hk-locale-picker-trigger {
|
|
6
|
+
display: inline-flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
gap: 0.25rem;
|
|
9
|
+
padding: 0.25rem 0.5rem;
|
|
10
|
+
border: 1px solid var(--border-faint, rgb(var(--color-border) / 10%));
|
|
11
|
+
border-radius: var(--radius-sm, 4px);
|
|
12
|
+
background: transparent;
|
|
13
|
+
color: rgb(var(--color-muted));
|
|
14
|
+
font-size: 0.75rem;
|
|
15
|
+
font-weight: 500;
|
|
16
|
+
font-family: inherit;
|
|
17
|
+
cursor: pointer;
|
|
18
|
+
transition: background 0.15s;
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
var(--hk-pop-out-ease, cubic-bezier(0.4, 0, 1, 1)),
|
|
21
|
-
transform var(--hk-pop-out-duration, 0.15s)
|
|
22
|
-
var(--hk-pop-out-ease, cubic-bezier(0.4, 0, 1, 1));
|
|
23
|
-
}
|
|
20
|
+
&:hover {
|
|
21
|
+
background: rgb(var(--color-primary) / 8%);
|
|
22
|
+
}
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
scale(var(--hk-pop-scale, 0.97));
|
|
30
|
-
}
|
|
24
|
+
&:focus-visible {
|
|
25
|
+
outline: 2px solid rgb(var(--color-primary) / 70%);
|
|
26
|
+
outline-offset: 1px;
|
|
27
|
+
}
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
/* The caret is furniture, not copy — half-strength ink, as the
|
|
30
|
+
pre-rewrite trigger drew it. */
|
|
31
|
+
svg {
|
|
32
|
+
opacity: 0.5;
|
|
36
33
|
}
|
|
37
34
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { defineComponent, ref,
|
|
1
|
+
import { defineComponent, ref, type PropType } from "vue";
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import HkLocalePickerPopup from "./HkLocalePickerPopup";
|
|
4
4
|
import "./HkLocalePicker.scss";
|
|
5
5
|
|
|
6
6
|
export interface LocaleOption {
|
|
@@ -8,6 +8,15 @@ export interface LocaleOption {
|
|
|
8
8
|
label: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* HkLocalePicker — the compact trigger + dropdown locale picker. The
|
|
13
|
+
* dropdown hand-rolled its own absolutely-positioned stock (inline
|
|
14
|
+
* styles, JS-painted hover, no sheet, no roles — the 2026-10-31 sweep's
|
|
15
|
+
* finding #8); it now rides HkLocalePickerPopup (HkMenu), so the rows
|
|
16
|
+
* speak the menu-item grammar, the panel anchors/cascades/flips like
|
|
17
|
+
* every menu, and phones get the sheet family. The public surface is
|
|
18
|
+
* unchanged: locales / currentLocale props, `select` event.
|
|
19
|
+
*/
|
|
11
20
|
export const HkLocalePicker = defineComponent({
|
|
12
21
|
name: "HkLocalePicker",
|
|
13
22
|
props: {
|
|
@@ -23,110 +32,42 @@ export const HkLocalePicker = defineComponent({
|
|
|
23
32
|
emits: ["select"],
|
|
24
33
|
setup(props, { emit }) {
|
|
25
34
|
const open = ref(false);
|
|
26
|
-
|
|
27
|
-
// this little dropdown moves like the rest of the pop family
|
|
28
|
-
// instead of appearing bare.
|
|
29
|
-
const anim = useSurfaceTransition(250).hooks();
|
|
35
|
+
const trigger = ref<HTMLElement | null>(null);
|
|
30
36
|
|
|
31
|
-
function handleSelect(code: string) {
|
|
32
|
-
|
|
37
|
+
function handleSelect(code: string | string[]) {
|
|
38
|
+
const code0 = Array.isArray(code) ? code[0] : code;
|
|
39
|
+
if (code0) emit("select", code0);
|
|
33
40
|
open.value = false;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
return () => (
|
|
37
|
-
<div style={{ position: "relative" }}>
|
|
44
|
+
<div style={{ position: "relative" }} ref={trigger}>
|
|
38
45
|
<button
|
|
39
46
|
type="button"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
gap: "0.25rem",
|
|
44
|
-
padding: "0.25rem 0.5rem",
|
|
45
|
-
border: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
|
|
46
|
-
borderRadius: "var(--radius-sm, 4px)",
|
|
47
|
-
background: "transparent",
|
|
48
|
-
color: "rgb(var(--color-muted))",
|
|
49
|
-
fontSize: "0.75rem",
|
|
50
|
-
fontWeight: 500,
|
|
51
|
-
cursor: "pointer",
|
|
52
|
-
transition: "background 0.15s",
|
|
53
|
-
}}
|
|
47
|
+
class="hk-locale-picker-trigger"
|
|
48
|
+
aria-haspopup="menu"
|
|
49
|
+
aria-expanded={open.value}
|
|
54
50
|
onClick={() => (open.value = !open.value)}
|
|
55
|
-
onBlur={() => setTimeout(() => (open.value = false), 150)}
|
|
56
51
|
>
|
|
57
52
|
{props.currentLocale.toUpperCase()}
|
|
58
|
-
<svg width="10" height="10" viewBox="0 0 10 10"
|
|
59
|
-
<path
|
|
53
|
+
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
|
54
|
+
<path
|
|
55
|
+
d="M2 3.5l3 3 3-3"
|
|
56
|
+
fill="none"
|
|
57
|
+
stroke="currentColor"
|
|
58
|
+
stroke-width="1.2"
|
|
59
|
+
/>
|
|
60
60
|
</svg>
|
|
61
61
|
</button>
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
<div
|
|
72
|
-
class="hk-locale-picker-dropdown"
|
|
73
|
-
style={{
|
|
74
|
-
position: "absolute",
|
|
75
|
-
top: "calc(100% + 4px)",
|
|
76
|
-
right: 0,
|
|
77
|
-
minWidth: "120px",
|
|
78
|
-
background: "rgb(var(--color-surface))",
|
|
79
|
-
border: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
|
|
80
|
-
borderRadius: "var(--radius-md, 6px)",
|
|
81
|
-
boxShadow: "0 4px 16px rgb(0 0 0 / 12%)",
|
|
82
|
-
// Local stacking inside the header's own context — the
|
|
83
|
-
// app-chrome header-popup band documents the intent (this
|
|
84
|
-
// value is scoped, it never races the popup stack).
|
|
85
|
-
zIndex: "var(--hk-z-float)",
|
|
86
|
-
overflow: "hidden",
|
|
87
|
-
padding: "4px",
|
|
88
|
-
}}
|
|
89
|
-
>
|
|
90
|
-
{props.locales.map((loc) => (
|
|
91
|
-
<button
|
|
92
|
-
key={loc.code}
|
|
93
|
-
type="button"
|
|
94
|
-
style={{
|
|
95
|
-
display: "flex",
|
|
96
|
-
alignItems: "center",
|
|
97
|
-
width: "100%",
|
|
98
|
-
padding: "6px 10px",
|
|
99
|
-
border: "none",
|
|
100
|
-
borderRadius: "4px",
|
|
101
|
-
background: loc.code === props.currentLocale
|
|
102
|
-
? "var(--c-primary-subtle, rgb(var(--color-primary) / 8%))"
|
|
103
|
-
: "transparent",
|
|
104
|
-
color: loc.code === props.currentLocale
|
|
105
|
-
? "var(--c-primary, rgb(var(--color-primary)))"
|
|
106
|
-
: "rgb(var(--color-text))",
|
|
107
|
-
fontSize: "0.75rem",
|
|
108
|
-
cursor: "pointer",
|
|
109
|
-
textAlign: "left",
|
|
110
|
-
transition: "background 0.1s",
|
|
111
|
-
}}
|
|
112
|
-
onMouseenter={(e: MouseEvent) => {
|
|
113
|
-
if (loc.code !== props.currentLocale) {
|
|
114
|
-
(e.currentTarget as HTMLElement).style.background = "var(--c-primary-overlay, rgb(var(--color-primary) / 15%))";
|
|
115
|
-
}
|
|
116
|
-
}}
|
|
117
|
-
onMouseleave={(e: MouseEvent) => {
|
|
118
|
-
if (loc.code !== props.currentLocale) {
|
|
119
|
-
(e.currentTarget as HTMLElement).style.background = "transparent";
|
|
120
|
-
}
|
|
121
|
-
}}
|
|
122
|
-
onMousedown={() => handleSelect(loc.code)}
|
|
123
|
-
>
|
|
124
|
-
{loc.label}
|
|
125
|
-
</button>
|
|
126
|
-
))}
|
|
127
|
-
</div>
|
|
128
|
-
)}
|
|
129
|
-
</Transition>
|
|
62
|
+
<HkLocalePickerPopup
|
|
63
|
+
open={open.value}
|
|
64
|
+
triggerRef={trigger.value}
|
|
65
|
+
placement="bottom-end"
|
|
66
|
+
locales={props.locales.map((loc) => ({ code: loc.code, label: loc.label }))}
|
|
67
|
+
currentLocale={props.currentLocale}
|
|
68
|
+
onUpdate:open={(v: boolean) => (open.value = v)}
|
|
69
|
+
onSelect={handleSelect}
|
|
70
|
+
/>
|
|
130
71
|
</div>
|
|
131
72
|
);
|
|
132
73
|
},
|
|
@@ -38,7 +38,9 @@ const HkLocalePickerPopup = defineComponent({
|
|
|
38
38
|
open={props.open}
|
|
39
39
|
anchorRef={anchor}
|
|
40
40
|
placement={
|
|
41
|
-
props.placement === "left-start" ||
|
|
41
|
+
props.placement === "left-start" ||
|
|
42
|
+
props.placement === "right-start" ||
|
|
43
|
+
props.placement === "bottom-end"
|
|
42
44
|
? props.placement
|
|
43
45
|
: "right-start"
|
|
44
46
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Map as MapIcon,
|
|
1
|
+
import { Map as MapIcon, Minimize2, RotateCcw, ZoomIn, ZoomOut } from "lucide-vue-next";
|
|
2
2
|
import { computed, defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
|
|
3
3
|
|
|
4
4
|
import HSlider from "./HkSlider";
|
|
@@ -407,7 +407,7 @@ export default defineComponent({
|
|
|
407
407
|
aria-label={t("hikari::zoomToolbar.reset", "Reset zoom")}
|
|
408
408
|
title={t("hikari::zoomToolbar.reset", "Reset zoom")}
|
|
409
409
|
>
|
|
410
|
-
<
|
|
410
|
+
<RotateCcw size={11} />
|
|
411
411
|
</button>
|
|
412
412
|
)}
|
|
413
413
|
{props.collapsible && (
|
|
@@ -12,12 +12,15 @@ const H = vi.hoisted(() => {
|
|
|
12
12
|
static instances: FakeWebGLRenderer[] = [];
|
|
13
13
|
renders: Array<{ scene: unknown; camera: THREE.PerspectiveCamera }> = [];
|
|
14
14
|
disposed = false;
|
|
15
|
-
|
|
15
|
+
readonly domElement: HTMLCanvasElement;
|
|
16
|
+
constructor(opts: { canvas: HTMLCanvasElement }) {
|
|
17
|
+
this.domElement = opts.canvas;
|
|
16
18
|
FakeWebGLRenderer.instances.push(this);
|
|
17
19
|
}
|
|
18
20
|
setPixelRatio(): void {}
|
|
19
21
|
setSize(): void {}
|
|
20
22
|
setClearColor(): void {}
|
|
23
|
+
forceContextLoss(): void { this.disposed = true; }
|
|
21
24
|
render(scene: unknown, camera: THREE.PerspectiveCamera): void {
|
|
22
25
|
// A real renderer maintains world matrices every pass.
|
|
23
26
|
(scene as THREE.Scene).updateMatrixWorld(true);
|
|
@@ -424,4 +427,43 @@ describe("HkMinimap3D collapse affordance (ported from the 2D minimap)", () => {
|
|
|
424
427
|
expect(container.querySelector(".hk-mm-collapse-btn")).toBeNull();
|
|
425
428
|
expect(container.querySelector(".hk-minimap--collapsed")).toBeNull();
|
|
426
429
|
});
|
|
430
|
+
});
|
|
431
|
+
describe("HkMinimap3D reopen cycle (the canvas element is rebuilt by v-if)", () => {
|
|
432
|
+
it("re-binds the renderer to the LIVE canvas; helpers never duplicate", async () => {
|
|
433
|
+
const { engine, scene, firePostRender } = fakeEngine();
|
|
434
|
+
const { container } = mountMap(engine, { collapsible: true });
|
|
435
|
+
await nextTick();
|
|
436
|
+
firePostRender();
|
|
437
|
+
expect(H.FakeWebGLRenderer.instances).toHaveLength(1);
|
|
438
|
+
|
|
439
|
+
const helpersOnLayer = () =>
|
|
440
|
+
scene.children.filter((c) => (c.layers.mask & (1 << BOARD3D_HELPERS_LAYER)) !== 0).length;
|
|
441
|
+
expect(helpersOnLayer()).toBe(2); // glyph group + view-plane frame
|
|
442
|
+
|
|
443
|
+
// fold -> unfold: Vue rebuilds the canvas element; the renderer must
|
|
444
|
+
// follow it or the reopened map paints into the detached element.
|
|
445
|
+
container.querySelector<HTMLButtonElement>(".hk-mm-collapse-btn")!.click();
|
|
446
|
+
await nextTick();
|
|
447
|
+
container.querySelector<HTMLButtonElement>(".hk-minimap--collapsed")!.click();
|
|
448
|
+
await nextTick();
|
|
449
|
+
await nextTick();
|
|
450
|
+
|
|
451
|
+
const canvasNow = container.querySelector<HTMLCanvasElement>(".hk-minimap3d-canvas");
|
|
452
|
+
expect(canvasNow, "canvas is back").toBeTruthy();
|
|
453
|
+
const latest = H.FakeWebGLRenderer.instances.at(-1)!;
|
|
454
|
+
expect(latest.domElement).toBe(canvasNow);
|
|
455
|
+
expect(helpersOnLayer()).toBe(2); // rebuilt, NOT duplicated
|
|
456
|
+
firePostRender();
|
|
457
|
+
expect(latest.renders.length).toBe(1);
|
|
458
|
+
|
|
459
|
+
// a second cycle stays correct
|
|
460
|
+
container.querySelector<HTMLButtonElement>(".hk-mm-collapse-btn")!.click();
|
|
461
|
+
await nextTick();
|
|
462
|
+
container.querySelector<HTMLButtonElement>(".hk-minimap--collapsed")!.click();
|
|
463
|
+
await nextTick();
|
|
464
|
+
await nextTick();
|
|
465
|
+
const canvas2 = container.querySelector<HTMLCanvasElement>(".hk-minimap3d-canvas");
|
|
466
|
+
expect(H.FakeWebGLRenderer.instances.at(-1)!.domElement).toBe(canvas2);
|
|
467
|
+
expect(helpersOnLayer()).toBe(2);
|
|
468
|
+
});
|
|
427
469
|
});
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* mount.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { Map as MapIcon,
|
|
27
|
+
import { Map as MapIcon, Minimize2, RotateCcw, ZoomIn, ZoomOut } from "lucide-vue-next";
|
|
28
28
|
import {
|
|
29
29
|
computed,
|
|
30
30
|
defineComponent,
|
|
@@ -381,11 +381,36 @@ export default defineComponent({
|
|
|
381
381
|
}
|
|
382
382
|
});
|
|
383
383
|
|
|
384
|
-
/**
|
|
385
|
-
* mounts) and from the expand transition (a
|
|
386
|
-
* has no canvas at mount
|
|
384
|
+
/** GL-surface init, safe to call repeatedly. Called from onMounted
|
|
385
|
+
* (expanded mounts) and from the expand transition (a
|
|
386
|
+
* defaultCollapsed map has no canvas at mount; a fold/unfold cycle
|
|
387
|
+
* REBUILDS the canvas element, so a surviving renderer would keep
|
|
388
|
+
* painting into the detached element and the reopened map would
|
|
389
|
+
* stay empty — detect the swap and rebuild against the live canvas,
|
|
390
|
+
* helpers and post-render hook included). */
|
|
387
391
|
function initRenderer(): void {
|
|
388
|
-
if (renderer) return;
|
|
392
|
+
if (renderer && renderer.domElement === canvasRef.value) return;
|
|
393
|
+
removePostRender?.();
|
|
394
|
+
removePostRender = null;
|
|
395
|
+
if (renderer) {
|
|
396
|
+
renderer.dispose();
|
|
397
|
+
renderer.forceContextLoss?.();
|
|
398
|
+
renderer = null;
|
|
399
|
+
}
|
|
400
|
+
const s = scene.value;
|
|
401
|
+
if (s) {
|
|
402
|
+
if (glyph) {
|
|
403
|
+
s.remove(glyph);
|
|
404
|
+
disposeNode(glyph);
|
|
405
|
+
glyph = null;
|
|
406
|
+
}
|
|
407
|
+
if (planeFrame) {
|
|
408
|
+
s.remove(planeFrame);
|
|
409
|
+
planeFrame.geometry.dispose();
|
|
410
|
+
(planeFrame.material as THREE.Material).dispose();
|
|
411
|
+
planeFrame = null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
389
414
|
const canvas = canvasRef.value;
|
|
390
415
|
const el = rootRef.value;
|
|
391
416
|
if (!canvas || !el) return;
|
|
@@ -519,7 +544,7 @@ export default defineComponent({
|
|
|
519
544
|
aria-label={t("hikari::board3d.resetView", "Reset view")}
|
|
520
545
|
title={t("hikari::board3d.resetView", "Reset view")}
|
|
521
546
|
>
|
|
522
|
-
<
|
|
547
|
+
<RotateCcw size={11} />
|
|
523
548
|
</button>
|
|
524
549
|
{props.collapsible && (
|
|
525
550
|
<button
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
@use "../styles/polyfill" as *;
|
|
2
|
+
|
|
3
|
+
/* HkStarSystem's optional built-in CSS2D chip. Host products can replace
|
|
4
|
+
* it through makeLabel without inheriting any product-specific styling. */
|
|
5
|
+
.hk-star-system-chip {
|
|
6
|
+
display: inline-flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
max-width: 240px;
|
|
9
|
+
padding: 3px 8px;
|
|
10
|
+
overflow: hidden;
|
|
11
|
+
border: 1px solid rgb(var(--color-border, 100 116 139) / 0.38);
|
|
12
|
+
border-radius: 9999px;
|
|
13
|
+
background: rgb(var(--color-background, 11 16 32) / 0.8);
|
|
14
|
+
color: rgb(var(--color-text, 226 232 240));
|
|
15
|
+
font-size: 11px;
|
|
16
|
+
line-height: 1.4;
|
|
17
|
+
text-overflow: ellipsis;
|
|
18
|
+
white-space: nowrap;
|
|
19
|
+
cursor: pointer;
|
|
20
|
+
@include user-select(none);
|
|
21
|
+
pointer-events: auto;
|
|
22
|
+
@include backdrop-filter(blur(6px));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.hk-star-system-chip.is-core {
|
|
26
|
+
border-style: dashed;
|
|
27
|
+
cursor: default;
|
|
28
|
+
pointer-events: none;
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
const sheet = readFileSync(join(import.meta.dirname, "HkStarSystem.scss"), "utf8");
|
|
6
|
+
|
|
7
|
+
describe("HkStarSystem chip theme channels", () => {
|
|
8
|
+
it("uses RGB channel tokens through rgb(), never as complete CSS colors", () => {
|
|
9
|
+
expect(sheet).toContain("color: rgb(var(--color-text,");
|
|
10
|
+
expect(sheet).toContain("background: rgb(var(--color-background,");
|
|
11
|
+
expect(sheet).toContain("border: 1px solid rgb(var(--color-border,");
|
|
12
|
+
expect(sheet).not.toContain("color-mix");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick, reactive, type App } from "vue";
|
|
3
|
+
|
|
4
|
+
import type { StarSystemBodyInput } from "../scene3d/holographicFleetSpec";
|
|
5
|
+
import HkStarSystem from "./HkStarSystem";
|
|
6
|
+
|
|
7
|
+
const mocks = vi.hoisted(() => ({
|
|
8
|
+
engine: {},
|
|
9
|
+
ready: undefined as undefined | (() => void),
|
|
10
|
+
build: vi.fn(),
|
|
11
|
+
handle: {
|
|
12
|
+
update: vi.fn(),
|
|
13
|
+
setSelected: vi.fn(),
|
|
14
|
+
focusBody: vi.fn(),
|
|
15
|
+
frameFleet: vi.fn(() => true),
|
|
16
|
+
dispose: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("../scene3d/holographicFleetScene", () => ({
|
|
21
|
+
buildFleetScene: mocks.build.mockImplementation(() => mocks.handle),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("./HkBoard3D", async () => {
|
|
25
|
+
const { defineComponent, h: vueH, onMounted } = await import("vue");
|
|
26
|
+
return {
|
|
27
|
+
default: defineComponent({
|
|
28
|
+
name: "HkBoard3D",
|
|
29
|
+
emits: ["ready", "objectClick"],
|
|
30
|
+
setup(_props, { emit, slots }) {
|
|
31
|
+
mocks.ready = () => emit("ready", mocks.engine);
|
|
32
|
+
onMounted(() => mocks.ready?.());
|
|
33
|
+
return () => vueH("div", { class: "board-stub" }, slots.default?.());
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const BODIES: readonly StarSystemBodyInput[] = [
|
|
40
|
+
{
|
|
41
|
+
id: "hub-a", label: "Hub A", role: "star", status: "Online",
|
|
42
|
+
online: true, endpointCount: 2, detail: "2 endpoints",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "node-1", label: "Node One", role: "planet", status: "Offline",
|
|
46
|
+
online: false, endpointCount: 0,
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const mounts: Array<{ app: App; container: HTMLElement }> = [];
|
|
51
|
+
|
|
52
|
+
async function settle(): Promise<void> {
|
|
53
|
+
await nextTick();
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
55
|
+
await nextTick();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
vi.clearAllMocks();
|
|
60
|
+
mocks.engine = {};
|
|
61
|
+
mocks.ready = undefined;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
66
|
+
app.unmount();
|
|
67
|
+
container.remove();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function mountSystem(initialSelection: string | null = "node-1") {
|
|
72
|
+
const state = reactive({
|
|
73
|
+
selectedId: initialSelection as string | null,
|
|
74
|
+
bodies: [...BODIES],
|
|
75
|
+
});
|
|
76
|
+
const emitted: Array<string | null> = [];
|
|
77
|
+
const container = document.createElement("div");
|
|
78
|
+
document.body.appendChild(container);
|
|
79
|
+
const app = createApp({
|
|
80
|
+
render: () => h(HkStarSystem, {
|
|
81
|
+
bodies: state.bodies,
|
|
82
|
+
selectedId: state.selectedId,
|
|
83
|
+
"onUpdate:selectedId": (id: string | null) => emitted.push(id),
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
app.mount(container);
|
|
87
|
+
mounts.push({ app, container });
|
|
88
|
+
return { state, emitted };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("HkStarSystem controlled scene lifecycle", () => {
|
|
92
|
+
it("passes real body data and restores selection without flying", async () => {
|
|
93
|
+
mountSystem("node-1");
|
|
94
|
+
await settle();
|
|
95
|
+
|
|
96
|
+
expect(mocks.handle.update).toHaveBeenCalled();
|
|
97
|
+
const [spec, offline] = mocks.handle.update.mock.calls[0]!;
|
|
98
|
+
expect(spec.stars[0]).toMatchObject({
|
|
99
|
+
id: "hub-a", label: "Hub A", status: "Online", detail: "2 endpoints",
|
|
100
|
+
});
|
|
101
|
+
expect(spec.planets[0]).toMatchObject({
|
|
102
|
+
id: "node-1", label: "Node One", status: "Offline", online: false,
|
|
103
|
+
});
|
|
104
|
+
expect(offline).toEqual({ "hub-a": false, "node-1": true });
|
|
105
|
+
expect(mocks.handle.setSelected).toHaveBeenCalledWith("node-1");
|
|
106
|
+
expect(mocks.handle.focusBody).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("focuses an explicit controlled selection change", async () => {
|
|
110
|
+
const { state } = mountSystem("node-1");
|
|
111
|
+
await settle();
|
|
112
|
+
state.selectedId = "hub-a";
|
|
113
|
+
await settle();
|
|
114
|
+
expect(mocks.handle.focusBody).toHaveBeenCalledWith("hub-a");
|
|
115
|
+
|
|
116
|
+
state.selectedId = null;
|
|
117
|
+
await settle();
|
|
118
|
+
expect(mocks.handle.setSelected).toHaveBeenCalledWith(null);
|
|
119
|
+
expect(mocks.handle.frameFleet).toHaveBeenCalledWith(600);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("updates before clearing a selection whose body disappeared", async () => {
|
|
123
|
+
const { state, emitted } = mountSystem("hub-a");
|
|
124
|
+
await settle();
|
|
125
|
+
mocks.handle.update.mockClear();
|
|
126
|
+
mocks.handle.setSelected.mockClear();
|
|
127
|
+
|
|
128
|
+
state.bodies = state.bodies.filter((body) => body.id !== "hub-a");
|
|
129
|
+
await settle();
|
|
130
|
+
expect(mocks.handle.update).toHaveBeenCalledTimes(1);
|
|
131
|
+
expect(mocks.handle.setSelected).toHaveBeenCalledWith(null);
|
|
132
|
+
expect(emitted).toContain(null);
|
|
133
|
+
expect(mocks.handle.update.mock.invocationCallOrder[0])
|
|
134
|
+
.toBeLessThan(mocks.handle.setSelected.mock.invocationCallOrder[0]!);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("disposes engine A before restoring the selection on engine B", async () => {
|
|
138
|
+
mountSystem("node-1");
|
|
139
|
+
await settle();
|
|
140
|
+
mocks.build.mockClear();
|
|
141
|
+
mocks.handle.dispose.mockClear();
|
|
142
|
+
mocks.handle.focusBody.mockClear();
|
|
143
|
+
|
|
144
|
+
mocks.engine = {};
|
|
145
|
+
mocks.ready?.();
|
|
146
|
+
await settle();
|
|
147
|
+
expect(mocks.handle.dispose).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(mocks.build).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(mocks.handle.dispose.mock.invocationCallOrder[0])
|
|
150
|
+
.toBeLessThan(mocks.build.mock.invocationCallOrder[0]!);
|
|
151
|
+
expect(mocks.handle.setSelected).toHaveBeenCalledWith("node-1");
|
|
152
|
+
expect(mocks.handle.focusBody).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("disposes the builder on unmount", async () => {
|
|
156
|
+
mountSystem();
|
|
157
|
+
await settle();
|
|
158
|
+
const [{ app }] = mounts;
|
|
159
|
+
app.unmount();
|
|
160
|
+
mounts.splice(0, 1);
|
|
161
|
+
expect(mocks.handle.dispose).toHaveBeenCalledTimes(1);
|
|
162
|
+
});
|
|
163
|
+
});
|