@celestia-island/hikari 0.55.254 → 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/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/HkTitleBar.scss +14 -13
- package/src/components/HkTitleBar.test.tsx +15 -12
- 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", () => {
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HkStarSystem — a reusable star / planet / satellite business scene.
|
|
3
|
+
*
|
|
4
|
+
* The host owns real node data and selection; this component owns the
|
|
5
|
+
* deterministic layout, holographic bodies, data-bound HUD bands, camera
|
|
6
|
+
* follow, labels, minimap markers and every GPU resource it creates. RPC,
|
|
7
|
+
* permissions and node actions deliberately stay in the host product.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
defineComponent,
|
|
12
|
+
onBeforeUnmount,
|
|
13
|
+
shallowRef,
|
|
14
|
+
watch,
|
|
15
|
+
type PropType,
|
|
16
|
+
} from "vue";
|
|
17
|
+
|
|
18
|
+
import HkBoard3D, { type Board3DEngine } from "./HkBoard3D";
|
|
19
|
+
import {
|
|
20
|
+
buildFleetScene,
|
|
21
|
+
type FleetSceneHandle,
|
|
22
|
+
} from "../scene3d/holographicFleetScene";
|
|
23
|
+
import {
|
|
24
|
+
buildHolographicFleetSpec,
|
|
25
|
+
type FleetBodySpec,
|
|
26
|
+
type StarSystemBodyInput,
|
|
27
|
+
} from "../scene3d/holographicFleetSpec";
|
|
28
|
+
import "./HkStarSystem.scss";
|
|
29
|
+
|
|
30
|
+
export default defineComponent({
|
|
31
|
+
name: "HkStarSystem",
|
|
32
|
+
props: {
|
|
33
|
+
bodies: {
|
|
34
|
+
type: Array as PropType<readonly StarSystemBodyInput[]>,
|
|
35
|
+
required: true,
|
|
36
|
+
},
|
|
37
|
+
/** Controlled selection. Remounts restore the cursor without an
|
|
38
|
+
* unsolicited camera flight; an explicit change focuses the body. */
|
|
39
|
+
selectedId: { type: String as PropType<string | null>, default: null },
|
|
40
|
+
/** CSS2D needs real elements. Host products may localize and enrich
|
|
41
|
+
* these chips while the HUD band still uses the body's real fields. */
|
|
42
|
+
makeLabel: {
|
|
43
|
+
type: Function as PropType<(body: FleetBodySpec) => HTMLElement>,
|
|
44
|
+
default: undefined,
|
|
45
|
+
},
|
|
46
|
+
makeCoreLabel: {
|
|
47
|
+
type: Function as PropType<() => HTMLElement>,
|
|
48
|
+
default: undefined,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
emits: {
|
|
52
|
+
"update:selectedId": (_id: string | null) => true,
|
|
53
|
+
},
|
|
54
|
+
setup(props, { emit, expose, slots }) {
|
|
55
|
+
const engineRef = shallowRef<Board3DEngine | null>(null);
|
|
56
|
+
let sceneHandle: FleetSceneHandle | null = null;
|
|
57
|
+
let handleEngine: Board3DEngine | null = null;
|
|
58
|
+
let currentSelection: string | null = null;
|
|
59
|
+
|
|
60
|
+
function dropScene(): void {
|
|
61
|
+
sceneHandle?.dispose();
|
|
62
|
+
sceneHandle = null;
|
|
63
|
+
handleEngine = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function defaultLabel(body: FleetBodySpec): HTMLElement {
|
|
67
|
+
const chip = document.createElement("div");
|
|
68
|
+
chip.className = "hk-star-system-chip";
|
|
69
|
+
chip.textContent = [body.label, body.status, body.detail]
|
|
70
|
+
.filter((part) => part.trim().length > 0)
|
|
71
|
+
.join(" · ");
|
|
72
|
+
chip.addEventListener("click", (event) => {
|
|
73
|
+
event.stopPropagation();
|
|
74
|
+
selectBody(body.id);
|
|
75
|
+
});
|
|
76
|
+
return chip;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function defaultCoreLabel(): HTMLElement {
|
|
80
|
+
const chip = document.createElement("div");
|
|
81
|
+
chip.className = "hk-star-system-chip is-core";
|
|
82
|
+
chip.textContent = "Core";
|
|
83
|
+
return chip;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function selectBody(id: string | null): void {
|
|
87
|
+
const changed = currentSelection !== id;
|
|
88
|
+
currentSelection = id;
|
|
89
|
+
sceneHandle?.setSelected(id);
|
|
90
|
+
// An explicit click on the ALREADY selected body still re-centres it,
|
|
91
|
+
// matching the original fleet scene; only deselect is idempotent.
|
|
92
|
+
if (id) sceneHandle?.focusBody(id);
|
|
93
|
+
else if (changed) sceneHandle?.frameFleet(600);
|
|
94
|
+
emit("update:selectedId", id);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
watch(() => props.selectedId, (id) => {
|
|
98
|
+
if (id === currentSelection) return;
|
|
99
|
+
currentSelection = id;
|
|
100
|
+
sceneHandle?.setSelected(id);
|
|
101
|
+
if (id) sceneHandle?.focusBody(id);
|
|
102
|
+
else sceneHandle?.frameFleet(600);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
watch(
|
|
106
|
+
[engineRef, () => props.bodies],
|
|
107
|
+
([engine]) => {
|
|
108
|
+
if (!engine) return;
|
|
109
|
+
if (!sceneHandle || handleEngine !== engine) {
|
|
110
|
+
dropScene();
|
|
111
|
+
sceneHandle = buildFleetScene(engine, {
|
|
112
|
+
makeLabel: (body) => props.makeLabel?.(body) ?? defaultLabel(body),
|
|
113
|
+
registryCoreLabel: () => props.makeCoreLabel?.() ?? defaultCoreLabel(),
|
|
114
|
+
});
|
|
115
|
+
handleEngine = engine;
|
|
116
|
+
// A fresh board restores only the cursor. Focusing here would
|
|
117
|
+
// hijack a scene → list → scene round trip with a new flight.
|
|
118
|
+
currentSelection = props.selectedId;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const spec = buildHolographicFleetSpec(props.bodies);
|
|
122
|
+
sceneHandle.update(
|
|
123
|
+
spec,
|
|
124
|
+
Object.fromEntries(props.bodies.map((body) => [body.id, !body.online])),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const selected = props.selectedId;
|
|
128
|
+
const stillExists = selected !== null && props.bodies.some((body) => body.id === selected);
|
|
129
|
+
if (selected && !stillExists) {
|
|
130
|
+
// update() has already re-anchored/stopped the old follow.
|
|
131
|
+
currentSelection = null;
|
|
132
|
+
sceneHandle.setSelected(null);
|
|
133
|
+
emit("update:selectedId", null);
|
|
134
|
+
} else {
|
|
135
|
+
currentSelection = selected;
|
|
136
|
+
sceneHandle.setSelected(selected);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
{ immediate: true, deep: true },
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
onBeforeUnmount(dropScene);
|
|
143
|
+
|
|
144
|
+
expose({
|
|
145
|
+
focusBody(id: string) {
|
|
146
|
+
selectBody(id);
|
|
147
|
+
},
|
|
148
|
+
frameFleet(durationMs?: number): boolean {
|
|
149
|
+
return sceneHandle?.frameFleet(durationMs) ?? false;
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return () => (
|
|
154
|
+
<HkBoard3D
|
|
155
|
+
fov={45}
|
|
156
|
+
minDistance={3}
|
|
157
|
+
maxDistance={420}
|
|
158
|
+
transparentBackground
|
|
159
|
+
initialPosition={[22, -19, 34]}
|
|
160
|
+
onReady={(engine: Board3DEngine) => {
|
|
161
|
+
engineRef.value = engine;
|
|
162
|
+
}}
|
|
163
|
+
onObjectClick={selectBody}
|
|
164
|
+
>
|
|
165
|
+
{{
|
|
166
|
+
default: () => slots.default?.(),
|
|
167
|
+
...(slots.fallback ? { fallback: () => slots.fallback?.() } : {}),
|
|
168
|
+
}}
|
|
169
|
+
</HkBoard3D>
|
|
170
|
+
);
|
|
171
|
+
},
|
|
172
|
+
});
|
|
@@ -18,16 +18,17 @@
|
|
|
18
18
|
--hk-tb-focus: rgb(var(--color-primary, 122 162 247) / 0.6);
|
|
19
19
|
--hk-tb-close-hover: #e81123;
|
|
20
20
|
--hk-tb-close-active: #f1707a;
|
|
21
|
-
/*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
/* --hk-tb-close-ink and --hk-tb-icon-size are HOST-CONFIGURABLE, so they
|
|
22
|
+
are read with fallbacks at their use sites instead of being declared
|
|
23
|
+
here: a declaration on .hk-titlebar itself would out-rank a value the
|
|
24
|
+
host sets on :root (local declarations beat inherited ones), which
|
|
25
|
+
made the tokens look configurable while only the component could win.
|
|
26
|
+
The close fill is a FIXED danger red, so its ink default is fixed too:
|
|
27
|
+
deriving it from the theme's on-solid choice (the default dark preset
|
|
28
|
+
picks polar-night ink for its pale primary fills) painted a dark X on
|
|
29
|
+
dark red at roughly 1:1 - the host-visible bug both wowsp and momoi
|
|
30
|
+
filed. */
|
|
27
31
|
--hk-tb-radius-close: 0 8px 0 0;
|
|
28
|
-
/* Caption glyph size; hosts that want a larger brand mark set the token
|
|
29
|
-
instead of overriding the icon rule. */
|
|
30
|
-
--hk-tb-icon-size: 16px;
|
|
31
32
|
|
|
32
33
|
position: fixed;
|
|
33
34
|
top: 0;
|
|
@@ -56,8 +57,8 @@
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
.hk-titlebar-icon {
|
|
59
|
-
width: var(--hk-tb-icon-size);
|
|
60
|
-
height: var(--hk-tb-icon-size);
|
|
60
|
+
width: var(--hk-tb-icon-size, 16px);
|
|
61
|
+
height: var(--hk-tb-icon-size, 16px);
|
|
61
62
|
display: block;
|
|
62
63
|
pointer-events: none;
|
|
63
64
|
}
|
|
@@ -142,12 +143,12 @@
|
|
|
142
143
|
|
|
143
144
|
.hk-titlebar .hk-titlebar-btn-close:hover {
|
|
144
145
|
background: var(--hk-tb-close-hover);
|
|
145
|
-
color: var(--hk-tb-close-ink);
|
|
146
|
+
color: var(--hk-tb-close-ink, #ffffff);
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
.hk-titlebar .hk-titlebar-btn-close:active {
|
|
149
150
|
background: var(--hk-tb-close-active);
|
|
150
|
-
color: var(--hk-tb-close-ink);
|
|
151
|
+
color: var(--hk-tb-close-ink, #ffffff);
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
.hk-titlebar .hk-titlebar-btn:focus-visible {
|
|
@@ -217,17 +217,20 @@ describe("HkTitleBar stylesheet contract", () => {
|
|
|
217
217
|
expect(body).toContain("border-radius: var(--hk-tb-radius-close)");
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
-
it("inks the close fill from its own token, never the theme's on-solid", () => {
|
|
221
|
-
// The fill is a fixed danger red; the ink
|
|
220
|
+
it("inks the close fill from its own host-configurable token, never the theme's on-solid", () => {
|
|
221
|
+
// The fill is a fixed danger red; the ink is fixed with it. The
|
|
222
222
|
// theme-derived --hi-color-text-on-danger painted a dark X on dark red
|
|
223
|
-
// under the default dark preset (the wowsp/momoi field report).
|
|
224
|
-
|
|
225
|
-
|
|
223
|
+
// under the default dark preset (the wowsp/momoi field report). The
|
|
224
|
+
// token is read with its fallback at the use site rather than declared
|
|
225
|
+
// on .hk-titlebar, so a host's :root assignment actually wins - a local
|
|
226
|
+
// declaration would beat the inherited value (the momoi follow-up).
|
|
226
227
|
const hover = ruleBody(css, ".hk-titlebar .hk-titlebar-btn-close:hover");
|
|
227
|
-
expect(hover).toContain("color: var(--hk-tb-close-ink)");
|
|
228
|
+
expect(hover).toContain("color: var(--hk-tb-close-ink, #ffffff)");
|
|
228
229
|
expect(hover).not.toContain("--hi-color-text-on-danger");
|
|
229
230
|
const active = ruleBody(css, ".hk-titlebar .hk-titlebar-btn-close:active");
|
|
230
|
-
expect(active).toContain("color: var(--hk-tb-close-ink)");
|
|
231
|
+
expect(active).toContain("color: var(--hk-tb-close-ink, #ffffff)");
|
|
232
|
+
const scss = readFileSync(resolve(componentDir, "HkTitleBar.scss"), "utf8");
|
|
233
|
+
expect(scss).not.toMatch(/^\s*--hk-tb-close-ink:/m);
|
|
231
234
|
});
|
|
232
235
|
|
|
233
236
|
it("outranks host element rules with caption-scoped buttons", () => {
|
|
@@ -240,11 +243,11 @@ describe("HkTitleBar stylesheet contract", () => {
|
|
|
240
243
|
expect(closeHoverAt).toBeGreaterThan(hoverAt);
|
|
241
244
|
});
|
|
242
245
|
|
|
243
|
-
it("sizes the caption icon through
|
|
244
|
-
const scss = readFileSync(resolve(componentDir, "HkTitleBar.scss"), "utf8");
|
|
245
|
-
expect(scss).toContain("--hk-tb-icon-size:");
|
|
246
|
+
it("sizes the caption icon through a host-configurable token", () => {
|
|
246
247
|
const body = ruleBody(css, ".hk-titlebar-icon");
|
|
247
|
-
expect(body).toContain("width: var(--hk-tb-icon-size)");
|
|
248
|
-
expect(body).toContain("height: var(--hk-tb-icon-size)");
|
|
248
|
+
expect(body).toContain("width: var(--hk-tb-icon-size, 16px)");
|
|
249
|
+
expect(body).toContain("height: var(--hk-tb-icon-size, 16px)");
|
|
250
|
+
const scss = readFileSync(resolve(componentDir, "HkTitleBar.scss"), "utf8");
|
|
251
|
+
expect(scss).not.toMatch(/^\s*--hk-tb-icon-size:/m);
|
|
249
252
|
});
|
|
250
253
|
});
|
package/src/index.ts
CHANGED
|
@@ -822,6 +822,12 @@ export {
|
|
|
822
822
|
BOARD3D_HELPERS_LAYER,
|
|
823
823
|
BOARD3D_MAIN_LAYER,
|
|
824
824
|
} from "./components/HkBoard3D";
|
|
825
|
+
export { default as HkStarSystem } from "./components/HkStarSystem";
|
|
826
|
+
export { default as HStarSystem } from "./components/HkStarSystem";
|
|
827
|
+
export type {
|
|
828
|
+
StarSystemBodyInput as HkStarSystemBodyInput,
|
|
829
|
+
FleetBodySpec as HkStarSystemBodySpec,
|
|
830
|
+
} from "./scene3d/holographicFleetSpec";
|
|
825
831
|
export { default as HkMinimap3D } from "./components/HkMinimap3D";
|
|
826
832
|
export { default as HkTrendChart } from "./components/HkTrendChart";
|
|
827
833
|
export { default as HkTreemap } from "./components/HkTreemap";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import * as THREE from "three";
|
|
3
|
+
|
|
4
|
+
import { fakeAs } from "../test-utils/fakes";
|
|
5
|
+
import {
|
|
6
|
+
hologramHudText,
|
|
7
|
+
makeFleetHologram,
|
|
8
|
+
poseFleetHologram,
|
|
9
|
+
type FleetHologramInput,
|
|
10
|
+
} from "./holographicFleetBody";
|
|
11
|
+
|
|
12
|
+
const INPUT: FleetHologramInput = {
|
|
13
|
+
id: "node-1",
|
|
14
|
+
label: "node-1 engine stack",
|
|
15
|
+
status: "Online",
|
|
16
|
+
detail: "3 endpoints",
|
|
17
|
+
role: "planet",
|
|
18
|
+
radius: 1,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function mockCanvas(): ReturnType<typeof vi.fn> {
|
|
22
|
+
const fillText = vi.fn();
|
|
23
|
+
const gradient = fakeAs<CanvasGradient>({ addColorStop: vi.fn() });
|
|
24
|
+
const context = fakeAs<CanvasRenderingContext2D>({
|
|
25
|
+
clearRect: vi.fn(),
|
|
26
|
+
fillRect: vi.fn(),
|
|
27
|
+
fillText,
|
|
28
|
+
measureText: (text: string) => fakeAs<TextMetrics>({ width: text.length * 24 }),
|
|
29
|
+
createRadialGradient: () => gradient,
|
|
30
|
+
});
|
|
31
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(context);
|
|
32
|
+
return fillText;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("hologramHudText", () => {
|
|
40
|
+
it("contains only the real label, current status and supplied detail", () => {
|
|
41
|
+
expect(hologramHudText(INPUT)).toBe("node-1 engine stack · Online · 3 endpoints");
|
|
42
|
+
expect(hologramHudText({ ...INPUT, status: "Offline", detail: "" }))
|
|
43
|
+
.toBe("node-1 engine stack · Offline");
|
|
44
|
+
expect(hologramHudText({ ...INPUT, label: "", status: "", detail: "" }))
|
|
45
|
+
.toBe("node-1");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("makeFleetHologram", () => {
|
|
50
|
+
it("draws the real data on the cylindrical band and animates each layer", () => {
|
|
51
|
+
const fillText = mockCanvas();
|
|
52
|
+
const hologram = makeFleetHologram(INPUT, new THREE.Color(0x7fb8dd), false);
|
|
53
|
+
|
|
54
|
+
expect(hologram.hudTexture).not.toBeNull();
|
|
55
|
+
const band = hologram.group.getObjectByName("fleet-hologram-hud-band");
|
|
56
|
+
expect(band).toBeInstanceOf(THREE.Mesh);
|
|
57
|
+
expect((band as THREE.Mesh).geometry).toBeInstanceOf(THREE.CylinderGeometry);
|
|
58
|
+
expect(fillText).toHaveBeenCalled();
|
|
59
|
+
for (const call of fillText.mock.calls) {
|
|
60
|
+
expect(call[0]).toBe("node-1 engine stack · Online · 3 endpoints");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Decorative rings and the text band never widen the body's pointer
|
|
64
|
+
// target. Only the central polyhedron keeps the default raycaster.
|
|
65
|
+
const hits: THREE.Intersection[] = [];
|
|
66
|
+
band?.raycast(new THREE.Raycaster(), hits);
|
|
67
|
+
expect(hits).toHaveLength(0);
|
|
68
|
+
|
|
69
|
+
const initialRingY = hologram.rings.map((ring) => ring.pivot.rotation.y);
|
|
70
|
+
poseFleetHologram(hologram, 5, 20);
|
|
71
|
+
expect(hologram.mesh.rotation.y).toBeCloseTo(Math.PI / 2, 6);
|
|
72
|
+
hologram.rings.forEach((ring, index) => {
|
|
73
|
+
expect(ring.pivot.rotation.y).not.toBe(initialRingY[index]);
|
|
74
|
+
});
|
|
75
|
+
expect(hologram.hudTexture?.offset.x).toBeCloseTo(0.09, 6);
|
|
76
|
+
poseFleetHologram(hologram, 10, 20);
|
|
77
|
+
expect(hologram.hudTexture?.offset.x).toBeCloseTo(0.18, 6);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("uses three rings for a hub and two for a standard node", () => {
|
|
81
|
+
mockCanvas();
|
|
82
|
+
const hub = makeFleetHologram({ ...INPUT, role: "star" }, new THREE.Color(), false);
|
|
83
|
+
const planet = makeFleetHologram(INPUT, new THREE.Color(), false);
|
|
84
|
+
expect(hub.rings).toHaveLength(3);
|
|
85
|
+
expect(planet.rings).toHaveLength(2);
|
|
86
|
+
expect(hub.mesh.geometry).toBeInstanceOf(THREE.IcosahedronGeometry);
|
|
87
|
+
expect(planet.mesh.geometry).toBeInstanceOf(THREE.DodecahedronGeometry);
|
|
88
|
+
});
|
|
89
|
+
});
|