@unson/brainbase-mcp 0.23.7 → 0.23.9
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/dist/world-extension.js +1 -1
- package/package.json +2 -2
- package/ui/outcome-knowledge.css +38 -8
- package/ui/outcome-knowledge.js +205 -30
- package/ui/world/world-canvas-notes.js +31 -0
- package/ui/world/world-canvas-ui.css +352 -0
- package/ui/world/world-canvas-ui.js +467 -0
- package/ui/world/world-district.js +79 -11
- package/ui/world/world-view.css +2 -0
- package/ui/world/world-view.js +241 -65
- package/ui/world/world-work-rail.js +3 -3
- package/ui/world-model-view.css +57 -0
- package/ui/world-model-view.js +45 -7
|
@@ -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
|
-
|
|
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,10 +750,11 @@ 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
|
-
|
|
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
|
-
notice.textContent = `仕事の記録を読めないため、区画の中は霧で見えません(${reason}
|
|
757
|
+
notice.textContent = `仕事の記録を読めないため、区画の中は霧で見えません(${reason})。仕事の件数は未確認です。0件とは確認できません。`;
|
|
724
758
|
}
|
|
725
759
|
home = { target: new THREE.Vector3(0, 0, -1.5), position: new THREE.Vector3(14, 30, gateZ + 24) };
|
|
726
760
|
}
|
|
@@ -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)
|
|
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
|
|
821
|
+
if (event.key !== 'Escape' || event.defaultPrevented || !onEscape) return;
|
|
822
|
+
event.preventDefault();
|
|
823
|
+
onEscape();
|
|
784
824
|
});
|
|
785
825
|
|
|
786
|
-
function
|
|
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
|
-
|
|
795
|
-
|
|
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
|
-
|
|
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();
|