@unson/brainbase-mcp 0.23.7 → 0.23.8

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.
@@ -0,0 +1,467 @@
1
+ /*
2
+ * Canvas-only controls and read-only overlays. The host supplies every business,
3
+ * warning and detail node; this module never fetches, persists or invents data.
4
+ * Coordinates passed to setTaskAnchor are stage-local CSS pixels. An explicit
5
+ * unanchored:true omits the pointer for a real record without a drawable lot.
6
+ */
7
+
8
+ const SKY_MODES = Object.freeze([
9
+ { value: 'auto', label: '時刻に合わせる', icon: '◷' },
10
+ { value: 'day', label: '昼', icon: '☀' },
11
+ { value: 'dusk', label: '夕方', icon: '◒' },
12
+ { value: 'night', label: '夜', icon: '☾' },
13
+ ]);
14
+ const CLOSE_DELAY = 220;
15
+ let instanceId = 0;
16
+
17
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
18
+ const nodeList = (nodes) => (Array.isArray(nodes) ? nodes : [nodes]).filter((node) => node != null);
19
+
20
+ /**
21
+ * @param {object} options
22
+ * @param {Document} options.doc
23
+ * @param {HTMLElement} options.stage Positioned canvas container.
24
+ * @param {boolean} [options.reducedMotion]
25
+ * @param {() => void} [options.onReset] Return to the entire world.
26
+ * @param {(code: string) => void} [options.onCity] Enter a known business.
27
+ * @param {(mode: 'auto'|'day'|'dusk'|'night') => void} [options.onSky]
28
+ * @param {() => void} [options.onDismissTask] User dismissed task details.
29
+ *
30
+ * showRail/showTask/setHelp accept actual DOM nodes, preserving their events and
31
+ * all source details. showRail never opens the drawer. showTask does not move
32
+ * focus unless its optional second argument is { focus: true }.
33
+ * addNote shows a quest only for an explicit nonempty summary; its full text
34
+ * remains in help. No summary is inferred from a long or nonurgent notice.
35
+ */
36
+ export function createWorldCanvasUI({ doc, stage, reducedMotion = false, onReset, onCity, onSky, onDismissTask }) {
37
+ const id = `bb-world-canvas-${++instanceId}`;
38
+ const listeners = [];
39
+ let disposed = false;
40
+ let railOpen = false;
41
+ let railHovered = false;
42
+ let railTimer = null;
43
+ let restoringRailFocus = false;
44
+ let taskOpen = false;
45
+ let taskAnchor = null;
46
+ let helpOpen = false;
47
+ let skyOpen = false;
48
+ let skyMode = 'auto';
49
+ let businesses = new Set();
50
+ let overlayOrder = [];
51
+ let overlayLayer = 10;
52
+
53
+ function element(tag, className, text, attrs = {}) {
54
+ const node = doc.createElement(tag);
55
+ if (className) node.className = className;
56
+ if (text != null) node.textContent = text;
57
+ for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, String(value));
58
+ return node;
59
+ }
60
+ function listen(target, name, handler) {
61
+ target.addEventListener(name, handler);
62
+ listeners.push(() => target.removeEventListener(name, handler));
63
+ }
64
+ function button(className, label, text = label) {
65
+ return element('button', className, text, { type: 'button', 'aria-label': label, title: label });
66
+ }
67
+ function panel(className, label, panelId) {
68
+ const node = element('section', className, null, { id: panelId, 'aria-label': label, 'aria-hidden': 'true' });
69
+ node.inert = true;
70
+ return node;
71
+ }
72
+ function reveal(node, open) {
73
+ node.classList.toggle('is-open', open);
74
+ node.setAttribute('aria-hidden', String(!open));
75
+ node.inert = !open;
76
+ }
77
+ function activate(name, node) {
78
+ overlayOrder = overlayOrder.filter((entry) => entry !== name);
79
+ overlayOrder.push(name);
80
+ node.style.zIndex = String(++overlayLayer);
81
+ }
82
+ function forget(name) {
83
+ overlayOrder = overlayOrder.filter((entry) => entry !== name);
84
+ }
85
+ function focusedWithin(node) { return node.contains(doc.activeElement); }
86
+ function focus(node) { node?.focus?.({ preventScroll: true }); }
87
+ function focusCanvas() {
88
+ const canvas = [...stage.querySelectorAll('canvas')].find((node) => !node.hidden);
89
+ focus(canvas ?? reset);
90
+ }
91
+ function heading(container) { return container.querySelector('h1, h2, h3, [role="heading"]'); }
92
+ function focusContent(container, fallback) {
93
+ const target = heading(container) ?? fallback;
94
+ target.setAttribute('tabindex', '-1');
95
+ focus(target);
96
+ }
97
+ function replaceBody(container, nodes) {
98
+ const previous = focusedWithin(container) ? doc.activeElement : null;
99
+ const scrollTop = container.scrollTop;
100
+ container.replaceChildren(...nodeList(nodes));
101
+ if (previous) {
102
+ // Reuse a retained node, or a unique equivalent control in freshly built
103
+ // source details. Never move focus here when it began outside the panel.
104
+ let target = container.contains(previous) ? previous : null;
105
+ if (!target) {
106
+ const key = ['id', 'href', 'name', 'aria-label'].find((name) => previous.getAttribute(name));
107
+ const candidates = [...container.querySelectorAll('a, button, input, select, textarea, [tabindex], h1, h2, h3, h4')]
108
+ .filter((node) => node.tagName === previous.tagName && !node.disabled
109
+ && (key ? node.getAttribute(key) === previous.getAttribute(key) : node.textContent === previous.textContent));
110
+ if (candidates.length === 1) target = candidates[0];
111
+ }
112
+ if (target) focus(target);
113
+ else focusContent(container, container);
114
+ }
115
+ container.scrollTop = scrollTop;
116
+ }
117
+
118
+ const root = element('div', `bb-world-canvas-ui${reducedMotion ? ' is-reduced-motion' : ''}`);
119
+ const toolbar = element('div', 'bb-world-canvas-toolbar', null, { 'aria-label': '世界の表示操作' });
120
+ const reset = button('bb-world-canvas-control bb-world-canvas-reset', '世界全体に戻る', '全体に戻る');
121
+ const city = element('select', 'bb-world-canvas-control bb-world-canvas-city', null, { 'aria-label': '都市を選ぶ' });
122
+ const sky = button('bb-world-canvas-control bb-world-canvas-sky', '空:時刻に合わせる', '');
123
+ sky.setAttribute('aria-haspopup', 'menu');
124
+ sky.setAttribute('aria-expanded', 'false');
125
+ sky.setAttribute('aria-controls', `${id}-sky`);
126
+ const skyIcon = element('span', 'bb-world-canvas-sky-icon', SKY_MODES[0].icon, { 'aria-hidden': 'true' });
127
+ sky.append(skyIcon);
128
+ toolbar.append(reset, city, sky);
129
+ const skyMenu = element('div', 'bb-world-canvas-sky-menu', null, { id: `${id}-sky`, role: 'menu', 'aria-label': '空の時間帯' });
130
+ skyMenu.hidden = true;
131
+ const skyOptions = SKY_MODES.map((mode) => {
132
+ const option = button('bb-world-canvas-sky-option', mode.label);
133
+ option.setAttribute('role', 'menuitemradio');
134
+ option.setAttribute('aria-checked', String(mode.value === skyMode));
135
+ option.setAttribute('tabindex', '-1');
136
+ listen(option, 'click', () => {
137
+ setSky(mode.value);
138
+ closeSky(true);
139
+ onSky?.(mode.value);
140
+ });
141
+ skyMenu.append(option);
142
+ return option;
143
+ });
144
+ const quests = element('div', 'bb-world-canvas-quests', null, { 'aria-live': 'polite', 'aria-label': '確認が必要なこと' });
145
+
146
+ const edge = button('bb-world-canvas-edge', '都市の詳細を開く', '');
147
+ edge.setAttribute('aria-controls', `${id}-rail`);
148
+ edge.setAttribute('aria-expanded', 'false');
149
+ const rail = panel('bb-world-canvas-drawer', '都市の詳細', `${id}-rail`);
150
+ const railTop = element('div', 'bb-world-canvas-panel-top');
151
+ const railTitle = element('h2', '', '都市の詳細', { tabindex: '-1' });
152
+ const railClose = button('bb-world-canvas-close', '都市の詳細を閉じる', '×');
153
+ const railBody = element('div', 'bb-world-canvas-rail-body');
154
+ railTop.append(railTitle, railClose);
155
+ rail.append(railTop, railBody);
156
+
157
+ const task = panel('bb-world-canvas-task', '仕事の詳細', `${id}-task`);
158
+ task.hidden = true;
159
+ const taskTop = element('div', 'bb-world-canvas-panel-top');
160
+ const taskClose = button('bb-world-canvas-close', '仕事の詳細を閉じる', '×');
161
+ const taskBody = element('div', 'bb-world-canvas-task-body', null, { tabindex: '-1' });
162
+ taskTop.append(taskClose);
163
+ task.append(taskTop, taskBody);
164
+
165
+ const helpButton = button('bb-world-canvas-help-button', '世界の見方と凡例', '?');
166
+ helpButton.setAttribute('aria-controls', `${id}-help`);
167
+ helpButton.setAttribute('aria-expanded', 'false');
168
+ const help = panel('bb-world-canvas-help', '世界の見方と凡例', `${id}-help`);
169
+ const helpTop = element('div', 'bb-world-canvas-panel-top');
170
+ const helpTitle = element('h2', '', '世界の見方', { tabindex: '-1' });
171
+ const helpClose = button('bb-world-canvas-close', '世界の見方を閉じる', '×');
172
+ helpTop.append(helpTitle, helpClose);
173
+ const helpBody = element('div', 'bb-world-canvas-help-body');
174
+ const helpContent = element('div', 'bb-world-canvas-help-content');
175
+ const notes = element('div', 'bb-world-canvas-notes');
176
+ helpBody.append(helpContent, notes);
177
+ help.append(helpTop, helpBody);
178
+ root.append(toolbar, skyMenu, quests, edge, rail, task, helpButton, help);
179
+ stage.append(root);
180
+
181
+ function setCities(items = [], selectedCode = null) {
182
+ if (disposed) return;
183
+ businesses = new Set();
184
+ const placeholder = element('option', '', '都市を選ぶ', { value: '', disabled: 'disabled' });
185
+ placeholder.value = '';
186
+ const options = [placeholder];
187
+ for (const business of items) {
188
+ if (!business?.code || businesses.has(String(business.code))) continue;
189
+ const code = String(business.code);
190
+ businesses.add(code);
191
+ const option = element('option', '', business.name || code, { value: code });
192
+ option.value = code;
193
+ options.push(option);
194
+ }
195
+ city.replaceChildren(...options);
196
+ city.disabled = businesses.size === 0;
197
+ setCity(selectedCode);
198
+ }
199
+ function setCity(code) {
200
+ if (disposed) return;
201
+ city.value = businesses.has(String(code)) ? String(code) : '';
202
+ }
203
+ function setSky(mode) {
204
+ if (disposed) return;
205
+ const match = SKY_MODES.find((entry) => entry.value === mode);
206
+ if (!match) return;
207
+ skyMode = mode;
208
+ skyIcon.textContent = match.icon;
209
+ sky.setAttribute('aria-label', `空:${match.label}`);
210
+ sky.setAttribute('title', `空:${match.label}`);
211
+ skyOptions.forEach((option, index) => option.setAttribute('aria-checked', String(SKY_MODES[index].value === mode)));
212
+ }
213
+ function openSky() {
214
+ if (disposed) return;
215
+ skyOpen = true;
216
+ skyMenu.hidden = false;
217
+ sky.setAttribute('aria-expanded', 'true');
218
+ activate('sky', skyMenu);
219
+ focus(skyOptions[SKY_MODES.findIndex((entry) => entry.value === skyMode)]);
220
+ }
221
+ function closeSky(restoreFocus = false) {
222
+ if (!skyOpen) return;
223
+ const shouldRestore = restoreFocus && focusedWithin(skyMenu);
224
+ skyOpen = false;
225
+ skyMenu.hidden = true;
226
+ sky.setAttribute('aria-expanded', 'false');
227
+ forget('sky');
228
+ if (shouldRestore) focus(sky);
229
+ }
230
+ function cancelRailTimer() {
231
+ if (railTimer != null) clearTimeout(railTimer);
232
+ railTimer = null;
233
+ }
234
+ function openRail() {
235
+ if (disposed) return;
236
+ cancelRailTimer();
237
+ if (railOpen) return;
238
+ railOpen = true;
239
+ reveal(rail, true);
240
+ edge.setAttribute('aria-expanded', 'true');
241
+ activate('rail', rail);
242
+ }
243
+ function closeRail() {
244
+ cancelRailTimer();
245
+ if (!railOpen) return;
246
+ const restoreFocus = focusedWithin(rail);
247
+ railOpen = false;
248
+ railHovered = false;
249
+ reveal(rail, false);
250
+ edge.setAttribute('aria-expanded', 'false');
251
+ forget('rail');
252
+ if (restoreFocus) {
253
+ restoringRailFocus = true;
254
+ focus(edge);
255
+ restoringRailFocus = false;
256
+ }
257
+ }
258
+ function queueRailClose() {
259
+ cancelRailTimer();
260
+ if (disposed || !railOpen) return;
261
+ railTimer = setTimeout(() => {
262
+ railTimer = null;
263
+ if (!railHovered && !focusedWithin(rail) && doc.activeElement !== edge) closeRail();
264
+ }, CLOSE_DELAY);
265
+ }
266
+ function railEnter(event) {
267
+ if (event.pointerType === 'touch') return;
268
+ railHovered = true;
269
+ openRail();
270
+ }
271
+ function railLeave(event) {
272
+ if (edge.contains(event.relatedTarget) || rail.contains(event.relatedTarget)) return;
273
+ railHovered = false;
274
+ queueRailClose();
275
+ }
276
+ function showRail(nodes) {
277
+ if (disposed) return;
278
+ // Retain the complete host-built rail, with its native links and handlers.
279
+ replaceBody(railBody, nodes);
280
+ }
281
+
282
+ function positionTask() {
283
+ if (disposed || !taskOpen) return;
284
+ const rect = stage.getBoundingClientRect();
285
+ const width = stage.clientWidth || rect.width;
286
+ const height = stage.clientHeight || rect.height;
287
+ const anchor = taskAnchor;
288
+ const visible = anchor?.visible !== false && Number.isFinite(anchor?.x) && Number.isFinite(anchor?.y)
289
+ && width > 0 && height > 0 && anchor.x >= 0 && anchor.x <= width && anchor.y >= 0 && anchor.y <= height;
290
+ const restoreFocus = !visible && focusedWithin(task);
291
+ task.hidden = !visible;
292
+ reveal(task, visible);
293
+ if (restoreFocus) focusCanvas();
294
+ if (!visible) return;
295
+ const inset = Math.min(10, width / 4, height / 4);
296
+ const panelWidth = Math.min(330, width - inset * 2);
297
+ const toolbarRect = toolbar.getBoundingClientRect();
298
+ const toolbarBottom = toolbarRect.bottom > rect.top ? toolbarRect.bottom - rect.top + 12 : (width <= 460 ? 60 : 68);
299
+ const topLimit = Math.max(inset, Math.min(toolbarBottom, height - inset - 40));
300
+ const maxHeight = Math.max(1, Math.min(370, height - topLimit - inset));
301
+ task.style.width = `${panelWidth}px`;
302
+ task.style.maxHeight = `${maxHeight}px`;
303
+ const panelHeight = Math.min(task.offsetHeight || 220, maxHeight);
304
+ const left = clamp(anchor.x - panelWidth / 2, inset, width - panelWidth - inset);
305
+ const above = anchor.y - panelHeight - 18;
306
+ const below = above < topLimit;
307
+ const top = clamp(below ? anchor.y + 18 : above, topLimit, height - panelHeight - inset);
308
+ task.style.left = `${Math.round(left)}px`;
309
+ task.style.top = `${Math.round(top)}px`;
310
+ task.style.setProperty('--bb-world-task-pointer-x', `${clamp(anchor.x - left, 18, panelWidth - 18)}px`);
311
+ task.classList.toggle('is-below', below);
312
+ task.classList.toggle('is-unanchored', Boolean(anchor.unanchored));
313
+ }
314
+ function showTask(nodes, { focus: shouldFocus = false } = {}) {
315
+ if (disposed) return;
316
+ replaceBody(taskBody, nodes);
317
+ if (!taskOpen) {
318
+ taskOpen = true;
319
+ activate('task', task);
320
+ }
321
+ positionTask();
322
+ if (shouldFocus && !task.hidden) focusContent(taskBody, taskBody);
323
+ }
324
+ function closeTask() {
325
+ if (disposed) return;
326
+ const restoreFocus = focusedWithin(task);
327
+ taskOpen = false;
328
+ task.hidden = true;
329
+ reveal(task, false);
330
+ forget('task');
331
+ if (restoreFocus) focusCanvas();
332
+ }
333
+ function dismissTask() {
334
+ closeTask();
335
+ onDismissTask?.();
336
+ }
337
+ function setTaskAnchor(anchor) {
338
+ if (disposed) return;
339
+ taskAnchor = anchor;
340
+ positionTask();
341
+ }
342
+ function closeHelp() {
343
+ if (!helpOpen) return;
344
+ const shouldRestore = focusedWithin(help);
345
+ helpOpen = false;
346
+ reveal(help, false);
347
+ helpButton.setAttribute('aria-expanded', 'false');
348
+ forget('help');
349
+ if (shouldRestore) focus(helpButton);
350
+ }
351
+ function toggleHelp() {
352
+ if (helpOpen) return closeHelp();
353
+ helpOpen = true;
354
+ reveal(help, true);
355
+ helpButton.setAttribute('aria-expanded', 'true');
356
+ activate('help', help);
357
+ focus(helpTitle);
358
+ }
359
+ function addNote({ label = '', text = '', tone = '', summary } = {}) {
360
+ if (disposed) return;
361
+ const note = element('section', 'bb-world-canvas-note');
362
+ if (label) note.append(element('h3', '', label));
363
+ if (text || summary) note.append(element('p', '', text || summary));
364
+ if (label || text || summary) notes.append(note);
365
+ if (typeof summary !== 'string' || !summary.trim()) return;
366
+ const quest = element('p', 'bb-world-canvas-quest');
367
+ const icon = tone === 'attention' || tone === 'warning' ? '!' : 'i';
368
+ quest.append(
369
+ element('span', 'bb-world-canvas-quest-symbol', icon, { 'aria-hidden': 'true' }),
370
+ element('span', 'bb-world-canvas-quest-text', summary.trim()),
371
+ );
372
+ // The accessible name contains the unabridged summary even when visual text
373
+ // is ellipsized on a narrow canvas; the long explanation remains in help.
374
+ quest.setAttribute('aria-label', summary.trim());
375
+ quests.append(quest);
376
+ }
377
+ function clearNotes() {
378
+ if (disposed) return;
379
+ quests.replaceChildren();
380
+ notes.replaceChildren();
381
+ }
382
+ function dismissOverlay() {
383
+ if (disposed) return false;
384
+ // A selected task outside the camera is not a visible overlay to dismiss.
385
+ const name = [...overlayOrder].reverse().find((entry) => entry !== 'task' || !task.hidden);
386
+ if (!name) return false;
387
+ if (name === 'sky') closeSky(true);
388
+ else if (name === 'help') closeHelp();
389
+ else if (name === 'rail') closeRail();
390
+ else if (name === 'task') dismissTask();
391
+ return true;
392
+ }
393
+
394
+ listen(reset, 'click', () => {
395
+ closeSky();
396
+ closeHelp();
397
+ closeRail();
398
+ closeTask();
399
+ onReset?.();
400
+ });
401
+ listen(city, 'change', () => { if (businesses.has(city.value)) onCity?.(city.value); });
402
+ listen(sky, 'click', () => { if (skyOpen) closeSky(); else openSky(); });
403
+ listen(sky, 'keydown', (event) => {
404
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); openSky(); }
405
+ });
406
+ listen(skyMenu, 'keydown', (event) => {
407
+ let index = skyOptions.indexOf(doc.activeElement);
408
+ if (event.key === 'ArrowDown') index = (index + 1) % skyOptions.length;
409
+ else if (event.key === 'ArrowUp') index = (index - 1 + skyOptions.length) % skyOptions.length;
410
+ else if (event.key === 'Home') index = 0;
411
+ else if (event.key === 'End') index = skyOptions.length - 1;
412
+ else if (event.key === 'Tab') { closeSky(true); return; }
413
+ else return;
414
+ event.preventDefault();
415
+ focus(skyOptions[index]);
416
+ });
417
+ listen(skyMenu, 'focusout', (event) => {
418
+ if (!skyMenu.contains(event.relatedTarget) && event.relatedTarget !== sky) closeSky();
419
+ });
420
+ for (const target of [edge, rail]) {
421
+ listen(target, 'pointerenter', railEnter);
422
+ listen(target, 'pointerleave', railLeave);
423
+ listen(target, 'focusout', (event) => {
424
+ if (!rail.contains(event.relatedTarget) && event.relatedTarget !== edge) queueRailClose();
425
+ });
426
+ }
427
+ listen(edge, 'focus', () => { if (!restoringRailFocus) openRail(); });
428
+ listen(edge, 'click', () => { openRail(); focusContent(railBody, railTitle); });
429
+ listen(rail, 'focusin', cancelRailTimer);
430
+ listen(railClose, 'click', closeRail);
431
+ listen(taskClose, 'click', dismissTask);
432
+ listen(helpButton, 'click', toggleHelp);
433
+ listen(helpClose, 'click', closeHelp);
434
+ listen(root, 'keydown', (event) => {
435
+ if (event.key === 'Escape' && !event.defaultPrevented && dismissOverlay()) {
436
+ event.preventDefault();
437
+ event.stopPropagation();
438
+ }
439
+ });
440
+ // Pointer activity in an overlay must never start the canvas's orbit/picking
441
+ // gesture. Wheel scrolling stays native inside long details.
442
+ for (const name of ['pointerdown', 'pointerup', 'click', 'dblclick', 'wheel']) {
443
+ listen(root, name, (event) => event.stopPropagation());
444
+ }
445
+ const view = doc.defaultView;
446
+ if (view?.addEventListener) listen(view, 'resize', positionTask);
447
+ const Observer = view?.ResizeObserver ?? globalThis.ResizeObserver;
448
+ const observer = Observer ? new Observer(positionTask) : null;
449
+ observer?.observe(stage);
450
+ observer?.observe(taskBody);
451
+ setCities([]);
452
+
453
+ return {
454
+ setCities, setCity, setSky, showRail, showTask, closeTask, setTaskAnchor,
455
+ setHelp(nodes) { if (!disposed) helpContent.replaceChildren(...nodeList(nodes)); },
456
+ addNote, clearNotes, dismissOverlay,
457
+ dispose() {
458
+ if (disposed) return;
459
+ disposed = true;
460
+ cancelRailTimer();
461
+ observer?.disconnect();
462
+ for (const remove of listeners) remove();
463
+ overlayOrder = [];
464
+ root.remove();
465
+ },
466
+ };
467
+ }
@@ -51,7 +51,39 @@ export const DISTRICT_LEGEND = Object.freeze([
51
51
  ['is-ambience', '通りを歩く人と煙=街の雰囲気(記録とは関係しません)'],
52
52
  ]);
