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.