PageSourceSearch

https://tehvandi.ee/wp-content/themes/tehvandi_theme2/code4/map-hotspots.js

js tehvandi.ee collected 2026-10-02 07:25:04 UTC 14,077 bytes, 436 lines download raw bytes

1export function ensureScreenReaderHelp() {
2    let help = document.getElementById('map-hotspot-help');
3
4    if (!help) {
5        help = document.createElement('p');
6        help.id = 'map-hotspot-help';
7
8        help.style.position = 'fixed';
9        help.style.left = '-99999px';
10        help.style.top = '0';
11        help.style.width = '1px';
12        help.style.height = '1px';
13        help.style.overflow = 'hidden';
14
15        document.body.appendChild(help);
16    }
17
18    help.textContent =
19        'Use Tab to move through map locations. Press Enter or Space to open a location. Use left and right arrow keys to rotate the map while focused on a location or after clicking the map. Press Escape to close the open location.';
20}
21
22export function injectHotspotStyles() {
23    if (document.getElementById('map-hotspot-inline-styles')) return;
24
25    const style = document.createElement('style');
26    style.id = 'map-hotspot-inline-styles';
27    style.textContent = `
28        html,
29        body {
30            overflow-x: hidden !important;
31        }
32
33        #kolmdee {
34            position: relative !important;
35            overflow: hidden !important;
36            isolation: isolate;
37        }
38
39        #canvas {
40            position: absolute !important;
41            inset: 0 !important;
42            width: 100% !important;
43            height: 100% !important;
44            z-index: 1 !important;
45            display: block !important;
46        }
47
48        #map-hotspot-root {
49            position: absolute !important;
50            inset: 0 !important;
51            width: 100% !important;
52            height: 100% !important;
53            pointer-events: none !important;
54            z-index: 2 !important;
55            overflow: hidden !important;
56            contain: layout paint !important;
57        }
58    `;
59
60    document.head.appendChild(style);
61}
62
63export function ensureHotspotRoot() {
64    let root = document.getElementById('map-hotspot-root');
65    const mapContainer = document.getElementById('kolmdee');
66
67    if (!root) {
68        root = document.createElement('div');
69        root.id = 'map-hotspot-root';
70    }
71
72    if (mapContainer) {
73        mapContainer.style.position = 'relative';
74        mapContainer.style.overflow = 'hidden';
75
76        root.style.position = 'absolute';
77        root.style.left = '0';
78        root.style.top = '0';
79        root.style.width = '100%';
80        root.style.height = '100%';
81        root.style.pointerEvents = 'none';
82        root.style.zIndex = '2';
83        root.style.overflow = 'hidden';
84
85        if (root.parentNode !== mapContainer) {
86            mapContainer.appendChild(root);
87        }
88    } else if (!root.parentNode) {
89        root.style.position = 'fixed';
90        root.style.left = '0';
91        root.style.top = '0';
92        root.style.width = '100vw';
93        root.style.height = '100vh';
94        root.style.pointerEvents = 'none';
95        root.style.zIndex = '2';
96        root.style.overflow = 'hidden';
97        document.body.appendChild(root);
98    }
99
100    return root;
101}
102
103function getHotspotIndicatorClasses(data, meshName) {
104    const classes = [];
105    const sources = [data?.titleClass, data?.labelClass, data?.className];
106
107    for (const source of sources) {
108        if (typeof source !== 'string') continue;
109
110        const split = source.trim().split(/\s+/).filter(Boolean);
111        for (const cls of split) {
112            if (!classes.includes(cls)) {
113                classes.push(cls);
114            }
115        }
116    }
117
118    const meshDerivedClass = meshName ? `title_${meshName}` : '';
119    if (meshDerivedClass && !classes.includes(meshDerivedClass)) {
120        classes.push(meshDerivedClass);
121    }
122
123    return classes;
124}
125
126function focusElementWithoutScroll(element) {
127    if (!element) return;
128    try {
129        element.focus({ preventScroll: true });
130    } catch {
131        element.focus();
132    }
133}
134
135class MapHotspotManager {
136    constructor({ camera, scene, onActivate, onFocus, onArrowKeyChange, activateMapKeyboardMode, suppressFocusRef }) {
137        this.camera = camera;
138        this.scene = scene;
139        this.onActivate = onActivate;
140        this.onFocus = onFocus;
141        this.onArrowKeyChange = onArrowKeyChange;
142        this.activateMapKeyboardMode = activateMapKeyboardMode;
143        this.suppressFocusRef = suppressFocusRef;
144        this.items = [];
145    }
146
147    register({ id, meshName, name, object3D, anchorWorld, data, tabOrder }) {
148        const button = document.createElement('button');
149        button.type = 'button';
150
151        const indicatorClasses = getHotspotIndicatorClasses(data, meshName);
152
153        button.classList.add('title_silt');
154        if (indicatorClasses.length) {
155            button.classList.add(...indicatorClasses);
156        }
157
158        button.textContent = name;
159        button.dataset.id = id;
160        button.dataset.mesh = meshName;
161        button.tabIndex = 0;
162        button.setAttribute('aria-label', name);
163        button.setAttribute('aria-describedby', 'map-hotspot-help');
164
165        if (typeof tabOrder === 'number') {
166            button.dataset.tabOrder = String(tabOrder);
167        }
168
169        button.addEventListener('pointerdown', (event) => {
170            if (this.suppressFocusRef) {
171                this.suppressFocusRef.value = true;
172            }
173            if (typeof this.activateMapKeyboardMode === 'function') {
174                this.activateMapKeyboardMode();
175            }
176            event.stopPropagation();
177        });
178
179        button.addEventListener('mousedown', () => {
180            if (this.suppressFocusRef) {
181                this.suppressFocusRef.value = true;
182            }
183        });
184
185        button.addEventListener('focus', () => {
186            if (typeof this.activateMapKeyboardMode === 'function') {
187                this.activateMapKeyboardMode();
188            }
189
190            if (this.suppressFocusRef?.value) {
191                this.suppressFocusRef.value = false;
192                return;
193            }
194
195            if (typeof this.onFocus === 'function') {
196                this.onFocus(data);
197            }
198        });
199
200        button.addEventListener('blur', () => {
201            if (typeof this.onArrowKeyChange === 'function') {
202                this.onArrowKeyChange({ left: false, right: false });
203            }
204        });
205
206        button.addEventListener('click', (event) => {
207            event.preventDefault();
208            event.stopPropagation();
209
210            if (typeof this.activateMapKeyboardMode === 'function') {
211                this.activateMapKeyboardMode();
212            }
213
214            if (typeof this.onActivate === 'function') {
215                this.onActivate(data);
216            }
217        });
218
219        button.addEventListener('keydown', (event) => {
220            if (event.key === 'Tab') {
221                const sortedButtons = this.getTabbableButtons();
222                const currentIndex = sortedButtons.indexOf(button);
223
224                if (currentIndex !== -1 && sortedButtons.length > 0) {
225                    let nextIndex = -1;
226
227                    if (event.shiftKey) {
228                        if (currentIndex > 0) {
229                            nextIndex = currentIndex - 1;
230                        }
231                    } else {
232                        if (currentIndex < sortedButtons.length - 1) {
233                            nextIndex = currentIndex + 1;
234                        }
235                    }
236
237                    if (nextIndex !== -1) {
238                        event.preventDefault();
239                        const nextButton = sortedButtons[nextIndex];
240                        if (typeof this.activateMapKeyboardMode === 'function') {
241                            this.activateMapKeyboardMode();
242                        }
243                        focusElementWithoutScroll(nextButton);
244                    }
245                }
246                return;
247            }
248
249            if (event.key === 'ArrowLeft') {
250                event.preventDefault();
251                if (typeof this.activateMapKeyboardMode === 'function') {
252                    this.activateMapKeyboardMode();
253                }
254                if (typeof this.onArrowKeyChange === 'function') {
255                    this.onArrowKeyChange({ left: true, right: false });
256                }
257                return;
258            }
259
260            if (event.key === 'ArrowRight') {
261                event.preventDefault();
262                if (typeof this.activateMapKeyboardMode === 'function') {
263                    this.activateMapKeyboardMode();
264                }
265                if (typeof this.onArrowKeyChange === 'function') {
266                    this.onArrowKeyChange({ left: false, right: true });
267                }
268                return;
269            }
270
271            if (event.key === 'Enter' || event.key === ' ') {
272                event.preventDefault();
273                event.stopPropagation();
274
275                if (typeof this.activateMapKeyboardMode === 'function') {
276                    this.activateMapKeyboardMode();
277                }
278
279                if (typeof this.onActivate === 'function') {
280                    this.onActivate(data);
281                }
282            }
283        });
284
285        button.addEventListener('keyup', (event) => {
286            if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
287                event.preventDefault();
288                if (typeof this.onArrowKeyChange === 'function') {
289                    this.onArrowKeyChange({ left: false, right: false });
290                }
291            }
292        });
293
294        const root = document.getElementById('map-hotspot-root');
295        if (root) {
296            root.appendChild(button);
297        }
298
299        this.items.push({
300            id,
301            meshName,
302            name,
303            object3D,
304            anchorWorld,
305            button,
306            data,
307            visible: true,
308            tabOrder,
309        });
310    }
311
312    getTabbableButtons() {
313        return this.items
314            .filter((item) => !item.button.hidden && item.button.style.visibility !== 'hidden')
315            .sort((a, b) => {
316                const aOrder = typeof a.tabOrder === 'number' ? a.tabOrder : 9999;
317                const bOrder = typeof b.tabOrder === 'number' ? b.tabOrder : 9999;
318                return aOrder - bOrder;
319            })
320            .map((item) => item.button);
321    }
322
323    update() {
324        const container = document.getElementById('kolmdee');
325        if (!container) return;
326
327        const rect = container.getBoundingClientRect();
328        const width = rect.width || window.innerWidth;
329        const height = rect.height || window.innerHeight;
330
331        this.camera.updateMatrixWorld(true);
332        this.camera.updateProjectionMatrix();
333        this.scene.updateMatrixWorld(true);
334
335        for (const item of this.items) {
336            const projected = item.anchorWorld.clone().project(this.camera);
337            const x = (projected.x * 0.5 + 0.5) * width;
338            const y = (-projected.y * 0.5 + 0.5) * height;
339
340            if (item.button.hidden) continue;
341
342            item.button.style.transform = `translate3d(${x}px, ${y}px, 0) translate(-50%, -100%)`;
343        }
344    }
345
346    updateAnchorHeight(meshName, newY) {
347        const item = this.items.find((entry) => entry.meshName === meshName);
348        if (item) {
349            item.anchorWorld.y = newY;
350        }
351    }
352
353    setOnlyOneVisible(meshName) {
354        for (const item of this.items) {
355            if (item.meshName === meshName) {
356                item.button.style.opacity = '1';
357                item.button.style.visibility = 'visible';
358                item.button.style.pointerEvents = 'auto';
359                item.button.hidden = false;
360                item.button.setAttribute('aria-hidden', 'false');
361                item.button.tabIndex = 0;
362            } else {
363                item.button.style.opacity = '0';
364                item.button.style.visibility = 'hidden';
365                item.button.style.pointerEvents = 'none';
366                item.button.hidden = true;
367                item.button.setAttribute('aria-hidden', 'true');
368                item.button.tabIndex = -1;
369            }
370        }
371    }
372
373    clearSelected() {
374        for (const item of this.items) {
375            item.button.style.opacity = '1';
376            item.button.style.visibility = 'visible';
377            item.button.style.pointerEvents = 'auto';
378            item.button.hidden = false;
379            item.button.setAttribute('aria-hidden', 'false');
380            item.button.tabIndex = 0;
381        }
382    }
383}
384
385export function createHotspotManager(config) {
386    return new MapHotspotManager(config);
387}
388
389export function buildAccessibleHotspots({ model, scene, mapNames, hotspotManager }) {
390    const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });
391
392    model.updateWorldMatrix(true, true);
393
394    const meshMap = new Map();
395
396    model.traverse((obj) => {
397        if (!obj.isMesh) return;
398        meshMap.set(obj.name, obj);
399    });
400
401    const sortedEntries = [...mapNames].sort((a, b) => {
402        const aOrder = typeof a.tabOrder === 'number' ? a.tabOrder : 9999;
403        const bOrder = typeof b.tabOrder === 'number' ? b.tabOrder : 9999;
404        return aOrder - bOrder;
405    });
406
407    sortedEntries.forEach((entry) => {
408        const obj = meshMap.get(entry.mesh);
409        if (!obj) return;
410
411        const worldPos = new THREE.Vector3();
412        obj.getWorldPosition(worldPos);
413
414        const anchorWorld = new THREE.Vector3(worldPos.x, entry.height, worldPos.z);
415
416        const points = [
417            new THREE.Vector3(worldPos.x, 0, worldPos.z),
418            new THREE.Vector3(worldPos.x, entry.height, worldPos.z),
419        ];
420
421        const geometry = new THREE.BufferGeometry().setFromPoints(points);
422        const line = new THREE.Line(geometry, lineMaterial);
423        line.name = 'line' + obj.name;
424        scene.add(line);
425
426        hotspotManager.register({
427            id: entry.mesh,
428            meshName: entry.mesh,
429            name: entry.name,
430            object3D: obj,
431            anchorWorld,
432            data: entry,
433            tabOrder: entry.tabOrder,
434        });
435    });
436}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.