@celestia-island/hikari 0.55.262 → 0.55.264
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/HkBoard3D.controls.test.tsx +99 -0
- package/src/components/HkBoard3D.tsx +44 -4
- package/src/components/HkSolarLocationSetting.tsx +3 -3
- package/src/components/HkTimeZoneGlobe.board.test.tsx +263 -0
- package/src/components/HkTimeZoneGlobe.scss +42 -13
- package/src/components/HkTimeZoneGlobe.test.tsx +33 -0
- package/src/components/HkTimeZoneGlobe.tsx +66 -34
- package/src/scene3d/holographicFleetBody.test.ts +55 -0
- package/src/scene3d/holographicFleetBody.ts +26 -10
- package/src/scene3d/holographicFleetScene.test.ts +30 -0
- package/src/scene3d/holographicFleetScene.ts +6 -2
- package/src/theme/solarGlobeMap.ts +1 -1
- package/src/theme/solarTimeZones.ts +6 -6
package/package.json
CHANGED
|
@@ -138,3 +138,102 @@ describe("HkBoard3D × real OrbitControls", () => {
|
|
|
138
138
|
expect(engine!.controls.maxDistance).toBe(80);
|
|
139
139
|
});
|
|
140
140
|
});
|
|
141
|
+
|
|
142
|
+
/* The orbit ELEVATION window: the solar globe pins min === max to become a
|
|
143
|
+
* turntable (user direction 2026-11-01: the sphere must only spin about
|
|
144
|
+
* its vertical axis, never tip / roll). The distance-window suite above
|
|
145
|
+
* pins the same normalisation discipline for the other orbit axis. */
|
|
146
|
+
const DEG = Math.PI / 180;
|
|
147
|
+
const RING_TILT_DEG = 72;
|
|
148
|
+
|
|
149
|
+
describe("HkBoard3D orbit elevation window", () => {
|
|
150
|
+
it("pins the controls' polar angle from the equal prop pair", async () => {
|
|
151
|
+
let engine: Board3DEngine | null = null;
|
|
152
|
+
mountBoard(
|
|
153
|
+
{ interactive: true, minPolarAngle: RING_TILT_DEG, maxPolarAngle: RING_TILT_DEG },
|
|
154
|
+
(e) => (engine = e),
|
|
155
|
+
);
|
|
156
|
+
await nextTick();
|
|
157
|
+
await tickFrames();
|
|
158
|
+
|
|
159
|
+
expect(engine!.controls.minPolarAngle).toBeCloseTo(RING_TILT_DEG * DEG, 6);
|
|
160
|
+
expect(engine!.controls.maxPolarAngle).toBeCloseTo(RING_TILT_DEG * DEG, 6);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("normalises a reversed, out-of-range or non-finite window", async () => {
|
|
164
|
+
let engine: Board3DEngine | null = null;
|
|
165
|
+
mountBoard(
|
|
166
|
+
// 120 > -30 (reversed) and both bounds are outside the sphere.
|
|
167
|
+
{ interactive: true, minPolarAngle: 120, maxPolarAngle: -30 },
|
|
168
|
+
(e) => (engine = e),
|
|
169
|
+
);
|
|
170
|
+
await nextTick();
|
|
171
|
+
await tickFrames();
|
|
172
|
+
|
|
173
|
+
expect(engine!.controls.minPolarAngle).toBe(0);
|
|
174
|
+
expect(engine!.controls.maxPolarAngle).toBe(120 * DEG);
|
|
175
|
+
|
|
176
|
+
mountBoard(
|
|
177
|
+
{ interactive: true, minPolarAngle: Number.NaN, maxPolarAngle: 999 },
|
|
178
|
+
(e) => (engine = e),
|
|
179
|
+
);
|
|
180
|
+
await nextTick();
|
|
181
|
+
await tickFrames();
|
|
182
|
+
expect(engine!.controls.minPolarAngle).toBe(0);
|
|
183
|
+
expect(engine!.controls.maxPolarAngle).toBeCloseTo(Math.PI, 6);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("relaxes the elevation window when the board is not interactive", async () => {
|
|
187
|
+
let engine: Board3DEngine | null = null;
|
|
188
|
+
mountBoard(
|
|
189
|
+
{ interactive: false, minPolarAngle: RING_TILT_DEG, maxPolarAngle: RING_TILT_DEG },
|
|
190
|
+
(e) => (engine = e),
|
|
191
|
+
);
|
|
192
|
+
await nextTick();
|
|
193
|
+
await tickFrames();
|
|
194
|
+
|
|
195
|
+
// Same contract as the distance window: an externally driven camera
|
|
196
|
+
// is never fought, so a locked elevation must not survive here.
|
|
197
|
+
expect(engine!.controls.minPolarAngle).toBe(0);
|
|
198
|
+
expect(engine!.controls.maxPolarAngle).toBeCloseTo(Math.PI, 6);
|
|
199
|
+
engine!.camera.position.set(0, 6, 0);
|
|
200
|
+
engine!.controls.target.set(0, 0, 0);
|
|
201
|
+
engine!.controls.update();
|
|
202
|
+
expect(engine!.camera.position.y).toBeCloseTo(6, 5);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("keeps the elevation fixed while azimuth alone can orbit (turntable)", async () => {
|
|
206
|
+
let engine: Board3DEngine | null = null;
|
|
207
|
+
mountBoard(
|
|
208
|
+
{ interactive: true, minPolarAngle: RING_TILT_DEG, maxPolarAngle: RING_TILT_DEG },
|
|
209
|
+
(e) => (engine = e),
|
|
210
|
+
);
|
|
211
|
+
await nextTick();
|
|
212
|
+
await tickFrames();
|
|
213
|
+
|
|
214
|
+
const controls = engine!.controls;
|
|
215
|
+
const camera = engine!.camera;
|
|
216
|
+
controls.enableDamping = false;
|
|
217
|
+
|
|
218
|
+
// An external pose far above the ring snaps down onto it: the eye
|
|
219
|
+
// cannot climb toward the pole.
|
|
220
|
+
camera.position.set(0, 6, 0);
|
|
221
|
+
controls.target.set(0, 0, 0);
|
|
222
|
+
controls.update();
|
|
223
|
+
const ringRadius = camera.position.length();
|
|
224
|
+
const fixedY = ringRadius * Math.cos(RING_TILT_DEG * DEG);
|
|
225
|
+
expect(camera.position.y).toBeCloseTo(fixedY, 5);
|
|
226
|
+
|
|
227
|
+
// A vertical orbit input is absorbed; a horizontal one still turns.
|
|
228
|
+
controls.rotateUp(0.8);
|
|
229
|
+
controls.update();
|
|
230
|
+
expect(camera.position.y).toBeCloseTo(fixedY, 5);
|
|
231
|
+
const azimuthBefore = Math.atan2(camera.position.x, camera.position.z);
|
|
232
|
+
controls.rotateLeft(0.6);
|
|
233
|
+
controls.update();
|
|
234
|
+
expect(camera.position.y).toBeCloseTo(fixedY, 5);
|
|
235
|
+
expect(
|
|
236
|
+
Math.abs(Math.atan2(camera.position.x, camera.position.z) - azimuthBefore),
|
|
237
|
+
).toBeGreaterThan(0.3);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
* stereoscopic minimap; the page owns the content:
|
|
9
9
|
*
|
|
10
10
|
* - CAMERA: perspective + OrbitControls (damped orbit / wheel zoom /
|
|
11
|
-
* right-or-two-finger pan), distance
|
|
12
|
-
* animated `flyTo
|
|
11
|
+
* right-or-two-finger pan), orbit distance AND elevation windows,
|
|
12
|
+
* `frameAll` fit and animated `flyTo` — an equal
|
|
13
|
+
* `minPolarAngle`/`maxPolarAngle` pair locks the eye's elevation, so
|
|
14
|
+
* azimuth is the only ORBIT axis left; with panning off the scene
|
|
15
|
+
* then spins about the vertical axis (the turntable behaviour a globe
|
|
16
|
+
* picker needs);
|
|
13
17
|
* - CONTENT: `engine.setObject(id, def)` registers an Object3D as
|
|
14
18
|
* addressable content — it joins framing, minimap markers and (when
|
|
15
19
|
* `pickable`) raycast picking; `label` attaches a CSS2D chip that
|
|
@@ -326,6 +330,18 @@ function selectionFrameColor(): THREE.Color {
|
|
|
326
330
|
return cssRgbToColor(readCssColor("--color-primary", "21 101 192"));
|
|
327
331
|
}
|
|
328
332
|
|
|
333
|
+
/** Orbit elevation window for the `minPolarAngle` / `maxPolarAngle` props:
|
|
334
|
+
* degrees in, radians out, clamped to the sphere's 0…180° range. A
|
|
335
|
+
* non-finite bound falls back to the OrbitControls default, and a
|
|
336
|
+
* reversed window normalises (same rule as the distance window) instead
|
|
337
|
+
* of leaving OrbitControls with min > max. */
|
|
338
|
+
function polarWindow(minDeg: number, maxDeg: number): { min: number; max: number } {
|
|
339
|
+
const toRad = (deg: number) => (Math.min(180, Math.max(0, deg)) * Math.PI) / 180;
|
|
340
|
+
const min = Number.isFinite(minDeg) ? toRad(minDeg) : 0;
|
|
341
|
+
const max = Number.isFinite(maxDeg) ? toRad(maxDeg) : Math.PI;
|
|
342
|
+
return { min: Math.min(min, max), max: Math.max(min, max) };
|
|
343
|
+
}
|
|
344
|
+
|
|
329
345
|
export default defineComponent({
|
|
330
346
|
name: "HkBoard3D",
|
|
331
347
|
props: {
|
|
@@ -358,6 +374,19 @@ export default defineComponent({
|
|
|
358
374
|
maxDistance: { type: Number, default: 600 },
|
|
359
375
|
/** Wire two-finger / right-drag panning. STATIC after mount. */
|
|
360
376
|
enablePan: { type: Boolean, default: true },
|
|
377
|
+
/** Orbit elevation window in DEGREES from the scene's +Y axis
|
|
378
|
+
* (OrbitControls' polar angle). STATIC after mount (applied to the
|
|
379
|
+
* controls once; at mount it is relaxed to 0…180° when the board
|
|
380
|
+
* mounts non-interactive, because an externally driven camera is
|
|
381
|
+
* never fought — the same rule as the distance window, and like that
|
|
382
|
+
* window it is NOT re-applied on a runtime `interactive` flip). The
|
|
383
|
+
* pair pins how far the eye
|
|
384
|
+
* may climb toward either pole; EQUAL values lock the elevation
|
|
385
|
+
* outright and leave azimuth as the only orbit axis — the turntable
|
|
386
|
+
* behaviour a globe or instrument board needs. A reversed window
|
|
387
|
+
* normalises; the default is the full 0…180° sphere. */
|
|
388
|
+
minPolarAngle: { type: Number, default: 0 },
|
|
389
|
+
maxPolarAngle: { type: Number, default: 180 },
|
|
361
390
|
/** Options for the lighting rig created on first `applyLighting`
|
|
362
391
|
* (ambient floor, shadow map size). */
|
|
363
392
|
lighting: {
|
|
@@ -756,15 +785,26 @@ export default defineComponent({
|
|
|
756
785
|
// at min forever and defeating every frameAll. Normalise instead.
|
|
757
786
|
controls.minDistance = Math.min(props.minDistance, props.maxDistance);
|
|
758
787
|
controls.maxDistance = Math.max(props.minDistance, props.maxDistance);
|
|
788
|
+
// The elevation window: same normalisation discipline as the
|
|
789
|
+
// distance window above, in degrees at the prop boundary. Equal
|
|
790
|
+
// bounds pin the eye to one elevation (turntable/yaw-only orbit).
|
|
791
|
+
const polar = polarWindow(props.minPolarAngle, props.maxPolarAngle);
|
|
792
|
+
controls.minPolarAngle = polar.min;
|
|
793
|
+
controls.maxPolarAngle = polar.max;
|
|
759
794
|
controls.enablePan = props.enablePan;
|
|
760
795
|
controls.target.set(initTarget[0], initTarget[1], initTarget[2]);
|
|
761
796
|
controls.enabled = props.interactive;
|
|
762
797
|
if (!props.interactive) {
|
|
763
798
|
// An externally-driven camera (the board is just its renderer)
|
|
764
|
-
// must not be fought by the orbit
|
|
765
|
-
//
|
|
799
|
+
// must not be fought by the orbit windows — the panel flies to
|
|
800
|
+
// poses far beyond any interactive zoom range, from any
|
|
801
|
+
// elevation. OrbitControls' update() clamps BOTH bounds every
|
|
802
|
+
// frame regardless of `enabled`, so the elevation window has to
|
|
803
|
+
// be relaxed here too, not just the radius.
|
|
766
804
|
controls.minDistance = 0;
|
|
767
805
|
controls.maxDistance = Infinity;
|
|
806
|
+
controls.minPolarAngle = 0;
|
|
807
|
+
controls.maxPolarAngle = Math.PI;
|
|
768
808
|
}
|
|
769
809
|
controls.update();
|
|
770
810
|
|
|
@@ -233,9 +233,9 @@ export const HkSolarLocationSetting = defineComponent({
|
|
|
233
233
|
<div class="hk-solar-manual">
|
|
234
234
|
{/* The globe picker (2026-10-31): 24 equal time-sector
|
|
235
235
|
wedges on a spinning, borderless world-plates sphere,
|
|
236
|
-
|
|
237
|
-
replaced listed hundreds of raw zone ids.
|
|
238
|
-
writes the same draftTimeZone the engine applies. */}
|
|
236
|
+
an always-on top-center readout included — the IANA
|
|
237
|
+
dropdown it replaced listed hundreds of raw zone ids.
|
|
238
|
+
Still writes the same draftTimeZone the engine applies. */}
|
|
239
239
|
<HkTimeZoneGlobe
|
|
240
240
|
modelValue={draftTimeZone.value}
|
|
241
241
|
onUpdate:modelValue={onSelectTimeZone}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// AI-generated: developed with the ZCode agent (model GLM-5.3); human-directed.
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createApp, h, nextTick, ref, type Ref } from "vue";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HkTimeZoneGlobe × HkBoard3D — the picker's WEBGL branch contract.
|
|
7
|
+
*
|
|
8
|
+
* The real board cannot init in happy-dom (no WebGL), so this suite stubs
|
|
9
|
+
* it and keeps the board's whole props surface addressable. Two product
|
|
10
|
+
* directions from 2026-11-01 are template-level promises and belong here:
|
|
11
|
+
*
|
|
12
|
+
* - TURNTABLE: the globe passes equal min/max polar bounds (90° - the
|
|
13
|
+
* eye's 18° ring tilt) so the board can only spin about the vertical
|
|
14
|
+
* axis — the sphere never tips toward a pole or rolls;
|
|
15
|
+
* - READOUT: the SELECTED sector's chip is the globe's own always-on
|
|
16
|
+
* layer pinned to the top-center of the stage — not a pointer-following
|
|
17
|
+
* tooltip (which sat under the finger on a touch drag), not gated on
|
|
18
|
+
* hover, and not rewritten by hover either (a drag re-selects without
|
|
19
|
+
* moving the pointer, so a hover preview would go stale as it is read).
|
|
20
|
+
*
|
|
21
|
+
* The no-WebGL assertions (fallback list, readout stepping aside) live in
|
|
22
|
+
* HkTimeZoneGlobe.test.tsx, which mounts the real board.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
interface CapturedBoard {
|
|
26
|
+
minPolarAngle?: number;
|
|
27
|
+
maxPolarAngle?: number;
|
|
28
|
+
interactive?: boolean;
|
|
29
|
+
minimap?: boolean;
|
|
30
|
+
minimapCollapsible?: boolean;
|
|
31
|
+
minimapDefaultCollapsed?: boolean;
|
|
32
|
+
enablePan?: boolean;
|
|
33
|
+
minDistance?: number;
|
|
34
|
+
maxDistance?: number;
|
|
35
|
+
/** The globe's onObjectHover listener (preview a wedge). */
|
|
36
|
+
onObjectHover: ((id: string | null) => void) | null;
|
|
37
|
+
/** The globe's onError listener (no-WebGL report). */
|
|
38
|
+
onError: ((message: string) => void) | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const captured = vi.hoisted(() => ({ boards: [] as CapturedBoard[] }));
|
|
42
|
+
|
|
43
|
+
vi.mock("./HkBoard3D", async () => {
|
|
44
|
+
const vue = await import("vue");
|
|
45
|
+
/** Narrow an undeclared-listener attr without a double cast. */
|
|
46
|
+
function listenerOf<T extends (...args: never[]) => void>(value: unknown): T | null {
|
|
47
|
+
return typeof value === "function" ? (value as T) : null;
|
|
48
|
+
}
|
|
49
|
+
const BoardStub = vue.defineComponent({
|
|
50
|
+
name: "HkBoard3D",
|
|
51
|
+
props: {
|
|
52
|
+
interactive: Boolean,
|
|
53
|
+
minimap: Boolean,
|
|
54
|
+
minimapCollapsible: Boolean,
|
|
55
|
+
minimapDefaultCollapsed: Boolean,
|
|
56
|
+
hoverBox: Boolean,
|
|
57
|
+
transparentBackground: Boolean,
|
|
58
|
+
enablePan: Boolean,
|
|
59
|
+
minPolarAngle: Number,
|
|
60
|
+
maxPolarAngle: Number,
|
|
61
|
+
fov: Number,
|
|
62
|
+
minDistance: Number,
|
|
63
|
+
maxDistance: Number,
|
|
64
|
+
initialPosition: Array,
|
|
65
|
+
initialTarget: Array,
|
|
66
|
+
},
|
|
67
|
+
// Deliberately NO `emits` declaration: the globe's listeners then land
|
|
68
|
+
// in `attrs`, which is exactly how this suite reaches onObjectHover /
|
|
69
|
+
// onError without the stub ever calling emit().
|
|
70
|
+
setup(props, { attrs }) {
|
|
71
|
+
captured.boards.push({
|
|
72
|
+
minPolarAngle: props.minPolarAngle,
|
|
73
|
+
maxPolarAngle: props.maxPolarAngle,
|
|
74
|
+
interactive: props.interactive,
|
|
75
|
+
minimap: props.minimap,
|
|
76
|
+
minimapCollapsible: props.minimapCollapsible,
|
|
77
|
+
minimapDefaultCollapsed: props.minimapDefaultCollapsed,
|
|
78
|
+
enablePan: props.enablePan,
|
|
79
|
+
minDistance: props.minDistance,
|
|
80
|
+
maxDistance: props.maxDistance,
|
|
81
|
+
onObjectHover: listenerOf<(id: string | null) => void>(attrs.onObjectHover),
|
|
82
|
+
onError: listenerOf<(message: string) => void>(attrs.onError),
|
|
83
|
+
});
|
|
84
|
+
return () => null;
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
return { default: BoardStub };
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
import HkTimeZoneGlobe from "./HkTimeZoneGlobe";
|
|
91
|
+
import { setLocale } from "../i18n/context";
|
|
92
|
+
|
|
93
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
94
|
+
|
|
95
|
+
function mountGlobe(modelValue = "Asia/Shanghai"): {
|
|
96
|
+
container: HTMLElement;
|
|
97
|
+
zone: Ref<string>;
|
|
98
|
+
} {
|
|
99
|
+
captured.boards.length = 0;
|
|
100
|
+
const container = document.createElement("div");
|
|
101
|
+
document.body.appendChild(container);
|
|
102
|
+
// A live modelValue ref, so a test can drive a re-selection the way the
|
|
103
|
+
// host does (HkSolarLocationSetting writes draftTimeZone).
|
|
104
|
+
const zone = ref(modelValue);
|
|
105
|
+
const app = createApp({
|
|
106
|
+
render: () =>
|
|
107
|
+
h(HkTimeZoneGlobe, {
|
|
108
|
+
modelValue: zone.value,
|
|
109
|
+
"onUpdate:modelValue": (next: string) => {
|
|
110
|
+
zone.value = next;
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
app.mount(container);
|
|
115
|
+
mounts.push({ app, container });
|
|
116
|
+
return { container, zone };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function settle(): Promise<void> {
|
|
120
|
+
await nextTick();
|
|
121
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
122
|
+
await nextTick();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function board(): CapturedBoard {
|
|
126
|
+
expect(captured.boards, "the globe mounted one board").toHaveLength(1);
|
|
127
|
+
return captured.boards[0]!;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readout(): HTMLElement | null {
|
|
131
|
+
return document.body.querySelector<HTMLElement>('[data-test="hk-timezone-globe-readout"]');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
localStorage.clear();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
afterEach(async () => {
|
|
139
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
140
|
+
app.unmount();
|
|
141
|
+
container.remove();
|
|
142
|
+
}
|
|
143
|
+
await setLocale("en");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("HkTimeZoneGlobe turntable contract", () => {
|
|
147
|
+
it("locks the board's orbit elevation to the eye ring (equal polar bounds)", async () => {
|
|
148
|
+
mountGlobe();
|
|
149
|
+
await settle();
|
|
150
|
+
|
|
151
|
+
const { minPolarAngle, maxPolarAngle, interactive } = board();
|
|
152
|
+
// 90° - EYE_TILT_DEG (18°) = 72°, as a DEGREE-valued board prop.
|
|
153
|
+
expect(minPolarAngle).toBe(72);
|
|
154
|
+
expect(maxPolarAngle).toBe(72);
|
|
155
|
+
expect(minPolarAngle).toBe(maxPolarAngle);
|
|
156
|
+
// The lock only binds on an INTERACTIVE board: a non-interactive board
|
|
157
|
+
// deliberately relaxes the elevation window for its external driver,
|
|
158
|
+
// so the globe must never mount as one.
|
|
159
|
+
expect(interactive).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("keeps the minimap (collapsible, folded by default) and disables panning", async () => {
|
|
163
|
+
mountGlobe();
|
|
164
|
+
await settle();
|
|
165
|
+
|
|
166
|
+
const b = board();
|
|
167
|
+
expect(b.minimap).toBe(true);
|
|
168
|
+
expect(b.minimapCollapsible).toBe(true);
|
|
169
|
+
expect(b.minimapDefaultCollapsed).toBe(true);
|
|
170
|
+
expect(b.enablePan).toBe(false);
|
|
171
|
+
// The picker still owns its own distance window and framing.
|
|
172
|
+
expect(b.minDistance).toBe(2.15);
|
|
173
|
+
expect(b.maxDistance).toBe(3.6);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("HkTimeZoneGlobe pinned readout", () => {
|
|
178
|
+
it("shows the selected sector with no hover needed (always on)", async () => {
|
|
179
|
+
mountGlobe("Asia/Shanghai");
|
|
180
|
+
await settle();
|
|
181
|
+
|
|
182
|
+
const el = readout();
|
|
183
|
+
expect(el, "the readout renders without any pointer interaction").toBeTruthy();
|
|
184
|
+
expect(el!.textContent).toContain("UTC+8");
|
|
185
|
+
expect(el!.textContent).toContain("Asia/Shanghai");
|
|
186
|
+
// A city from the SELECTED band's second line — "Singapore" is not a
|
|
187
|
+
// substring of the zone id, so only the cities row can satisfy it
|
|
188
|
+
// ("Shanghai" would have been satisfied by "Asia/Shanghai").
|
|
189
|
+
const cities = el!.querySelector<HTMLElement>(".hk-timezone-globe__readout-cities");
|
|
190
|
+
expect(cities, "the cities line renders").toBeTruthy();
|
|
191
|
+
expect(cities!.textContent).toContain("Singapore");
|
|
192
|
+
// A live region, so a COMMITTED re-pick is spoken; hover never
|
|
193
|
+
// rewrites the chip, so hovering cannot flood the announce queue.
|
|
194
|
+
expect(el!.getAttribute("role")).toBe("status");
|
|
195
|
+
expect(el!.getAttribute("aria-live")).toBe("polite");
|
|
196
|
+
expect(el!.getAttribute("aria-atomic")).toBe("true");
|
|
197
|
+
// The old pointer-following tooltip is gone for good.
|
|
198
|
+
expect(document.body.querySelector('[data-test="hk-timezone-globe-tooltip"]')).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("follows a committed re-selection when the host writes a new zone", async () => {
|
|
202
|
+
const { zone } = mountGlobe("Asia/Shanghai");
|
|
203
|
+
await settle();
|
|
204
|
+
expect(readout()!.textContent).toContain("Asia/Shanghai");
|
|
205
|
+
|
|
206
|
+
// The host's write path (draftTimeZone → modelValue), not a click: the
|
|
207
|
+
// chip must re-render on the new band, not stay frozen at mount.
|
|
208
|
+
zone.value = "Africa/Cairo";
|
|
209
|
+
await settle();
|
|
210
|
+
expect(readout()!.textContent).toContain("Africa/Cairo");
|
|
211
|
+
expect(readout()!.textContent).toContain("UTC+2");
|
|
212
|
+
expect(readout()!.textContent).not.toContain("Asia/Shanghai");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("keeps showing the committed selection while a wedge is hovered", async () => {
|
|
216
|
+
mountGlobe("Asia/Shanghai");
|
|
217
|
+
await settle();
|
|
218
|
+
|
|
219
|
+
const onObjectHover = board().onObjectHover;
|
|
220
|
+
expect(onObjectHover, "the globe wired the board's hover event").toBeTruthy();
|
|
221
|
+
onObjectHover!("sector-20"); // offset +9 → Asia/Tokyo
|
|
222
|
+
await settle();
|
|
223
|
+
// The chip is a selection statement: hover only tints the wedge.
|
|
224
|
+
expect(readout()!.textContent).toContain("Asia/Shanghai");
|
|
225
|
+
expect(readout()!.textContent).not.toContain("Asia/Tokyo");
|
|
226
|
+
|
|
227
|
+
onObjectHover!(null);
|
|
228
|
+
await settle();
|
|
229
|
+
expect(readout()!.textContent).toContain("Asia/Shanghai");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("does not repeat an English band name that IS its offset label", async () => {
|
|
233
|
+
await setLocale("en"); // explicit: this contract is locale-dependent
|
|
234
|
+
mountGlobe("Asia/Shanghai");
|
|
235
|
+
await settle();
|
|
236
|
+
|
|
237
|
+
const text = readout()!.textContent ?? "";
|
|
238
|
+
expect(text.match(/UTC\+8/g)).toHaveLength(1);
|
|
239
|
+
expect(text).toContain("Asia/Shanghai");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("keeps the translated band name where the locale names the band (zh-Hans)", async () => {
|
|
243
|
+
await setLocale("zh-Hans");
|
|
244
|
+
mountGlobe("Asia/Shanghai");
|
|
245
|
+
await settle();
|
|
246
|
+
|
|
247
|
+
const text = readout()!.textContent ?? "";
|
|
248
|
+
expect(text).toContain("UTC+8");
|
|
249
|
+
expect(text).toContain("东八区");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("steps aside when the board reports no WebGL (the fallback list is the interface)", async () => {
|
|
253
|
+
mountGlobe("Asia/Shanghai");
|
|
254
|
+
await settle();
|
|
255
|
+
expect(readout()).toBeTruthy();
|
|
256
|
+
|
|
257
|
+
const onError = board().onError;
|
|
258
|
+
expect(onError, "the globe wired the board's error event").toBeTruthy();
|
|
259
|
+
onError!("no webgl (test)");
|
|
260
|
+
await settle();
|
|
261
|
+
expect(readout()).toBeNull();
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/* HkTimeZoneGlobe — the manual zone picker's globe stage,
|
|
2
|
-
* no-WebGL fallback list. AI-generated: developed with the
|
|
3
|
-
* (model GLM-5.3); human-directed. */
|
|
1
|
+
/* HkTimeZoneGlobe — the manual zone picker's globe stage, always-on
|
|
2
|
+
* readout and no-WebGL fallback list. AI-generated: developed with the
|
|
3
|
+
* ZCode agent (model GLM-5.3); human-directed. */
|
|
4
4
|
|
|
5
5
|
.hk-timezone-globe {
|
|
6
6
|
display: flex;
|
|
@@ -23,35 +23,64 @@
|
|
|
23
23
|
touch-action: none;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
/* The always-on sector readout: pinned to the TOP-CENTER of the stage
|
|
27
|
+
* (user direction 2026-11-01). It replaced the pointer-following
|
|
28
|
+
* tooltip, which on a touch screen sat under the very finger dragging
|
|
29
|
+
* the globe. A passive HUD layer — pointer-events none, so a drag that
|
|
30
|
+
* begins on the chip still orbits. */
|
|
31
|
+
.hk-timezone-globe__readout {
|
|
27
32
|
position: absolute;
|
|
33
|
+
top: var(--space-6, 0.375rem);
|
|
34
|
+
left: 50%;
|
|
28
35
|
z-index: 2;
|
|
29
|
-
|
|
36
|
+
transform: translateX(-50%);
|
|
30
37
|
display: flex;
|
|
31
38
|
flex-direction: column;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
align-items: center;
|
|
40
|
+
gap: 1px;
|
|
41
|
+
max-width: calc(100% - var(--space-16, 1rem));
|
|
42
|
+
padding: var(--space-4, 0.25rem) var(--space-10, 0.625rem);
|
|
35
43
|
border: 1px solid rgb(var(--color-muted) / 0.35);
|
|
36
44
|
border-radius: var(--radius-sm, 0.375rem);
|
|
37
|
-
background: rgb(var(--color-background, 22 24 30) / 0.
|
|
45
|
+
background: rgb(var(--color-background, 22 24 30) / 0.88);
|
|
38
46
|
color: inherit;
|
|
39
47
|
font-size: var(--text-xs, 0.75rem);
|
|
40
|
-
line-height: 1.
|
|
48
|
+
line-height: 1.35;
|
|
49
|
+
text-align: center;
|
|
50
|
+
pointer-events: none;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.hk-timezone-globe__readout-head {
|
|
54
|
+
display: flex;
|
|
55
|
+
flex-wrap: wrap;
|
|
56
|
+
align-items: baseline;
|
|
57
|
+
justify-content: center;
|
|
58
|
+
gap: 0 var(--space-6, 0.375rem);
|
|
59
|
+
white-space: nowrap;
|
|
41
60
|
}
|
|
42
61
|
|
|
43
|
-
.hk-timezone-
|
|
62
|
+
.hk-timezone-globe__readout-offset {
|
|
44
63
|
color: rgb(var(--color-text-secondary, var(--color-muted)));
|
|
45
64
|
font-weight: 600;
|
|
65
|
+
font-variant-numeric: tabular-nums;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.hk-timezone-globe__readout-name {
|
|
69
|
+
font-weight: 600;
|
|
46
70
|
}
|
|
47
71
|
|
|
48
|
-
.hk-timezone-
|
|
72
|
+
.hk-timezone-globe__readout-zone {
|
|
73
|
+
color: rgb(var(--color-text-secondary, var(--color-muted)));
|
|
49
74
|
font-family: var(--font-mono, ui-monospace, monospace);
|
|
50
75
|
font-size: var(--text-xs, 0.75rem);
|
|
51
76
|
}
|
|
52
77
|
|
|
53
|
-
.hk-timezone-
|
|
78
|
+
.hk-timezone-globe__readout-cities {
|
|
79
|
+
max-width: 100%;
|
|
80
|
+
overflow: hidden;
|
|
54
81
|
color: rgb(var(--color-text-secondary, var(--color-muted)));
|
|
82
|
+
text-overflow: ellipsis;
|
|
83
|
+
white-space: nowrap;
|
|
55
84
|
}
|
|
56
85
|
|
|
57
86
|
.hk-timezone-globe__hint {
|
|
@@ -116,6 +116,9 @@ describe("HkTimeZoneGlobe fallback list", () => {
|
|
|
116
116
|
const root = document.body.querySelector<HTMLElement>('[data-test="hk-timezone-globe"]')!;
|
|
117
117
|
expect(root.getAttribute("aria-label")).toBe("Time zone globe");
|
|
118
118
|
expect(root.textContent).toContain("Drag to spin the globe");
|
|
119
|
+
// The pinned HUD is a canvas affordance: without WebGL the list IS the
|
|
120
|
+
// interface, so the chip must not cover its rows.
|
|
121
|
+
expect(document.body.querySelector('[data-test="hk-timezone-globe-readout"]')).toBeNull();
|
|
119
122
|
});
|
|
120
123
|
|
|
121
124
|
it("flows zh-Hans through the sector names and city lists", async () => {
|
|
@@ -140,6 +143,36 @@ describe("HkTimeZoneGlobe look contract (source-level)", () => {
|
|
|
140
143
|
expect(src).toContain("minimapCollapsible");
|
|
141
144
|
expect(src).toContain("minimapDefaultCollapsed");
|
|
142
145
|
});
|
|
146
|
+
|
|
147
|
+
it("pins the readout to the stage's TOP-CENTER, pointer-transparent (user direction 2026-11-01)", () => {
|
|
148
|
+
const scss = readFileSync(join(import.meta.dirname, "HkTimeZoneGlobe.scss"), "utf8");
|
|
149
|
+
const block = (selector: string): string => {
|
|
150
|
+
const start = scss.indexOf(selector + " {");
|
|
151
|
+
expect(start, selector + " exists").toBeGreaterThanOrEqual(0);
|
|
152
|
+
const end = scss.indexOf("\n}", start);
|
|
153
|
+
expect(end, selector + " closes").toBeGreaterThan(start);
|
|
154
|
+
return scss.slice(start, end);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// The readout is absolutely positioned, so its containing block must
|
|
158
|
+
// stay the stage.
|
|
159
|
+
expect(block(".hk-timezone-globe__stage")).toContain("position: relative");
|
|
160
|
+
|
|
161
|
+
const readout = block(".hk-timezone-globe__readout");
|
|
162
|
+
expect(readout, "a HUD layer over the canvas").toContain("position: absolute");
|
|
163
|
+
expect(readout, "top-anchored").toMatch(/top:\s*var\(--space-6/);
|
|
164
|
+
expect(readout, "horizontally centered").toContain("left: 50%");
|
|
165
|
+
expect(readout, "horizontally centered").toContain("transform: translateX(-50%)");
|
|
166
|
+
// The board paints an opaque surface later in the DOM, so the chip is
|
|
167
|
+
// only visible while it stacks above it.
|
|
168
|
+
expect(readout, "above the board surface").toMatch(/z-index:\s*[1-9]/);
|
|
169
|
+
// The chip sits over the very surface the user drags; a drag that
|
|
170
|
+
// begins on it must still reach the canvas.
|
|
171
|
+
expect(readout, "a passive layer").toContain("pointer-events: none");
|
|
172
|
+
|
|
173
|
+
// A long city row must ellipsize inside the chip, not escape it.
|
|
174
|
+
expect(block(".hk-timezone-globe__readout-cities")).toContain("text-overflow: ellipsis");
|
|
175
|
+
});
|
|
143
176
|
});
|
|
144
177
|
describe("minimap reset icon contract (user direction 2026-10-31)", () => {
|
|
145
178
|
it("reset buttons use RotateCcw, never the expand-looking Maximize2", () => {
|
|
@@ -13,14 +13,26 @@
|
|
|
13
13
|
* white-alpha, the material tints it with the live theme accent, so
|
|
14
14
|
* one asset serves dark and light themes;
|
|
15
15
|
* - SECTORS: 24 pickable wedges of 15 deg longitude (theme/
|
|
16
|
-
* solarTimeZones.ts) — drag
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* solarTimeZones.ts) — drag spins the globe, settling snaps the wedge
|
|
17
|
+
* under the eye INTO the selection, clicking a wedge flies the
|
|
18
|
+
* camera to it; both write the wedge's REPRESENTATIVE IANA zone
|
|
19
19
|
* through update:modelValue (the engine stores exact ids, so a
|
|
20
20
|
* half-hour config survives until the user actually re-picks);
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* - TURNTABLE: the board's orbit elevation window is pinned
|
|
22
|
+
* (minPolarAngle === maxPolarAngle) to the eye's ring tilt, so the
|
|
23
|
+
* sphere can only spin about its vertical axis. It never tips toward
|
|
24
|
+
* a pole and never rolls — the north hemisphere keeps its 18° tilt
|
|
25
|
+
* (user direction 2026-11-01: "the globe turns in front of you");
|
|
26
|
+
* - READOUT: the SELECTED sector stays pinned to the TOP-CENTER of the
|
|
27
|
+
* stage — always on, and hover never rewrites it (a drag re-selects
|
|
28
|
+
* without moving the pointer, so a hover preview would be stale at
|
|
29
|
+
* exactly the moment it is read). It replaced the pointer-following
|
|
30
|
+
* tooltip, which on a touch screen landed under the very finger doing
|
|
31
|
+
* the drag (user direction 2026-11-01). The head carries the IANA id
|
|
32
|
+
* with the translated band name (dropped where the locale's band name
|
|
33
|
+
* IS the offset label — English), the second line its representative
|
|
34
|
+
* cities: cities only, never countries (border disputes stay out of a
|
|
35
|
+
* theme picker);
|
|
24
36
|
* - MINIMAP: the board pins HkMinimap3D bottom-right — a second,
|
|
25
37
|
* stereoscopic render of the SAME scene, so the operator always sees
|
|
26
38
|
* where on the sphere the eye currently sits;
|
|
@@ -70,6 +82,11 @@ const DEG = Math.PI / 180;
|
|
|
70
82
|
/** Eye distance and latitude tilt for the fixed-radius ring orbit. */
|
|
71
83
|
const EYE_DISTANCE = 2.85;
|
|
72
84
|
const EYE_TILT_DEG = 18;
|
|
85
|
+
/** The locked orbit elevation (OrbitControls polar angle, degrees from
|
|
86
|
+
* +Y) that pairs with EYE_TILT_DEG: equal min/max polar bounds keep the
|
|
87
|
+
* eye on this one ring, so azimuth is the only orbit axis left — the
|
|
88
|
+
* globe turns in place instead of tipping. */
|
|
89
|
+
const ORBIT_POLAR_DEG = 90 - EYE_TILT_DEG;
|
|
73
90
|
/** Sphere and wedge radii — the wedges ride slightly above the base to
|
|
74
91
|
* keep the highlight out of the texture's z-fight band. */
|
|
75
92
|
const GLOBE_RADIUS = 1;
|
|
@@ -120,9 +137,12 @@ export default defineComponent({
|
|
|
120
137
|
setup(props, { emit }) {
|
|
121
138
|
const { t } = useI18n();
|
|
122
139
|
const engine = shallowRef<Board3DEngine | null>(null);
|
|
123
|
-
const stageRef = ref<HTMLElement | null>(null);
|
|
124
140
|
const hoveredSector = ref<number | null>(null);
|
|
125
|
-
|
|
141
|
+
// The stage readout is a HUD for the CANVAS; without WebGL the board's
|
|
142
|
+
// fallback list is the whole interface (every sector already shows its
|
|
143
|
+
// zone there), so the HUD steps aside instead of covering its first
|
|
144
|
+
// rows.
|
|
145
|
+
const webglFailed = ref(false);
|
|
126
146
|
|
|
127
147
|
// Per-mount three handles the board does NOT own (we created them,
|
|
128
148
|
// we dispose them — the board never disposes consumer objects).
|
|
@@ -138,10 +158,13 @@ export default defineComponent({
|
|
|
138
158
|
|
|
139
159
|
const selectedSector = computed(() => sectorIndexForZone(props.modelValue));
|
|
140
160
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
161
|
+
// The pinned readout reports the COMMITTED selection, never the
|
|
162
|
+
// hovered wedge. Hover already answers "what am I pointing at" on the
|
|
163
|
+
// globe itself (the wedge highlight); the always-on chip must keep
|
|
164
|
+
// answering "what is selected", because a drag that re-selects does
|
|
165
|
+
// not move the pointer — a hover preview there would go stale at
|
|
166
|
+
// exactly the moment the user is looking at it.
|
|
167
|
+
const selectedInfo = computed(() => SOLAR_TIME_ZONE_SECTORS[selectedSector.value] ?? null);
|
|
145
168
|
|
|
146
169
|
function citiesLine(sector: { cities: readonly string[] }): string {
|
|
147
170
|
const sep = t("hikari::theme.solar.citySeparator", ", ");
|
|
@@ -281,11 +304,8 @@ export default defineComponent({
|
|
|
281
304
|
hoveredSector.value = m ? parseInt(m[1] ?? "", 10) : null;
|
|
282
305
|
}
|
|
283
306
|
|
|
284
|
-
function
|
|
285
|
-
|
|
286
|
-
if (!stage) return;
|
|
287
|
-
const rect = stage.getBoundingClientRect();
|
|
288
|
-
tooltipPos.value = { x: ev.clientX - rect.left, y: ev.clientY - rect.top };
|
|
307
|
+
function onBoardError(): void {
|
|
308
|
+
webglFailed.value = true;
|
|
289
309
|
}
|
|
290
310
|
|
|
291
311
|
onBeforeUnmount(() => {
|
|
@@ -337,6 +357,11 @@ export default defineComponent({
|
|
|
337
357
|
|
|
338
358
|
return () => {
|
|
339
359
|
const info = selectedInfo.value;
|
|
360
|
+
// In `en`, a band's translated name IS its offset label ("UTC+8"), so
|
|
361
|
+
// rendering both would read "UTC+8 UTC+8". Keep the name only where
|
|
362
|
+
// the locale actually names the band differently.
|
|
363
|
+
const infoOffset = info ? offsetLabel(info.offset) : "";
|
|
364
|
+
const infoName = info ? t(sectorNameKey(info.offset)) : "";
|
|
340
365
|
const initialEye = ringPosition(sectorCenterLngDeg(
|
|
341
366
|
SOLAR_TIME_ZONE_SECTORS[selectedSector.value]?.offset ?? 0,
|
|
342
367
|
));
|
|
@@ -347,7 +372,25 @@ export default defineComponent({
|
|
|
347
372
|
aria-label={t("hikari::theme.solar.globeLabel")}
|
|
348
373
|
data-test="hk-timezone-globe"
|
|
349
374
|
>
|
|
350
|
-
<div class="hk-timezone-globe__stage"
|
|
375
|
+
<div class="hk-timezone-globe__stage">
|
|
376
|
+
{info && !webglFailed.value && (
|
|
377
|
+
<div
|
|
378
|
+
class="hk-timezone-globe__readout"
|
|
379
|
+
data-test="hk-timezone-globe-readout"
|
|
380
|
+
role="status"
|
|
381
|
+
aria-live="polite"
|
|
382
|
+
aria-atomic="true"
|
|
383
|
+
>
|
|
384
|
+
<span class="hk-timezone-globe__readout-head">
|
|
385
|
+
<span class="hk-timezone-globe__readout-offset">{infoOffset}</span>
|
|
386
|
+
{infoName !== infoOffset && (
|
|
387
|
+
<span class="hk-timezone-globe__readout-name">{infoName}</span>
|
|
388
|
+
)}
|
|
389
|
+
<span class="hk-timezone-globe__readout-zone">{info.zone}</span>
|
|
390
|
+
</span>
|
|
391
|
+
<span class="hk-timezone-globe__readout-cities">{citiesLine(info)}</span>
|
|
392
|
+
</div>
|
|
393
|
+
)}
|
|
351
394
|
<HkBoard3D
|
|
352
395
|
interactive
|
|
353
396
|
minimap
|
|
@@ -356,6 +399,10 @@ export default defineComponent({
|
|
|
356
399
|
hoverBox={false}
|
|
357
400
|
transparentBackground
|
|
358
401
|
enablePan={false}
|
|
402
|
+
// Turntable: equal polar bounds pin the eye to its ring, so
|
|
403
|
+
// the globe spins about its vertical axis only.
|
|
404
|
+
minPolarAngle={ORBIT_POLAR_DEG}
|
|
405
|
+
maxPolarAngle={ORBIT_POLAR_DEG}
|
|
359
406
|
fov={38}
|
|
360
407
|
minDistance={2.15}
|
|
361
408
|
maxDistance={3.6}
|
|
@@ -364,25 +411,10 @@ export default defineComponent({
|
|
|
364
411
|
onReady={onReady}
|
|
365
412
|
onObjectClick={onObjectClick}
|
|
366
413
|
onObjectHover={onObjectHover}
|
|
414
|
+
onError={onBoardError}
|
|
367
415
|
>
|
|
368
416
|
{{ fallback: fallbackList }}
|
|
369
417
|
</HkBoard3D>
|
|
370
|
-
{info && hoveredSector.value !== null && (
|
|
371
|
-
<div
|
|
372
|
-
class="hk-timezone-globe__tooltip"
|
|
373
|
-
data-test="hk-timezone-globe-tooltip"
|
|
374
|
-
style={{
|
|
375
|
-
left: tooltipPos.value.x + 14 + "px",
|
|
376
|
-
top: tooltipPos.value.y + 14 + "px",
|
|
377
|
-
}}
|
|
378
|
-
>
|
|
379
|
-
<span class="hk-timezone-globe__tooltip-offset">
|
|
380
|
-
{offsetLabel(info.offset)} · {t(sectorNameKey(info.offset))}
|
|
381
|
-
</span>
|
|
382
|
-
<span class="hk-timezone-globe__tooltip-zone">{info.zone}</span>
|
|
383
|
-
<span class="hk-timezone-globe__tooltip-cities">{citiesLine(info)}</span>
|
|
384
|
-
</div>
|
|
385
|
-
)}
|
|
386
418
|
</div>
|
|
387
419
|
<p class="hk-timezone-globe__hint">{t("hikari::theme.solar.globeHint")}</p>
|
|
388
420
|
</div>
|
|
@@ -104,6 +104,61 @@ describe("makeFleetHologram", () => {
|
|
|
104
104
|
// more content; a node core is smaller and sparser — never identical.
|
|
105
105
|
expect(dialCounts(hub)).toEqual({ rings: 3, ticks: 48, arcs: 2, core: 1, spindle: 9 });
|
|
106
106
|
expect(dialCounts(node)).toEqual({ rings: 2, ticks: 28, arcs: 1, core: 1, spindle: 6 });
|
|
107
|
+
|
|
108
|
+
// 2026-10-31 follow-up ("大号的那些不够亮"): the large ones are also
|
|
109
|
+
// BRIGHTER, not just denser — outer ring, ticks and the equator
|
|
110
|
+
// spindle ring must all beat their planet-tier twins.
|
|
111
|
+
const outerRingOpacity = (hologram: ReturnType<typeof makeFleetHologram>) => {
|
|
112
|
+
const rings = hologram.frontDial.getObjectsByProperty(
|
|
113
|
+
"name",
|
|
114
|
+
"fleet-hologram-face-dial-ring",
|
|
115
|
+
) as THREE.LineLoop[];
|
|
116
|
+
return (rings[0]!.material as THREE.LineBasicMaterial).opacity;
|
|
117
|
+
};
|
|
118
|
+
const ticksOpacity = (hologram: ReturnType<typeof makeFleetHologram>) =>
|
|
119
|
+
((hologram.frontDial.getObjectByName("fleet-hologram-face-dial-ticks") as THREE.LineSegments)
|
|
120
|
+
.material as THREE.LineBasicMaterial).opacity;
|
|
121
|
+
const equatorSpindleOpacity = (hologram: ReturnType<typeof makeFleetHologram>) => {
|
|
122
|
+
const rings = hologram.shell.getObjectsByProperty(
|
|
123
|
+
"name",
|
|
124
|
+
"fleet-hologram-ring-spindle-circle",
|
|
125
|
+
) as THREE.LineLoop[];
|
|
126
|
+
const radiusOf = (ring: THREE.LineLoop) => {
|
|
127
|
+
const pos = ring.geometry.getAttribute("position");
|
|
128
|
+
return Math.hypot(pos.getX(0), pos.getY(0));
|
|
129
|
+
};
|
|
130
|
+
const widest = rings.reduce((best, r) => (radiusOf(r) > radiusOf(best) ? r : best), rings[0]!);
|
|
131
|
+
return (widest.material as THREE.LineBasicMaterial).opacity;
|
|
132
|
+
};
|
|
133
|
+
expect(outerRingOpacity(hub)).toBeGreaterThan(outerRingOpacity(node));
|
|
134
|
+
expect(ticksOpacity(hub)).toBeGreaterThan(ticksOpacity(node));
|
|
135
|
+
expect(equatorSpindleOpacity(hub)).toBeGreaterThan(equatorSpindleOpacity(node));
|
|
136
|
+
// Absolute pins for BOTH tiers (R3 S2 lesson: comparisons alone let a
|
|
137
|
+
// revert to the dim pre-fix star pass — the star must BURN at these
|
|
138
|
+
// levels, and the node must hold its own baseline).
|
|
139
|
+
expect(outerRingOpacity(hub)).toBeCloseTo(0.95, 5);
|
|
140
|
+
expect(ticksOpacity(hub)).toBeCloseTo(0.78, 5);
|
|
141
|
+
expect(equatorSpindleOpacity(hub)).toBeCloseTo(0.3, 5);
|
|
142
|
+
expect(outerRingOpacity(node)).toBeCloseTo(0.66, 5);
|
|
143
|
+
// And the star dial actually burns: the primary arc is fully lit.
|
|
144
|
+
const arcs = hub.frontDial.getObjectsByProperty(
|
|
145
|
+
"name",
|
|
146
|
+
"fleet-hologram-face-dial-arc",
|
|
147
|
+
) as THREE.Line[];
|
|
148
|
+
expect((arcs[0]!.material as THREE.LineBasicMaterial).opacity).toBe(1);
|
|
149
|
+
|
|
150
|
+
// Ceiling guard: every material opacity in EITHER hologram stays
|
|
151
|
+
// within [0, 1] — brightness tiers must never push past full lit.
|
|
152
|
+
for (const hologram of [hub, node]) {
|
|
153
|
+
hologram.group.traverse((obj) => {
|
|
154
|
+
const material = (obj as THREE.Mesh).material as
|
|
155
|
+
| (THREE.Material & { opacity?: number })
|
|
156
|
+
| undefined;
|
|
157
|
+
if (!material || !("opacity" in material) || material.opacity === undefined) return;
|
|
158
|
+
expect(material.opacity).toBeLessThanOrEqual(1);
|
|
159
|
+
expect(material.opacity).toBeGreaterThanOrEqual(0);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
107
162
|
});
|
|
108
163
|
|
|
109
164
|
it("builds the ball from circles only — no solid sphere, no wireframe cage", () => {
|
|
@@ -93,26 +93,31 @@ function lineMaterial(color: THREE.Color, opacity: number): THREE.LineBasicMater
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/** How much content a dial tier draws (user direction: main cores carry
|
|
96
|
-
* MORE content than node cores — rings, ticks, arcs — never identical
|
|
96
|
+
* MORE content than node cores — rings, ticks, arcs — never identical;
|
|
97
|
+
* 2026-10-31 follow-up: they are also BRIGHTER, not just denser). */
|
|
97
98
|
interface DialTier {
|
|
98
99
|
rings: ReadonlyArray<{ scale: number; opacity: number }>;
|
|
99
100
|
tickCount: number;
|
|
101
|
+
tickOpacity: number;
|
|
100
102
|
arcs: ReadonlyArray<{ scale: number; start: number; span: number; opacity: number }>;
|
|
101
103
|
core: boolean;
|
|
104
|
+
coreOpacity: number;
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
const FOCUS_STAR: DialTier = {
|
|
105
108
|
rings: [
|
|
106
|
-
{ scale: 1, opacity: 0.
|
|
107
|
-
{ scale: 0.64, opacity: 0.
|
|
108
|
-
{ scale: 0.38, opacity: 0.
|
|
109
|
+
{ scale: 1, opacity: 0.95 },
|
|
110
|
+
{ scale: 0.64, opacity: 0.45 },
|
|
111
|
+
{ scale: 0.38, opacity: 0.32 },
|
|
109
112
|
],
|
|
110
113
|
tickCount: 48,
|
|
114
|
+
tickOpacity: 0.78,
|
|
111
115
|
arcs: [
|
|
112
|
-
{ scale: 1, start: -0.25, span: Math.PI * 0.62, opacity:
|
|
113
|
-
{ scale: 0.64, start: Math.PI * 0.85, span: Math.PI * 0.2, opacity: 0.
|
|
116
|
+
{ scale: 1, start: -0.25, span: Math.PI * 0.62, opacity: 1 },
|
|
117
|
+
{ scale: 0.64, start: Math.PI * 0.85, span: Math.PI * 0.2, opacity: 0.82 },
|
|
114
118
|
],
|
|
115
119
|
core: true,
|
|
120
|
+
coreOpacity: 0.42,
|
|
116
121
|
};
|
|
117
122
|
|
|
118
123
|
const FOCUS_PLANET: DialTier = {
|
|
@@ -121,10 +126,12 @@ const FOCUS_PLANET: DialTier = {
|
|
|
121
126
|
{ scale: 0.52, opacity: 0.26 },
|
|
122
127
|
],
|
|
123
128
|
tickCount: 28,
|
|
129
|
+
tickOpacity: 0.58,
|
|
124
130
|
arcs: [
|
|
125
131
|
{ scale: 1, start: -0.25, span: Math.PI * 0.5, opacity: 0.9 },
|
|
126
132
|
],
|
|
127
133
|
core: true,
|
|
134
|
+
coreOpacity: 0.26,
|
|
128
135
|
};
|
|
129
136
|
|
|
130
137
|
/** The five non-facing shell faces: an outline and a faint arc only, so
|
|
@@ -132,8 +139,10 @@ const FOCUS_PLANET: DialTier = {
|
|
|
132
139
|
const SUPPORT: DialTier = {
|
|
133
140
|
rings: [{ scale: 1, opacity: 0.28 }],
|
|
134
141
|
tickCount: 0,
|
|
142
|
+
tickOpacity: 0,
|
|
135
143
|
arcs: [{ scale: 1, start: -0.25, span: Math.PI * 0.62, opacity: 0.34 }],
|
|
136
144
|
core: false,
|
|
145
|
+
coreOpacity: 0,
|
|
137
146
|
};
|
|
138
147
|
|
|
139
148
|
function focusTierFor(role: "star" | "planet"): DialTier {
|
|
@@ -174,7 +183,7 @@ function makeFaceDial(
|
|
|
174
183
|
"position",
|
|
175
184
|
new THREE.Float32BufferAttribute(ticks, 3),
|
|
176
185
|
),
|
|
177
|
-
lineMaterial(color, offline ? 0.
|
|
186
|
+
lineMaterial(color, offline ? tier.tickOpacity * 0.2 : tier.tickOpacity),
|
|
178
187
|
));
|
|
179
188
|
tickMarks.name = "fleet-hologram-face-dial-ticks";
|
|
180
189
|
dial.add(tickMarks);
|
|
@@ -195,7 +204,7 @@ function makeFaceDial(
|
|
|
195
204
|
new THREE.MeshBasicMaterial({
|
|
196
205
|
color,
|
|
197
206
|
transparent: true,
|
|
198
|
-
opacity: offline ? 0.
|
|
207
|
+
opacity: offline ? tier.coreOpacity * 0.3 : tier.coreOpacity,
|
|
199
208
|
side: THREE.DoubleSide,
|
|
200
209
|
depthWrite: false,
|
|
201
210
|
blending: THREE.AdditiveBlending,
|
|
@@ -240,21 +249,25 @@ function makeDiscShell(
|
|
|
240
249
|
/** A stack of concentric circles along the Z axis: fat at the equator,
|
|
241
250
|
* tapering to thin rings at the poles (user direction 2026-10-31 — this
|
|
242
251
|
* is the second of the two fused structures; it rounds the shell into a
|
|
243
|
-
* ball and keeps every side view a set of nested circles).
|
|
252
|
+
* ball and keeps every side view a set of nested circles). `brightness`
|
|
253
|
+
* scales the ring opacities so a main core's ball burns brighter than a
|
|
254
|
+
* node's without adding clutter (2026-10-31 follow-up). */
|
|
244
255
|
function makeRingSpindle(
|
|
245
256
|
bodyRadius: number,
|
|
246
257
|
color: THREE.Color,
|
|
247
258
|
offline: boolean,
|
|
248
259
|
count: number,
|
|
260
|
+
brightness = 1,
|
|
249
261
|
): THREE.Group {
|
|
250
262
|
const spindle = new THREE.Group();
|
|
251
263
|
spindle.name = "fleet-hologram-ring-spindle";
|
|
252
264
|
for (let i = 0; i < count; i += 1) {
|
|
253
265
|
const t = count === 1 ? 0 : -0.97 + (1.94 * i) / (count - 1);
|
|
254
266
|
const radius = bodyRadius * Math.sqrt(Math.max(0, 1 - t * t)) * 0.98;
|
|
267
|
+
const lit = Math.min(1, (0.08 + (1 - Math.abs(t)) * 0.12) * brightness);
|
|
255
268
|
const loop = unpickable(new THREE.LineLoop(
|
|
256
269
|
xyCircleGeometry(radius, 160),
|
|
257
|
-
lineMaterial(color, offline ?
|
|
270
|
+
lineMaterial(color, offline ? lit * 0.2 : lit),
|
|
258
271
|
));
|
|
259
272
|
loop.name = "fleet-hologram-ring-spindle-circle";
|
|
260
273
|
loop.position.z = bodyRadius * t;
|
|
@@ -371,11 +384,14 @@ export function makeFleetHologram(
|
|
|
371
384
|
mesh.name = "fleet-hologram-core";
|
|
372
385
|
|
|
373
386
|
const shell = makeDiscShell(input.radius, color, offline);
|
|
387
|
+
// A main core's ball BURNS brighter as well as denser (2026-10-31
|
|
388
|
+
// follow-up): 1.5× spindle brightness on top of the richer dial tier.
|
|
374
389
|
shell.add(makeRingSpindle(
|
|
375
390
|
input.radius,
|
|
376
391
|
color,
|
|
377
392
|
offline,
|
|
378
393
|
input.role === "star" ? 9 : 6,
|
|
394
|
+
input.role === "star" ? 1.5 : 1,
|
|
379
395
|
));
|
|
380
396
|
group.add(shell);
|
|
381
397
|
|
|
@@ -538,6 +538,36 @@ describe("buildFleetScene teardown", () => {
|
|
|
538
538
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
539
539
|
});
|
|
540
540
|
|
|
541
|
+
it("burns the large bodies' halos brighter than the node baseline", () => {
|
|
542
|
+
const engine = stubEngine();
|
|
543
|
+
const handle = buildFleetScene(engine, callbacks());
|
|
544
|
+
// A hub (star tier) plus a gateway (planet tier, no halo at all).
|
|
545
|
+
handle.update(buildFleetSceneSpec(ROWS), { "hub-a": false });
|
|
546
|
+
|
|
547
|
+
// User direction 2026-10-31 follow-up: the LARGE ones were too dim —
|
|
548
|
+
// the star halo carries the "burning" read, so pin its level.
|
|
549
|
+
const starGlow = engine.registered.get("hub-a")!.object
|
|
550
|
+
.children.find((c): c is THREE.Sprite => c instanceof THREE.Sprite)!;
|
|
551
|
+
expect((starGlow.material as THREE.SpriteMaterial).opacity).toBeCloseTo(0.5, 5);
|
|
552
|
+
const planetSprites = engine.registered.get("edge-1")!.object
|
|
553
|
+
.children.filter((c) => c instanceof THREE.Sprite);
|
|
554
|
+
expect(planetSprites).toHaveLength(0);
|
|
555
|
+
|
|
556
|
+
// The registry core is the largest body on the page: its halo reads
|
|
557
|
+
// brightest of all.
|
|
558
|
+
handle.update(buildFleetSceneSpec([{ node_id: "edge-1", tier: 2, station_count: 0 }]), {});
|
|
559
|
+
const core = engine.registered.get("__star-system-core__")!.object;
|
|
560
|
+
const coreGlow = core.children.find((c): c is THREE.Sprite => c instanceof THREE.Sprite)!;
|
|
561
|
+
expect((coreGlow.material as THREE.SpriteMaterial).opacity).toBeCloseTo(0.55, 5);
|
|
562
|
+
|
|
563
|
+
// An offline hub dims its halo with it (0.24 = the offline twin).
|
|
564
|
+
handle.update(buildFleetSceneSpec(ROWS), { "hub-a": true });
|
|
565
|
+
const darkGlow = engine.registered.get("hub-a")!.object
|
|
566
|
+
.children.find((c): c is THREE.Sprite => c instanceof THREE.Sprite)!;
|
|
567
|
+
expect((darkGlow.material as THREE.SpriteMaterial).opacity).toBeCloseTo(0.24, 5);
|
|
568
|
+
handle.dispose();
|
|
569
|
+
});
|
|
570
|
+
|
|
541
571
|
it("frees real HUD textures when a body generation is replaced", () => {
|
|
542
572
|
const gradient = fakeAs<CanvasGradient>({ addColorStop: vi.fn() });
|
|
543
573
|
const context = fakeAs<CanvasRenderingContext2D>({
|
|
@@ -201,7 +201,9 @@ export function buildFleetScene(
|
|
|
201
201
|
map: glowTexture,
|
|
202
202
|
color,
|
|
203
203
|
transparent: true,
|
|
204
|
-
|
|
204
|
+
// A hub's halo carries its "burning" read (2026-10-31 follow-up:
|
|
205
|
+
// the large ones were too dim next to their denser dials).
|
|
206
|
+
opacity: offline ? 0.24 : 0.5,
|
|
205
207
|
blending: THREE.AdditiveBlending,
|
|
206
208
|
depthWrite: false,
|
|
207
209
|
}),
|
|
@@ -260,7 +262,9 @@ export function buildFleetScene(
|
|
|
260
262
|
map: glowTexture,
|
|
261
263
|
color: 0xffe9b8,
|
|
262
264
|
transparent: true,
|
|
263
|
-
|
|
265
|
+
// The registry core is the largest body on the page — its halo
|
|
266
|
+
// reads brightest (2026-10-31 follow-up).
|
|
267
|
+
opacity: 0.55,
|
|
264
268
|
blending: THREE.AdditiveBlending,
|
|
265
269
|
depthWrite: false,
|
|
266
270
|
}),
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Deliberately a BORDERLESS world: the map carries continent silhouettes
|
|
7
7
|
* and TECTONIC PLATE boundary lines only — never national borders, never
|
|
8
|
-
* country labels (city names in the picker's
|
|
8
|
+
* country labels (city names in the picker's readout are the only named
|
|
9
9
|
* places, and names of cities carry no border disputes). The 15°
|
|
10
10
|
* meridian graticule doubles as the 24 time-sector edges the picker
|
|
11
11
|
* selects between.
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
* offset (offset hours / 4 degrees... equivalently hours × 15°) — the
|
|
16
16
|
* picked wedge and the engine's derived fix AGREE by construction;
|
|
17
17
|
* - representative zones are the canonical IANA ids for each band;
|
|
18
|
-
* - representative CITIES are display-only examples (the
|
|
19
|
-
* them translated; deliberately never the countries —
|
|
20
|
-
* carry no border disputes);
|
|
18
|
+
* - representative CITIES are display-only examples (the globe's pinned
|
|
19
|
+
* readout lists them translated; deliberately never the countries —
|
|
20
|
+
* city names carry no border disputes);
|
|
21
21
|
* - half-hour zones (Asia/Kolkata, Asia/Yangon, …) still SELECT fine:
|
|
22
22
|
* `sectorIndexForZone` rounds the zone's standard offset to the
|
|
23
23
|
* nearest whole-hour band, and the stored zone id keeps the exact
|
|
@@ -37,9 +37,9 @@ export interface SolarTimeZoneSector {
|
|
|
37
37
|
offset: number;
|
|
38
38
|
/** Representative IANA zone id — what selecting the wedge stores. */
|
|
39
39
|
zone: string;
|
|
40
|
-
/** Representative city slugs for the
|
|
41
|
-
* under `hikari::theme.solar.city.<slug>`, translated per
|
|
42
|
-
* cities only, never countries). */
|
|
40
|
+
/** Representative city slugs for the globe's pinned readout (locale
|
|
41
|
+
* keys live under `hikari::theme.solar.city.<slug>`, translated per
|
|
42
|
+
* language; cities only, never countries). */
|
|
43
43
|
cities: readonly string[];
|
|
44
44
|
}
|
|
45
45
|
|