53
53
 
54
- export function createDistrictView({ doc, stage, reducedMotion = false, onPick, onClear, onEscape }) {
54
+ /**
55
+ * A world-space anchor projected to CSS pixels relative to the district stage. The viewport may be
56
+ * inset within the stage. Invisible anchors retain their task id; a cleared selection has no anchor.
57
+ * Check camera space as well as every clip plane: a point behind a perspective camera can otherwise
58
+ * appear to have plausible screen coordinates.
59
+ */
60
+ export function projectDistrictSelection({ taskId = null, anchor, camera, viewport, stage = viewport, visible = true }) {
61
+ const hidden = { taskId, x: null, y: null, visible: false };
62
+ if (!taskId || !anchor || !camera || !viewport?.width || !viewport?.height || !stage?.width || !stage?.height) return hidden;
63
+ camera.updateMatrixWorld(true);
64
+ const inCamera = anchor.clone().applyMatrix4(camera.matrixWorldInverse);
65
+ const point = anchor.clone().project(camera);
66
+ const x = ((point.x + 1) / 2) * viewport.width + (viewport.left ?? 0) - (stage.left ?? 0);
67
+ const y = ((1 - point.y) / 2) * viewport.height + (viewport.top ?? 0) - (stage.top ?? 0);
68
+ if (![x, y, point.z, inCamera.z].every(Number.isFinite)) return hidden;
69
+ return {
70
+ taskId,
71
+ x,
72
+ y,
73
+ visible: Boolean(visible && inCamera.z < 0 && point.z >= -1 && point.z <= 1
74
+ && Math.abs(point.x) <= 1 && Math.abs(point.y) <= 1
75
+ && x >= 0 && x <= stage.width && y >= 0 && y <= stage.height),
76
+ };
77
+ }
78
+
79
+ /**
80
+ * `showChrome: false` is for hosts that present DISTRICT_LEGEND and the work's read warnings in their
81
+ * own overlay. `focusSelection: false` leaves navigation with the viewer when selecting a work site.
82
+ * `onProjectSelection` receives { taskId, x, y, visible } on each rendered frame and immediately when
83
+ * selection, visibility, size or records change. Coordinates are stage-local CSS pixels, never device
84
+ * pixels; a cleared selection is { taskId: null, x: null, y: null, visible: false }.
85
+ */
86
+ export function createDistrictView({ doc, stage, reducedMotion = false, focusSelection = true, showChrome = true, onPick, onClear, onEscape, onProjectSelection }) {
55
87
  const renderer = new THREE.WebGLRenderer({ antialias: true });
56
88
  renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio ?? 1, 2));
