@unson/brainbase-mcp 0.20.0 → 0.21.1
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/canonical-task-service.d.ts +4 -0
- package/dist/canonical-task-service.js +14 -0
- package/dist/graph-foundation-reader.d.ts +1 -1
- package/dist/graph-foundation-reader.js +1 -0
- package/dist/world-extension.d.ts +1 -1
- package/dist/world-extension.js +10 -1
- package/dist/world-work.d.ts +248 -0
- package/dist/world-work.js +558 -0
- package/package.json +2 -2
- package/ui/world/world-district.js +642 -0
- package/ui/world/world-placement.js +60 -0
- package/ui/world/world-view.css +237 -0
- package/ui/world/world-view.js +546 -47
- package/ui/world/world-work-rail.js +337 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Inside a district (story-world-work-sites-and-gaps-v1, AC-11〜15).
|
|
3
|
+
*
|
|
4
|
+
* Opening a city leaves the world and enters its district: a gate, a main street and two back streets,
|
|
5
|
+
* the business's hall with the stones of its policies, and its work on lots facing the streets. What a
|
|
6
|
+
* lot looks like is read only from the work's records:
|
|
7
|
+
* - its recorded state is its form (stakes, scaffolding with a net, a grey cover, a lit house);
|
|
8
|
+
* - what cannot be seen of it is a phenomenon of the street, never a label: nobody on site (no
|
|
9
|
+
* assignee), a person outside the fence (named only in the text), no path to the street (no source
|
|
10
|
+
* link), a blueprint without a building (a completion condition without its record), weeds and faded
|
|
11
|
+
* colours (a review date passed), fog (the work could not be read).
|
|
12
|
+
* People walking the streets and chimney smoke are the town's atmosphere: they come from no record,
|
|
13
|
+
* never enter a lot, and the legend says so. Nothing here writes.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { THREE, MapControls } from './world-vendor.js';
|
|
17
|
+
import { DISTRICT_STREETS, districtStreetLots } from './world-placement.js';
|
|
18
|
+
import { makeWorkspaceElement as el } from '../../workspace-kit.js';
|
|
19
|
+
|
|
20
|
+
export const WORLD_DISTRICT_CONTRACT_VERSION = 'brainbase.world-district.v0';
|
|
21
|
+
|
|
22
|
+
function hash(text) {
|
|
23
|
+
let h = 2166136261;
|
|
24
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
25
|
+
h ^= text.charCodeAt(i);
|
|
26
|
+
h = Math.imul(h, 16777619);
|
|
27
|
+
}
|
|
28
|
+
return (h >>> 0) / 4294967296;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const STATE_WALL = Object.freeze({ in_progress: 0xd8d2c4, waiting: 0xcfcac0, pending: 0xd8d2c4, completed: 0xf2ebdd });
|
|
32
|
+
const GAP = (site, kind) => site.gaps.some((gap) => gap.kind === kind);
|
|
33
|
+
|
|
34
|
+
/** The legend of the district: what each phenomenon means, and what means nothing. */
|
|
35
|
+
export const DISTRICT_LEGEND = Object.freeze([
|
|
36
|
+
['is-scaffold', '足場と緑のネット=進行中(記録上)'],
|
|
37
|
+
['is-cover', 'ブルーシートの覆い=待ち(記録上)'],
|
|
38
|
+
['is-stakes', '杭と縄の空き地=未着手(記録上)'],
|
|
39
|
+
['is-house', '明かりのついた家=完了(記録上)'],
|
|
40
|
+
['is-worker', 'ヘルメットの人=担当欄の担当'],
|
|
41
|
+
['is-empty', '誰もいない現場=担当の記録なし'],
|
|
42
|
+
['is-outside', '柵の外の人(破線の輪)=本文にだけ名前がある'],
|
|
43
|
+
['is-nopath', '通りへの道が無い=出典リンクなし'],
|
|
44
|
+
['is-blueprint', '図面の看板だけ=成果物の記録が未接続'],
|
|
45
|
+
['is-weeds', '雑草と色あせ=見直し予定を過ぎた'],
|
|
46
|
+
['is-stone', '庁舎前の石碑=方針の決定(枠だけ=題名からの推定)'],
|
|
47
|
+
['is-street', '通りの看板=何のための仕事か(タスクの記録の言葉。言葉が無い仕事は門の近くの空き地)'],
|
|
48
|
+
['is-ambience', '通りを歩く人と煙=街の雰囲気(記録とは関係しません)'],
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
function canvasTexture(doc, width, height, draw) {
|
|
52
|
+
const canvas = doc.createElement('canvas');
|
|
53
|
+
canvas.width = width;
|
|
54
|
+
canvas.height = height;
|
|
55
|
+
draw(canvas.getContext('2d'), width, height);
|
|
56
|
+
const texture = new THREE.CanvasTexture(canvas);
|
|
57
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
58
|
+
return texture;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createDistrictView({ doc, stage, reducedMotion = false, onPick, onClear, onEscape }) {
|
|
62
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
63
|
+
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio ?? 1, 2));
|
|
64
|
+
renderer.shadowMap.enabled = true;
|
|
65
|
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
66
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
67
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
68
|
+
renderer.domElement.className = 'bb-world-canvas bb-world-district-canvas';
|
|
69
|
+
renderer.domElement.tabIndex = 0;
|
|
70
|
+
renderer.domElement.hidden = true;
|
|
71
|
+
stage.prepend(renderer.domElement);
|
|
72
|
+
const labelsLayer = el(doc, 'div', { className: 'bb-world-labels is-district' });
|
|
73
|
+
labelsLayer.hidden = true;
|
|
74
|
+
stage.append(labelsLayer);
|
|
75
|
+
const legend = el(doc, 'details', { className: 'bb-world-district-legend', attrs: { open: true } });
|
|
76
|
+
legend.append(el(doc, 'summary', { text: '区画の見方' }));
|
|
77
|
+
const legendList = el(doc, 'ul');
|
|
78
|
+
for (const [className, text] of DISTRICT_LEGEND) legendList.append(el(doc, 'li', { className, text }));
|
|
79
|
+
legend.append(legendList);
|
|
80
|
+
legend.hidden = true;
|
|
81
|
+
stage.append(legend);
|
|
82
|
+
const notice = el(doc, 'p', { className: 'bb-world-district-notice', attrs: { role: 'status' } });
|
|
83
|
+
notice.hidden = true;
|
|
84
|
+
stage.append(notice);
|
|
85
|
+
|
|
86
|
+
const scene = new THREE.Scene();
|
|
87
|
+
scene.background = canvasTexture(doc, 16, 256, (ctx, w, h) => {
|
|
88
|
+
const gradient = ctx.createLinearGradient(0, 0, 0, h);
|
|
89
|
+
gradient.addColorStop(0, '#a9cdea');
|
|
90
|
+
gradient.addColorStop(0.6, '#e2eef0');
|
|
91
|
+
gradient.addColorStop(1, '#f3efe3');
|
|
92
|
+
ctx.fillStyle = gradient;
|
|
93
|
+
ctx.fillRect(0, 0, w, h);
|
|
94
|
+
});
|
|
95
|
+
scene.fog = new THREE.Fog(0xe7efee, 70, 170);
|
|
96
|
+
scene.add(new THREE.HemisphereLight(0xf6f9ff, 0xd8ccb2, 1.25));
|
|
97
|
+
const sun = new THREE.DirectionalLight(0xfff0d8, 2.4);
|
|
98
|
+
sun.position.set(30, 55, 22);
|
|
99
|
+
sun.castShadow = true;
|
|
100
|
+
sun.shadow.mapSize.set(2048, 2048);
|
|
101
|
+
Object.assign(sun.shadow.camera, { left: -45, right: 45, top: 45, bottom: -45, near: 1, far: 160 });
|
|
102
|
+
sun.shadow.bias = -0.0005;
|
|
103
|
+
scene.add(sun);
|
|
104
|
+
|
|
105
|
+
const camera = new THREE.PerspectiveCamera(40, 1, 0.5, 600);
|
|
106
|
+
const controls = new MapControls(camera, renderer.domElement);
|
|
107
|
+
controls.enableDamping = !reducedMotion;
|
|
108
|
+
controls.dampingFactor = 0.08;
|
|
109
|
+
controls.screenSpacePanning = false;
|
|
110
|
+
controls.minDistance = 8;
|
|
111
|
+
controls.maxDistance = 95;
|
|
112
|
+
controls.maxPolarAngle = 1.22;
|
|
113
|
+
controls.minPolarAngle = 0.35;
|
|
114
|
+
|
|
115
|
+
const material = (color, options = {}) => new THREE.MeshStandardMaterial({ color, roughness: 0.85, metalness: 0.02, ...options });
|
|
116
|
+
const mesh = (geometry, mat, { x = 0, y = 0, z = 0, shadow = true } = {}) => {
|
|
117
|
+
const object = new THREE.Mesh(geometry, mat);
|
|
118
|
+
object.position.set(x, y, z);
|
|
119
|
+
object.castShadow = shadow;
|
|
120
|
+
object.receiveShadow = true;
|
|
121
|
+
return object;
|
|
122
|
+
};
|
|
123
|
+
const box = (w, h, d, color, at = {}, options = {}) => mesh(new THREE.BoxGeometry(w, h, d), material(color, options), { y: h / 2, ...at });
|
|
124
|
+
|
|
125
|
+
let root = null;
|
|
126
|
+
let lots = new Map();
|
|
127
|
+
let labels = [];
|
|
128
|
+
let walkers = [];
|
|
129
|
+
let puffs = [];
|
|
130
|
+
let open = false;
|
|
131
|
+
let selected = null;
|
|
132
|
+
let hovered = null;
|
|
133
|
+
let flight = null;
|
|
134
|
+
let home = null;
|
|
135
|
+
let focusIds = null;
|
|
136
|
+
const selectionRing = mesh(new THREE.RingGeometry(1.75, 1.95, 48), new THREE.MeshBasicMaterial({ color: 0x087d62, transparent: true, opacity: 0.9, side: THREE.DoubleSide }), { y: 0.09, shadow: false });
|
|
137
|
+
selectionRing.rotation.x = -Math.PI / 2;
|
|
138
|
+
selectionRing.visible = false;
|
|
139
|
+
|
|
140
|
+
function addLabel(text, position, className, { owner = null, near = Infinity, priority = 5 } = {}) {
|
|
141
|
+
const node = el(doc, 'div', { className: `bb-world-label ${className}`, text });
|
|
142
|
+
node.setAttribute('aria-hidden', 'true');
|
|
143
|
+
labelsLayer.append(node);
|
|
144
|
+
const label = { node, position, owner, near, priority, size: null };
|
|
145
|
+
labels.push(label);
|
|
146
|
+
return label;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- people (records: worker, person named in the text; atmosphere: walkers) ---------------------
|
|
150
|
+
function person(clothes, { helmet = false } = {}) {
|
|
151
|
+
const group = new THREE.Group();
|
|
152
|
+
group.add(mesh(new THREE.CapsuleGeometry(0.2, 0.46, 4, 10), material(clothes), { y: 0.5 }));
|
|
153
|
+
group.add(mesh(new THREE.SphereGeometry(0.17, 12, 10), material(0xf0cfae), { y: 1.0 }));
|
|
154
|
+
if (helmet) group.add(mesh(new THREE.SphereGeometry(0.19, 12, 8, 0, Math.PI * 2, 0, Math.PI / 2), material(0xf2c230, { roughness: 0.5 }), { y: 1.05 }));
|
|
155
|
+
return group;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- a lot, in local space: its front (the street) toward +z -------------------------------------
|
|
159
|
+
function lotGroup(site) {
|
|
160
|
+
const group = new THREE.Group();
|
|
161
|
+
const overdue = GAP(site, 'review_overdue');
|
|
162
|
+
const fade = (color) => (overdue ? new THREE.Color(color).lerp(new THREE.Color(0xb9b29a), 0.45) : new THREE.Color(color));
|
|
163
|
+
const half = DISTRICT_STREETS.lot / 2;
|
|
164
|
+
group.add(box(DISTRICT_STREETS.lot, 0.06, DISTRICT_STREETS.lot, overdue ? 0xc9c0a2 : 0xd9d4c4));
|
|
165
|
+
const status = site.work.status;
|
|
166
|
+
const building = status === 'in_progress' || status === 'waiting';
|
|
167
|
+
if (status !== 'completed') {
|
|
168
|
+
// A low site fence, open toward the street.
|
|
169
|
+
const fenceMat = material(0xe9e4d6);
|
|
170
|
+
for (const [x, z, w, d] of [[0, -half + 0.05, DISTRICT_STREETS.lot, 0.06], [-half + 0.05, 0, 0.06, DISTRICT_STREETS.lot], [half - 0.05, 0, 0.06, DISTRICT_STREETS.lot], [-half * 0.65, half - 0.05, half * 0.7, 0.06], [half * 0.65, half - 0.05, half * 0.7, 0.06]]) {
|
|
171
|
+
group.add(mesh(new THREE.BoxGeometry(w, 0.35, d), fenceMat, { x, y: 0.2, z }));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (status === 'pending' || status === null) {
|
|
175
|
+
// Stakes and string: the lot is marked, nothing is built.
|
|
176
|
+
const stake = material(0x9b7b55);
|
|
177
|
+
for (const [x, z] of [[-0.8, -0.8], [0.8, -0.8], [0.8, 0.6], [-0.8, 0.6]]) group.add(mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.6, 6), stake, { x, y: 0.3, z }));
|
|
178
|
+
const string = material(0xf4f1e8);
|
|
179
|
+
for (const [x, z, w, d] of [[0, -0.8, 1.6, 0.02], [0, 0.6, 1.6, 0.02], [-0.8, -0.1, 0.02, 1.4], [0.8, -0.1, 0.02, 1.4]]) group.add(mesh(new THREE.BoxGeometry(w, 0.02, d), string, { x, y: 0.5, z, shadow: false }));
|
|
180
|
+
}
|
|
181
|
+
if (building) {
|
|
182
|
+
const floors = 1 + Math.floor(hash(`${site.task_id}f`) * 2);
|
|
183
|
+
const height = 0.75 * floors;
|
|
184
|
+
group.add(box(1.7, height, 1.5, fade(STATE_WALL[status]), { z: -0.25 }));
|
|
185
|
+
// Scaffolding: a frame of edges around the building.
|
|
186
|
+
const frame = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(2.0, height + 0.6, 1.8)), new THREE.LineBasicMaterial({ color: 0x8c8f8a }));
|
|
187
|
+
frame.position.set(0, (height + 0.6) / 2, -0.25);
|
|
188
|
+
group.add(frame);
|
|
189
|
+
if (status === 'in_progress') {
|
|
190
|
+
const net = mesh(new THREE.PlaneGeometry(2.0, height + 0.5), material(0x4f9a5c, { transparent: true, opacity: 0.55, side: THREE.DoubleSide }), { y: (height + 0.5) / 2, z: 0.66, shadow: false });
|
|
191
|
+
group.add(net);
|
|
192
|
+
// A crane over a site being worked.
|
|
193
|
+
group.add(mesh(new THREE.BoxGeometry(0.12, 3.6, 0.12), material(0xe0a526), { x: 0.9, y: 1.8, z: -0.9 }));
|
|
194
|
+
group.add(mesh(new THREE.BoxGeometry(2.2, 0.1, 0.1), material(0xe0a526), { x: 0.2, y: 3.5, z: -0.9 }));
|
|
195
|
+
} else {
|
|
196
|
+
// Waiting: a blue tarp over the building, as on a site where work is held.
|
|
197
|
+
group.add(box(1.9, height + 0.4, 1.7, fade(0x3f78b5), { z: -0.25 }, { transparent: true, opacity: 0.9, roughness: 0.6 }));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (status === 'completed') {
|
|
201
|
+
group.add(box(1.6, 1.0, 1.4, 0xf2ebdd, { z: -0.3 }));
|
|
202
|
+
const roof = mesh(new THREE.ConeGeometry(1.25, 0.7, 4), material(0xb5654a), { y: 1.35, z: -0.3 });
|
|
203
|
+
roof.rotation.y = Math.PI / 4;
|
|
204
|
+
group.add(roof);
|
|
205
|
+
group.add(mesh(new THREE.BoxGeometry(0.5, 0.36, 0.04), material(0xffd98c, { emissive: new THREE.Color(0xffc867), emissiveIntensity: 0.9 }), { y: 0.55, z: 0.41, shadow: false }));
|
|
206
|
+
group.add(mesh(new THREE.BoxGeometry(0.2, 0.5, 0.2), material(0x9c7a62), { x: 0.4, y: 1.5, z: -0.6 }));
|
|
207
|
+
group.userData.chimney = new THREE.Vector3(0.4, 1.8, -0.6);
|
|
208
|
+
}
|
|
209
|
+
// A path to the street only when the record links its source.
|
|
210
|
+
if (!GAP(site, 'source_unlinked') && !GAP(site, 'outcome_unlinked')) {
|
|
211
|
+
group.add(mesh(new THREE.BoxGeometry(0.7, 0.04, 1.0), material(0xbdb7aa), { y: 0.05, z: half + 0.45, shadow: false }));
|
|
212
|
+
}
|
|
213
|
+
// A blueprint stands where the completion condition is, with no record of what was made.
|
|
214
|
+
if (GAP(site, 'outcome_unlinked')) {
|
|
215
|
+
const board = canvasTexture(doc, 64, 48, (ctx, w, h) => {
|
|
216
|
+
ctx.fillStyle = '#2f5d9c';
|
|
217
|
+
ctx.fillRect(0, 0, w, h);
|
|
218
|
+
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
|
|
219
|
+
ctx.lineWidth = 1.5;
|
|
220
|
+
ctx.strokeRect(6, 6, w - 12, h - 12);
|
|
221
|
+
ctx.strokeRect(14, 14, 18, 12);
|
|
222
|
+
ctx.beginPath();
|
|
223
|
+
ctx.moveTo(36, 14);
|
|
224
|
+
ctx.lineTo(56, 30);
|
|
225
|
+
ctx.stroke();
|
|
226
|
+
});
|
|
227
|
+
group.add(mesh(new THREE.BoxGeometry(0.05, 0.9, 0.05), material(0x6b5b4a), { x: 0.6, y: 0.45, z: 0.9 }));
|
|
228
|
+
group.add(mesh(new THREE.BoxGeometry(0.9, 0.62, 0.04), [material(0xf3efe6), material(0xf3efe6), material(0xf3efe6), material(0xf3efe6), new THREE.MeshBasicMaterial({ map: board }), material(0xf3efe6)], { x: 0.6, y: 1.05, z: 0.92 }));
|
|
229
|
+
}
|
|
230
|
+
// Weeds where the record was not looked at again by its review date.
|
|
231
|
+
if (overdue) {
|
|
232
|
+
const weed = material(0x7f8f4a, { flatShading: true });
|
|
233
|
+
for (let i = 0; i < 7; i += 1) {
|
|
234
|
+
const x = (hash(`${site.task_id}w${i}x`) - 0.5) * 2.4;
|
|
235
|
+
const z = (hash(`${site.task_id}w${i}z`) - 0.5) * 2.4;
|
|
236
|
+
group.add(mesh(new THREE.ConeGeometry(0.09, 0.32 + hash(`${site.task_id}w${i}h`) * 0.25, 5), weed, { x, y: 0.2, z, shadow: false }));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// People from the records: the assignee on site; a person named only in the text, outside the fence.
|
|
240
|
+
const recorded = site.people.filter((entry) => entry.link === 'recorded');
|
|
241
|
+
const named = site.people.filter((entry) => entry.link === 'inferred').length + (site.ambiguous_mentions?.length ?? 0);
|
|
242
|
+
if (recorded.length && status !== 'completed') {
|
|
243
|
+
const worker = person(0x3d6f9e, { helmet: true });
|
|
244
|
+
worker.position.set(0.55, 0.06, 0.55);
|
|
245
|
+
group.add(worker);
|
|
246
|
+
}
|
|
247
|
+
if (named && status !== 'completed') {
|
|
248
|
+
const outside = person(0x6b5b95);
|
|
249
|
+
outside.position.set(-0.95, 0, half + 0.65);
|
|
250
|
+
outside.rotation.y = Math.PI;
|
|
251
|
+
group.add(outside);
|
|
252
|
+
const ringMat = new THREE.MeshBasicMaterial({ color: 0x5b4a8a, side: THREE.DoubleSide });
|
|
253
|
+
for (let i = 0; i < 8; i += 1) {
|
|
254
|
+
const arc = mesh(new THREE.RingGeometry(0.34, 0.42, 6, 1, (i / 8) * Math.PI * 2, Math.PI / 8), ringMat, { x: -0.95, y: 0.03, z: half + 0.65, shadow: false });
|
|
255
|
+
arc.rotation.x = -Math.PI / 2;
|
|
256
|
+
group.add(arc);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return group;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- the district -------------------------------------------------------------------------------
|
|
263
|
+
function clear() {
|
|
264
|
+
if (root) scene.remove(root);
|
|
265
|
+
for (const label of labels) label.node.remove();
|
|
266
|
+
labels = [];
|
|
267
|
+
lots = new Map();
|
|
268
|
+
walkers = [];
|
|
269
|
+
puffs = [];
|
|
270
|
+
selected = null;
|
|
271
|
+
hovered = null;
|
|
272
|
+
focusIds = null;
|
|
273
|
+
selectionRing.visible = false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function build(business, work, neighbours) {
|
|
277
|
+
clear();
|
|
278
|
+
root = new THREE.Group();
|
|
279
|
+
scene.add(root);
|
|
280
|
+
root.add(selectionRing);
|
|
281
|
+
const readable = work?.status === 'ok' && (work.reads.tasks.state === 'complete' || work.reads.tasks.state === 'partial');
|
|
282
|
+
const shown = readable ? work.sites.filter((site) => site.work.status !== 'cancelled') : [];
|
|
283
|
+
const layout = districtStreetLots(shown.map((site) => ({ task_id: site.task_id, created_at: site.work.created_at, purpose_label: site.purpose_label })));
|
|
284
|
+
const L = layout.length;
|
|
285
|
+
const gateZ = L / 2 + 4;
|
|
286
|
+
const hallZ = -L / 2 - 7;
|
|
287
|
+
// Land, the district's paving, the main street and the back streets.
|
|
288
|
+
const land = mesh(new THREE.PlaneGeometry(260, 260), material(0xa9c595, { roughness: 1 }), { shadow: false });
|
|
289
|
+
land.rotation.x = -Math.PI / 2;
|
|
290
|
+
root.add(land);
|
|
291
|
+
const paving = mesh(new THREE.PlaneGeometry(26, L + 22), material(0xe9e4d6, { roughness: 1 }), { y: 0.01, z: (gateZ + hallZ) / 2, shadow: false });
|
|
292
|
+
paving.rotation.x = -Math.PI / 2;
|
|
293
|
+
root.add(paving);
|
|
294
|
+
const asphalt = material(0x8e938f, { roughness: 0.95 });
|
|
295
|
+
const street = (x, width, from, to) => {
|
|
296
|
+
const strip = mesh(new THREE.PlaneGeometry(width, Math.abs(to - from)), asphalt, { x, y: 0.02, z: (from + to) / 2, shadow: false });
|
|
297
|
+
strip.rotation.x = -Math.PI / 2;
|
|
298
|
+
root.add(strip);
|
|
299
|
+
};
|
|
300
|
+
street(0, DISTRICT_STREETS.main * 2, gateZ + 8, hallZ + 3);
|
|
301
|
+
street(-DISTRICT_STREETS.backStreet, 2, L / 2 + 1, -L / 2 - 1);
|
|
302
|
+
street(DISTRICT_STREETS.backStreet, 2, L / 2 + 1, -L / 2 - 1);
|
|
303
|
+
// A cross street between the blocks, and at the hall end of each block a sign saying what its work is for.
|
|
304
|
+
const crossWidth = DISTRICT_STREETS.backLot * 2 + 4;
|
|
305
|
+
layout.streets.forEach((block, index) => {
|
|
306
|
+
if (index < layout.streets.length - 1) {
|
|
307
|
+
const strip = mesh(new THREE.PlaneGeometry(crossWidth, DISTRICT_STREETS.cross * 0.7), asphalt, { y: 0.02, z: block.z_to + DISTRICT_STREETS.cross / 2, shadow: false });
|
|
308
|
+
strip.rotation.x = -Math.PI / 2;
|
|
309
|
+
root.add(strip);
|
|
310
|
+
}
|
|
311
|
+
const signZ = block.z_from - (index === 0 ? 0.9 : DISTRICT_STREETS.cross / 2);
|
|
312
|
+
root.add(mesh(new THREE.CylinderGeometry(0.07, 0.07, 2.4, 6), material(0x4a4a4a), { x: -DISTRICT_STREETS.main - 0.5, y: 1.2, z: signZ }));
|
|
313
|
+
root.add(mesh(new THREE.BoxGeometry(1.9, 0.5, 0.08), material(block.label ? 0x2f6e4f : 0xbfb8a8), { x: -DISTRICT_STREETS.main - 0.5, y: 2.3, z: signZ }));
|
|
314
|
+
const text = block.label ? `${block.label}(${block.count}件)` : `未分類の空き地:何のための仕事か未記録(${block.count}件)`;
|
|
315
|
+
addLabel(text, new THREE.Vector3(-DISTRICT_STREETS.main - 0.5, 2.9, signZ), `is-street${block.label ? '' : ' is-unlabelled'}`, { priority: 2 });
|
|
316
|
+
});
|
|
317
|
+
// Trees and lamps along the main street, between the lots (scenery).
|
|
318
|
+
const trunk = material(0x7d6249);
|
|
319
|
+
const crown = material(0x6f9a5c, { flatShading: true });
|
|
320
|
+
for (let z = -L / 2; z <= L / 2; z += DISTRICT_STREETS.row) {
|
|
321
|
+
for (const x of [-2.4, 2.4]) {
|
|
322
|
+
const at = z + DISTRICT_STREETS.row / 2;
|
|
323
|
+
if (at > L / 2) continue;
|
|
324
|
+
root.add(mesh(new THREE.CylinderGeometry(0.08, 0.1, 0.8, 6), trunk, { x, y: 0.4, z: at }));
|
|
325
|
+
root.add(mesh(new THREE.IcosahedronGeometry(0.55, 0), crown, { x, y: 1.15, z: at }));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// The gate.
|
|
329
|
+
const stone = material(0xe2dccf);
|
|
330
|
+
root.add(box(0.7, 4.2, 0.7, 0xe2dccf, { x: -3.2, z: gateZ }));
|
|
331
|
+
root.add(box(0.7, 4.2, 0.7, 0xe2dccf, { x: 3.2, z: gateZ }));
|
|
332
|
+
root.add(mesh(new THREE.BoxGeometry(7.6, 0.6, 0.9), stone, { y: 4.4, z: gateZ }));
|
|
333
|
+
addLabel(`${business.name}の区画(入口)`, new THREE.Vector3(0, 5.2, gateZ), 'is-city', { priority: 1 });
|
|
334
|
+
// The hall: what this business is for, and the stones of its policies in front of it.
|
|
335
|
+
root.add(box(8, 3.6, 4.4, 0xeee8dc, { z: hallZ }));
|
|
336
|
+
root.add(mesh(new THREE.BoxGeometry(8.6, 0.3, 5.0), stone, { y: 3.75, z: hallZ }));
|
|
337
|
+
for (let i = 0; i < 6; i += 1) root.add(mesh(new THREE.CylinderGeometry(0.16, 0.18, 3.0, 10), material(0xf7f4ee), { x: -3.1 + i * 1.24, y: 1.5, z: hallZ + 2.5 }));
|
|
338
|
+
const pediment = mesh(new THREE.ConeGeometry(4.9, 1.2, 4), material(0xf7f4ee), { y: 4.5, z: hallZ });
|
|
339
|
+
pediment.rotation.y = Math.PI / 4;
|
|
340
|
+
pediment.scale.z = 0.6;
|
|
341
|
+
root.add(pediment);
|
|
342
|
+
root.add(box(0.4, 0.9, 0.4, 0x9c7a62, { x: 2.8, y: 3.9, z: hallZ - 1.2 }));
|
|
343
|
+
const hallChimney = new THREE.Vector3(2.8, 4.8, hallZ - 1.2);
|
|
344
|
+
const purposeText = work?.status === 'ok' ? (work.purpose.text ?? '目的は未登録') : '目的を読めません';
|
|
345
|
+
addLabel(`庁舎:${business.name}(${purposeText})`, new THREE.Vector3(0, 5.6, hallZ), 'is-plaza', { priority: 1 });
|
|
346
|
+
const plaza = mesh(new THREE.CircleGeometry(3.6, 40), material(0xf1ece1), { y: 0.03, z: hallZ + 5.4, shadow: false });
|
|
347
|
+
plaza.rotation.x = -Math.PI / 2;
|
|
348
|
+
root.add(plaza);
|
|
349
|
+
const policies = work?.status === 'ok' ? work.purpose.policies.slice(0, 8) : [];
|
|
350
|
+
policies.forEach((policy, index) => {
|
|
351
|
+
const angle = Math.PI * (0.15 + (0.7 * index) / Math.max(policies.length - 1, 1));
|
|
352
|
+
const x = Math.cos(angle) * 2.8;
|
|
353
|
+
const z = hallZ + 5.4 + Math.sin(angle) * 1.6;
|
|
354
|
+
if (policy.link === 'recorded') {
|
|
355
|
+
root.add(box(0.5, 1.2, 0.22, 0xa8a49a, { x, z }));
|
|
356
|
+
} else {
|
|
357
|
+
const outline = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(0.5, 1.2, 0.22)), new THREE.LineDashedMaterial({ color: 0x5b4a8a, dashSize: 0.12, gapSize: 0.08 }));
|
|
358
|
+
outline.computeLineDistances();
|
|
359
|
+
outline.position.set(x, 0.6, z);
|
|
360
|
+
root.add(outline);
|
|
361
|
+
}
|
|
362
|
+
const title = policy.title.length > 18 ? `${policy.title.slice(0, 17)}…` : policy.title;
|
|
363
|
+
addLabel(`${policy.link === 'recorded' ? '━' : '┅'} ${title}`, new THREE.Vector3(x, 1.5, z), `is-link is-${policy.link === 'recorded' ? 'recorded' : 'inferred'}`, { near: 30, priority: 4 });
|
|
364
|
+
});
|
|
365
|
+
// Signposts outside the gate: the roads to other businesses.
|
|
366
|
+
const roads = new Map();
|
|
367
|
+
for (const relation of work?.status === 'ok' ? work.relations : []) {
|
|
368
|
+
if (!relation.business_code || relation.link === 'unreadable') continue;
|
|
369
|
+
const key = `${relation.business_code}|${relation.link}`;
|
|
370
|
+
if (!roads.has(key)) roads.set(key, { link: relation.link, name: neighbours.get(relation.business_code) ?? relation.business_code, texts: [] });
|
|
371
|
+
roads.get(key).texts.push(relation.label);
|
|
372
|
+
}
|
|
373
|
+
[...roads.values()].forEach((road, index) => {
|
|
374
|
+
const x = index % 2 === 0 ? -5.4 : 5.4;
|
|
375
|
+
const z = gateZ + 2.2 + Math.floor(index / 2) * 1.6;
|
|
376
|
+
root.add(mesh(new THREE.CylinderGeometry(0.06, 0.06, 2.0, 6), material(0x6b5b4a), { x, y: 1.0, z }));
|
|
377
|
+
root.add(mesh(new THREE.BoxGeometry(1.6, 0.36, 0.06), material(road.link === 'recorded' ? 0x1261ad : 0xd9d2e6), { x, y: 1.8, z }));
|
|
378
|
+
addLabel(`${road.link === 'recorded' ? '━' : '┅'} ${road.name}へ:${road.texts[0]}`, new THREE.Vector3(x, 2.3, z), `is-link is-${road.link}`, { priority: 3 });
|
|
379
|
+
});
|
|
380
|
+
// The lots.
|
|
381
|
+
for (const site of shown) {
|
|
382
|
+
const lot = layout.lots[site.task_id];
|
|
383
|
+
const group = lotGroup(site);
|
|
384
|
+
group.position.set(lot.x, 0, lot.z);
|
|
385
|
+
group.rotation.y = lot.facing > 0 ? Math.PI / 2 : -Math.PI / 2;
|
|
386
|
+
group.userData = { kind: 'site', site, chimney: group.userData.chimney ?? null };
|
|
387
|
+
root.add(group);
|
|
388
|
+
lots.set(site.task_id, group);
|
|
389
|
+
const title = site.title.length > 15 ? `${site.title.slice(0, 14)}…` : site.title;
|
|
390
|
+
addLabel(title, new THREE.Vector3(lot.x, 2.6, lot.z), `is-site${site.gaps.length ? ' is-check' : ''}`, { owner: group, near: 15, priority: 6 });
|
|
391
|
+
}
|
|
392
|
+
// Atmosphere: walkers on the sidewalks and back streets, smoke from chimneys (no record behind them).
|
|
393
|
+
const routes = [-1.55, 1.55, -DISTRICT_STREETS.backStreet + 0.6, DISTRICT_STREETS.backStreet - 0.6];
|
|
394
|
+
const palette = [0xc0504d, 0x4f81bd, 0x9bbb59, 0x8064a2, 0xf79646, 0x4bacc6, 0x7f7f7f, 0xd99694];
|
|
395
|
+
for (let i = 0; i < 10; i += 1) {
|
|
396
|
+
const walker = person(palette[i % palette.length]);
|
|
397
|
+
const x = routes[i % routes.length];
|
|
398
|
+
walker.userData = { x, from: -L / 2 - 1, to: L / 2 + (x === routes[0] || x === routes[1] ? 6 : 1), speed: 0.6 + hash(`walk${i}s`) * 0.6, phase: hash(`walk${i}p`) };
|
|
399
|
+
root.add(walker);
|
|
400
|
+
walkers.push(walker);
|
|
401
|
+
}
|
|
402
|
+
root.updateMatrixWorld(true);
|
|
403
|
+
const chimneys = [hallChimney];
|
|
404
|
+
for (const group of lots.values()) {
|
|
405
|
+
if (!group.userData.chimney) continue;
|
|
406
|
+
chimneys.push(group.localToWorld(group.userData.chimney.clone()));
|
|
407
|
+
}
|
|
408
|
+
const smoke = new THREE.MeshLambertMaterial({ color: 0xf3f3f3, transparent: true, opacity: 0.5, depthWrite: false });
|
|
409
|
+
for (const at of chimneys) {
|
|
410
|
+
for (let i = 0; i < 4; i += 1) {
|
|
411
|
+
const puff = mesh(new THREE.SphereGeometry(0.22, 8, 6), smoke.clone(), { x: at.x, y: at.y, z: at.z, shadow: false });
|
|
412
|
+
puff.userData = { base: at.clone(), phase: i / 4 };
|
|
413
|
+
root.add(puff);
|
|
414
|
+
puffs.push(puff);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Fog when the work could not be read: the district cannot be seen, which is not empty.
|
|
418
|
+
scene.fog.near = readable ? 70 : 6;
|
|
419
|
+
scene.fog.far = readable ? 170 : 34;
|
|
420
|
+
notice.hidden = readable;
|
|
421
|
+
if (!readable) {
|
|
422
|
+
const reason = work?.status === 'ok' ? work.reads.tasks.reason ?? work.reads.tasks.state : work?.reason ?? work?.status ?? '理由不明';
|
|
423
|
+
notice.textContent = `仕事の記録を読めないため、区画の中は霧で見えません(${reason})。仕事が0件という意味ではありません。`;
|
|
424
|
+
}
|
|
425
|
+
home = { target: new THREE.Vector3(0, 0, -1.5), position: new THREE.Vector3(14, 30, gateZ + 24) };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// --- camera, picking, frame loop ------------------------------------------------------------------
|
|
429
|
+
function fly(target, position, duration = 900) {
|
|
430
|
+
if (reducedMotion) {
|
|
431
|
+
controls.target.copy(target);
|
|
432
|
+
camera.position.copy(position);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
controls.enabled = false;
|
|
436
|
+
flight = { from: { target: controls.target.clone(), position: camera.position.clone() }, to: { target, position }, started: null, duration };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function resize() {
|
|
440
|
+
const rect = stage.getBoundingClientRect();
|
|
441
|
+
if (!rect.width || !rect.height) return;
|
|
442
|
+
renderer.setSize(rect.width, rect.height, false);
|
|
443
|
+
camera.aspect = rect.width / rect.height;
|
|
444
|
+
camera.updateProjectionMatrix();
|
|
445
|
+
}
|
|
446
|
+
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null;
|
|
447
|
+
observer?.observe(stage);
|
|
448
|
+
|
|
449
|
+
const raycaster = new THREE.Raycaster();
|
|
450
|
+
const pointer = new THREE.Vector2();
|
|
451
|
+
function pickAt(clientX, clientY) {
|
|
452
|
+
const rect = renderer.domElement.getBoundingClientRect();
|
|
453
|
+
pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
|
454
|
+
pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
|
455
|
+
raycaster.setFromCamera(pointer, camera);
|
|
456
|
+
const targets = [...lots.values()];
|
|
457
|
+
for (const hit of raycaster.intersectObjects(targets, true)) {
|
|
458
|
+
let object = hit.object;
|
|
459
|
+
while (object && !targets.includes(object)) object = object.parent;
|
|
460
|
+
if (object) return object;
|
|
461
|
+
}
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
let downAt = null;
|
|
465
|
+
renderer.domElement.addEventListener('pointerdown', (event) => { downAt = { x: event.clientX, y: event.clientY }; });
|
|
466
|
+
renderer.domElement.addEventListener('pointermove', (event) => {
|
|
467
|
+
const group = pickAt(event.clientX, event.clientY);
|
|
468
|
+
hovered = group;
|
|
469
|
+
renderer.domElement.style.cursor = group ? 'pointer' : '';
|
|
470
|
+
});
|
|
471
|
+
renderer.domElement.addEventListener('pointerup', (event) => {
|
|
472
|
+
if (!downAt || Math.hypot(event.clientX - downAt.x, event.clientY - downAt.y) > 5) return;
|
|
473
|
+
const group = pickAt(event.clientX, event.clientY);
|
|
474
|
+
if (group) {
|
|
475
|
+
select(group);
|
|
476
|
+
onPick?.(group.userData.site);
|
|
477
|
+
} else {
|
|
478
|
+
select(null);
|
|
479
|
+
onClear?.();
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
renderer.domElement.addEventListener('keydown', (event) => {
|
|
483
|
+
if (event.key === 'Escape') onEscape?.();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
function select(group) {
|
|
487
|
+
selected = group;
|
|
488
|
+
if (!group) {
|
|
489
|
+
selectionRing.visible = false;
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
selectionRing.position.set(group.position.x, 0.09, group.position.z);
|
|
493
|
+
selectionRing.visible = true;
|
|
494
|
+
const at = group.position.clone();
|
|
495
|
+
fly(new THREE.Vector3(at.x, 0, at.z), new THREE.Vector3(at.x + (at.x > 0 ? 9 : -9), 11, at.z + 12));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function applyFocus() {
|
|
499
|
+
for (const [taskId, group] of lots) {
|
|
500
|
+
const on = !focusIds || focusIds.has(taskId);
|
|
501
|
+
group.traverse((child) => {
|
|
502
|
+
if (!child.isMesh && !child.isLineSegments) return;
|
|
503
|
+
for (const mat of Array.isArray(child.material) ? child.material : [child.material]) {
|
|
504
|
+
mat.userData.focusBase ??= { transparent: mat.transparent, opacity: mat.opacity };
|
|
505
|
+
mat.transparent = on ? mat.userData.focusBase.transparent : true;
|
|
506
|
+
mat.opacity = on ? mat.userData.focusBase.opacity : 0.15;
|
|
507
|
+
mat.needsUpdate = true;
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const projected = new THREE.Vector3();
|
|
514
|
+
function placeLabels() {
|
|
515
|
+
const rect = stage.getBoundingClientRect();
|
|
516
|
+
const placed = [];
|
|
517
|
+
const visible = [];
|
|
518
|
+
for (const label of labels) {
|
|
519
|
+
const owner = label.owner;
|
|
520
|
+
const taskId = owner?.userData?.site?.task_id;
|
|
521
|
+
const emphasised = owner && (owner === selected || owner === hovered || (focusIds && focusIds.has(taskId)));
|
|
522
|
+
const hiddenByFocus = owner && focusIds && !focusIds.has(taskId);
|
|
523
|
+
const distance = camera.position.distanceTo(label.position);
|
|
524
|
+
projected.copy(label.position).project(camera);
|
|
525
|
+
const onScreen = projected.z < 1 && Math.abs(projected.x) < 1.05 && Math.abs(projected.y) < 1.05;
|
|
526
|
+
if (!onScreen || hiddenByFocus || (!emphasised && distance > label.near)) {
|
|
527
|
+
label.node.hidden = true;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
label.screen = { x: ((projected.x + 1) / 2) * rect.width, y: ((1 - projected.y) / 2) * rect.height };
|
|
531
|
+
label.rank = emphasised ? 0 : label.priority;
|
|
532
|
+
visible.push(label);
|
|
533
|
+
}
|
|
534
|
+
visible.sort((a, b) => a.rank - b.rank);
|
|
535
|
+
for (const label of visible) {
|
|
536
|
+
label.node.hidden = false;
|
|
537
|
+
label.size ??= { w: label.node.offsetWidth, h: label.node.offsetHeight };
|
|
538
|
+
const box2 = { x: label.screen.x - label.size.w / 2, y: label.screen.y - label.size.h, w: label.size.w, h: label.size.h };
|
|
539
|
+
if (label.rank > 0 && placed.some((other) => box2.x < other.x + other.w + 2 && box2.x + box2.w + 2 > other.x && box2.y < other.y + other.h + 2 && box2.y + box2.h + 2 > other.y)) {
|
|
540
|
+
label.node.hidden = true;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
placed.push(box2);
|
|
544
|
+
label.node.style.transform = `translate(${Math.round(box2.x)}px, ${Math.round(box2.y)}px)`;
|
|
545
|
+
label.node.classList.toggle('is-selected', label.rank === 0);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
let running = true;
|
|
550
|
+
function frame(time) {
|
|
551
|
+
if (!running) return;
|
|
552
|
+
requestAnimationFrame(frame);
|
|
553
|
+
if (!open) return;
|
|
554
|
+
if (flight) {
|
|
555
|
+
flight.started ??= time;
|
|
556
|
+
const t = Math.min((time - flight.started) / flight.duration, 1);
|
|
557
|
+
const e = 1 - (1 - t) ** 3;
|
|
558
|
+
controls.target.lerpVectors(flight.from.target, flight.to.target, e);
|
|
559
|
+
camera.position.lerpVectors(flight.from.position, flight.to.position, e);
|
|
560
|
+
camera.lookAt(controls.target);
|
|
561
|
+
if (t >= 1) {
|
|
562
|
+
flight = null;
|
|
563
|
+
controls.enabled = true;
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
controls.update();
|
|
567
|
+
}
|
|
568
|
+
if (!reducedMotion) {
|
|
569
|
+
const seconds = time / 1000;
|
|
570
|
+
for (const walker of walkers) {
|
|
571
|
+
const { x, from, to, speed, phase } = walker.userData;
|
|
572
|
+
const span = to - from;
|
|
573
|
+
const travel = ((seconds * speed) / span + phase) % 2;
|
|
574
|
+
const forward = travel < 1;
|
|
575
|
+
walker.position.set(x, 0, forward ? from + travel * span : to - (travel - 1) * span);
|
|
576
|
+
walker.rotation.y = forward ? 0 : Math.PI;
|
|
577
|
+
walker.position.y = Math.abs(Math.sin(seconds * 8 * speed)) * 0.04;
|
|
578
|
+
}
|
|
579
|
+
for (const puff of puffs) {
|
|
580
|
+
const cycle = (seconds * 0.25 + puff.userData.phase) % 1;
|
|
581
|
+
puff.position.set(puff.userData.base.x + cycle * 0.5, puff.userData.base.y + cycle * 2.2, puff.userData.base.z);
|
|
582
|
+
puff.scale.setScalar(0.6 + cycle * 1.4);
|
|
583
|
+
puff.material.opacity = 0.5 * (1 - cycle);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (selectionRing.visible) selectionRing.material.opacity = 0.6 + 0.3 * Math.sin(time / 300);
|
|
587
|
+
renderer.render(scene, camera);
|
|
588
|
+
placeLabels();
|
|
589
|
+
}
|
|
590
|
+
requestAnimationFrame(frame);
|
|
591
|
+
|
|
592
|
+
function setVisible(visible) {
|
|
593
|
+
open = visible;
|
|
594
|
+
renderer.domElement.hidden = !visible;
|
|
595
|
+
labelsLayer.hidden = !visible;
|
|
596
|
+
legend.hidden = !visible;
|
|
597
|
+
if (!visible) notice.hidden = true;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
/** Enters the district of a business; `neighbours` maps other business codes to their names. */
|
|
602
|
+
show(business, work, { neighbours = new Map() } = {}) {
|
|
603
|
+
build(business, work, neighbours);
|
|
604
|
+
setVisible(true);
|
|
605
|
+
resize();
|
|
606
|
+
// Entering: from high over the gate down to the street.
|
|
607
|
+
camera.position.set(home.position.x * 2.2, home.position.y * 2.4, home.position.z + 40);
|
|
608
|
+
controls.target.copy(home.target);
|
|
609
|
+
camera.lookAt(home.target);
|
|
610
|
+
fly(home.target.clone(), home.position.clone(), 1300);
|
|
611
|
+
renderer.domElement.focus({ preventScroll: true });
|
|
612
|
+
},
|
|
613
|
+
hide() {
|
|
614
|
+
setVisible(false);
|
|
615
|
+
},
|
|
616
|
+
isOpen: () => open,
|
|
617
|
+
/** Back to the view from the gate, keeping the district. */
|
|
618
|
+
home() {
|
|
619
|
+
select(null);
|
|
620
|
+
if (home) fly(home.target.clone(), home.position.clone());
|
|
621
|
+
},
|
|
622
|
+
select(taskId) {
|
|
623
|
+
const group = lots.get(taskId);
|
|
624
|
+
if (!group) return false;
|
|
625
|
+
select(group);
|
|
626
|
+
return true;
|
|
627
|
+
},
|
|
628
|
+
clearSelection() {
|
|
629
|
+
select(null);
|
|
630
|
+
},
|
|
631
|
+
focus(taskIds) {
|
|
632
|
+
focusIds = taskIds ? new Set(taskIds) : null;
|
|
633
|
+
applyFocus();
|
|
634
|
+
},
|
|
635
|
+
dispose() {
|
|
636
|
+
running = false;
|
|
637
|
+
observer?.disconnect();
|
|
638
|
+
controls.dispose();
|
|
639
|
+
renderer.dispose();
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
}
|