import { locationMiniStatus } from '../locationStatus.js'; import { t } from '../i18n/index.js'; const POI_KEYS = ['Q', 'W', 'E', 'R', 'T']; /** * locationStatus.js sentinel outputs used as localized-rewrite triggers for * the collapsed LOCATION mini-status. Built by calling the module with no * real data, so the comparisons track that module's copy without restating * English strings here. Resolved destinations (place names) stay verbatim. */ const LOCATION_MINI_EMPTY = Object.freeze(locationMiniStatus()); const LOCATION_MINI_SEARCHED_PLACEHOLDER = Object.freeze( locationMiniStatus({ searchedLabel: '·' }), ); /** Location DOM, keyboard handling and pending row animation over supplied actions. */ export class LocationControls { constructor({ elements, cities, getExpandedCity, onCity, onPoi, onSearch, onReset, doc = document, requestFrame = (callback) => requestAnimationFrame(callback), cancelFrame = (id) => cancelAnimationFrame(id), }) { Object.assign(this, { elements, cities, getExpandedCity, onCity, onPoi, onSearch, onReset, doc, requestFrame, cancelFrame, }); this.removers = []; this.poiRemovers = []; this.frame = null; this.destroyed = false; this.rowGeneration = 0; elements.pills.replaceChildren(); for (const [id, city] of Object.entries(cities)) { const pill = doc.createElement('button'); pill.type = 'button'; pill.className = 'location-pill'; pill.dataset.locationId = id; pill.textContent = city.name; this.bind(pill, 'click', () => onCity(id)); elements.pills.appendChild(pill); } this.bind(doc, 'keydown', (event) => { const cityId = getExpandedCity(); if ( !cityId || event.target?.matches?.('select, input, textarea') || event.target === elements.search ) return; const index = POI_KEYS.indexOf(event.key.toUpperCase()); if (index !== -1 && index < (cities[cityId]?.pois.length || 0)) onPoi(cityId, index); }); this.bind(elements.searchToggle, 'click', () => { elements.search.classList.toggle('expanded'); if (elements.search.classList.contains('expanded')) elements.search.focus(); }); this.bind(elements.search, 'keydown', (event) => { if (event.key === 'Enter') void onSearch(elements.search.value); }); for (const button of elements.resetButtons) this.bind(button, 'click', onReset); } bind(element, event, handler, removers = this.removers) { if (!element) return; const listener = (...args) => { if (!this.destroyed) return handler(...args); }; element.addEventListener(event, listener); removers.push(() => element.removeEventListener(event, listener)); } cancelExpansion() { this.rowGeneration++; if (this.frame !== null) this.cancelFrame(this.frame); this.frame = null; } showPois(cityId) { if (this.destroyed) return; const city = this.cities[cityId]; if (!city) return; this.cancelExpansion(); const generation = this.rowGeneration; for (const remove of this.poiRemovers.splice(0)) remove(); this.elements.poiRow.replaceChildren(); city.pois.forEach((poi, index) => { const pill = this.doc.createElement('button'); pill.type = 'button'; pill.className = 'poi-pill'; pill.dataset.poiIndex = index; const key = this.doc.createElement('span'); key.className = 'poi-pill-key'; key.textContent = POI_KEYS[index] || index + 1; const label = this.doc.createElement('span'); label.className = 'poi-pill-name'; label.textContent = poi.name; pill.append(key, label); this.bind( pill, 'click', () => this.onPoi(cityId, index), this.poiRemovers, ); this.elements.poiRow.appendChild(pill); }); this.frame = this.requestFrame(() => { if (this.destroyed || generation !== this.rowGeneration) return; this.frame = null; this.elements.poiRow.classList.add('expanded'); this.elements.divider.classList.add('visible'); }); } hidePois() { if (this.destroyed) return; this.cancelExpansion(); this.elements.poiRow.classList.remove('expanded'); this.elements.divider.classList.remove('visible'); } highlightPoi(index) { if (this.destroyed) return; for (const pill of this.elements.poiRow.querySelectorAll('.poi-pill')) pill.classList.toggle('active', Number(pill.dataset.poiIndex) === index); } highlightCity(id) { if (this.destroyed) return; for (const pill of this.elements.pills.querySelectorAll('.location-pill')) pill.classList.toggle('active', pill.dataset.locationId === id); } renderStatus(state) { if (this.destroyed || !this.elements.statusCity || !this.elements.statusPoi) return; const lines = locationMiniStatus(state); // The initial/placeholder states get catalog keys at the writer so a // non-English boot never leaks the English sentinels back over the // localized static readout (resolved place names stay verbatim data). this.elements.statusCity.textContent = lines.city === LOCATION_MINI_EMPTY.city ? t('📍 Location: --') : lines.city; if (lines.poi === LOCATION_MINI_EMPTY.poi) { this.elements.statusPoi.textContent = t('Landmark: --'); } else if (lines.poi === LOCATION_MINI_SEARCHED_PLACEHOLDER.poi) { this.elements.statusPoi.textContent = t('Searched location'); } else { this.elements.statusPoi.textContent = lines.poi; } } createOrbitIndicator() { if (this.destroyed) return null; if (!this.orbitIndicator) { this.orbitIndicator = this.doc.createElement('div'); this.orbitIndicator.id = 'orbit-indicator'; const icon = this.doc.createElement('span'); icon.className = 'orbit-icon'; icon.textContent = '↻'; this.orbitIndicator.append(icon, ` ${t('ORBIT')}`); this.doc.body.appendChild(this.orbitIndicator); } return this.orbitIndicator; } destroy() { if (this.destroyed) return; this.destroyed = true; this.cancelExpansion(); for (const remove of [ ...this.poiRemovers.splice(0), ...this.removers.splice(0), ]) remove(); this.orbitIndicator?.remove(); } }