57
89
  renderer.shadowMap.enabled = true;
@@ -301,6 +333,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
301
333
  let walkers = [];
302
334
  let puffs = [];
303
335
  let open = false;
336
+ let noticeVisible = false;
304
337
  let selected = null;
305
338
  let hovered = null;
306
339
  let flight = null;
@@ -717,7 +750,8 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
717
750
  scene.fog.color.set(sky.fog);
718
751
  scene.fog.near = readable ? 70 : 6;
719
752
  scene.fog.far = readable ? 170 : 34;
720
- notice.hidden = readable;
753
+ noticeVisible = !readable;
754
+ notice.hidden = !showChrome || !open || !noticeVisible;
721
755
  if (!readable) {
722
756
  const reason = work?.status === 'ok' ? work.reads.tasks.reason ?? work.reads.tasks.state : work?.reason ?? work?.status ?? '理由不明';
723
757
  notice.textContent = `仕事の記録を読めないため、区画の中は霧で見えません(${reason})。仕事が0件という意味ではありません。`;
@@ -738,10 +772,14 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
738
772
 
739
773
  function resize() {
740
774
  const rect = stage.getBoundingClientRect();
741
- if (!rect.width || !rect.height) return;
775
+ if (!rect.width || !rect.height) {
776
+ projectSelection();
777
+ return;
778
+ }
742
779
  renderer.setSize(rect.width, rect.height, false);
743
780
  camera.aspect = rect.width / rect.height;
744
781
  camera.updateProjectionMatrix();
782
+ projectSelection();
745
783
  }
746
784
  const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null;
747
785
  observer?.observe(stage);
@@ -780,19 +818,41 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
780
818
  }
781
819
  });
782
820
  renderer.domElement.addEventListener('keydown', (event) => {
783
- if (event.key === 'Escape') onEscape?.();
821
+ if (event.key !== 'Escape' || event.defaultPrevented || !onEscape) return;
822
+ event.preventDefault();
823
+ onEscape();
784
824
  });
785
825
 
786
- function select(group) {
826
+ function projectSelection() {
827
+ if (!onProjectSelection) return;
828
+ // Use the site's actual world transform, including any rising-building animation.
829
+ const anchor = selected?.localToWorld(new THREE.Vector3(0, 2.6, 0));
830
+ onProjectSelection(projectDistrictSelection({
831
+ taskId: selected?.userData?.site?.task_id ?? null,
832
+ anchor,
833
+ camera,
834
+ viewport: renderer.domElement.getBoundingClientRect(),
835
+ stage: stage.getBoundingClientRect(),
836
+ visible: open && selected?.visible !== false,
837
+ }));
838
+ }
839
+
840
+ function select(group, { focus = focusSelection } = {}) {
787
841
  selected = group;
788
842
  if (!group) {
789
843
  selectionRing.visible = false;
844
+ projectSelection();
790
845
  return;
791
846
  }
792
847
  selectionRing.position.set(group.position.x, 0.3, group.position.z);
793
848
  selectionRing.visible = true;
794
- const at = group.position.clone();
795
- fly(new THREE.Vector3(at.x, 0, at.z), new THREE.Vector3(at.x + (at.x > 0 ? 9 : -9), 11, at.z + 12));
849
+ if (focus) {
850
+ const at = group.position.clone();
851
+ fly(new THREE.Vector3(at.x, 0, at.z), new THREE.Vector3(at.x + (at.x > 0 ? 9 : -9), 11, at.z + 12));
852
+ // A reduced-motion selection changes the camera immediately rather than during a frame.
853
+ if (reducedMotion) camera.lookAt(controls.target);
854
+ }
855
+ projectSelection();
796
856
  }
797
857
 
798
858
  function applyFocus() {
@@ -899,6 +959,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
899
959
  if (selectionRing.visible) selectionRing.material.opacity = 0.6 + 0.3 * Math.sin(time / 300);
900
960
  renderer.render(scene, camera);
901
961
  placeLabels();
962
+ projectSelection();
902
963
  }
903
964
  requestAnimationFrame(frame);
904
965
 
@@ -906,8 +967,9 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
906
967
  open = visible;
907
968
  renderer.domElement.hidden = !visible;
908
969
  labelsLayer.hidden = !visible;
909
- legend.hidden = !visible;
910
- if (!visible) notice.hidden = true;
970
+ legend.hidden = !visible || !showChrome;
971
+ notice.hidden = !visible || !showChrome || !noticeVisible;
972
+ projectSelection();
911
973
  }
912
974
 
913
975
  return {
@@ -921,6 +983,8 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
921
983
  controls.target.copy(home.target);
922
984
  camera.lookAt(home.target);
923
985
  fly(home.target.clone(), home.position.clone(), 1300);
986
+ if (reducedMotion) camera.lookAt(controls.target);
987
+ projectSelection();
924
988
  renderer.domElement.focus({ preventScroll: true });
925
989
  },
926
990
  hide() {
@@ -954,14 +1018,18 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
954
1018
  celebrate,
955
1019
  /** Rebuilds the open district from fresh records, keeping the camera where it is. */
956
1020
  refresh(business, work, { neighbours = new Map() } = {}) {
957
- const keep = { target: controls.target.clone(), position: camera.position.clone() };
1021
+ const keep = { target: controls.target.clone(), position: camera.position.clone(), taskId: selected?.userData?.site?.task_id };
1022
+ flight = null;
1023
+ controls.enabled = true;
958
1024
  build(business, work, neighbours);
959
1025
  controls.target.copy(keep.target);
960
1026
  camera.position.copy(keep.position);
961
1027
  camera.lookAt(keep.target);
1028
+ select(lots.get(keep.taskId) ?? null, { focus: false });
962
1029
  },
963
1030
  dispose() {
964
1031
  running = false;
1032
+ setVisible(false);
965
1033
  observer?.disconnect();
966
1034
  controls.dispose();
967
1035
  renderer.dispose();
@@ -1,3 +1,5 @@
1
+ @import './world-canvas-ui.css';
2
+
1
3
  /* World view (ledger P17). Uses only the shared tokens. */
2
4
  .bb-world {
3
5
  display: flex;