| |
| |
| |
| |
| |
| |
| |
|
|
| import { CountryPicker } from './CountryPicker'; |
| import { Hs2Picker } from './Hs2Picker'; |
| import { CargoTypeDropdown } from './CargoTypeDropdown'; |
| import { KeyboardHelp } from './KeyboardHelp'; |
| import { LeftRail } from './components/LeftRail'; |
| import { CurrentRouteTab } from './tabs/CurrentRouteTab'; |
| import { AlternativesTab } from './tabs/AlternativesTab'; |
| import { LandTab } from './tabs/LandTab'; |
| import { CountryImpactTab } from './tabs/CountryImpactTab'; |
| import { inferCargoFromHs2, type ExplorerCargo } from './RouteExplorer.utils'; |
| import COUNTRY_PORT_CLUSTERS from '../../../scripts/shared/country-port-clusters.json'; |
| import { |
| parseExplorerUrl, |
| serializeExplorerUrl, |
| writeExplorerUrl, |
| DEFAULT_EXPLORER_STATE, |
| type ExplorerUrlState, |
| type ExplorerTab, |
| } from './url-state'; |
| import type { GetRouteExplorerLaneResponse, GetRouteImpactResponse, BypassCorridorOption } from '@/generated/server/worldmonitor/supply_chain/v1/service_server'; |
| import { fetchRouteExplorerLane, fetchRouteImpact } from '@/services/supply-chain'; |
| import { hasPremiumAccess } from '@/services/panel-gating'; |
| import { getAuthState } from '@/services/auth-state'; |
| import { trackGateHit, track, type UmamiEvent } from '@/services/analytics'; |
|
|
| import { TRADE_ROUTES } from '@/config/trade-routes'; |
| import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; |
|
|
|
|
| const TAB_LABELS: Record<ExplorerTab, string> = { 1: 'Current', 2: 'Alternatives', 3: 'Land', 4: 'Impact' }; |
| const FETCH_DEBOUNCE_MS = 250; |
|
|
| const CARGO_TO_ROUTE_CATEGORY: Record<string, string> = { |
| container: 'container', |
| tanker: 'energy', |
| bulk: 'bulk', |
| roro: 'container', |
| }; |
|
|
| const ROUTE_CATEGORY_MAP = new Map(TRADE_ROUTES.map((r) => [r.id, r.category])); |
|
|
| interface MapRef { |
| highlightRoute(routeIds: string[]): void; |
| clearHighlightedRoute(): void; |
| setBypassRoutes(corridors: Array<{ fromPort: [number, number]; toPort: [number, number] }>): void; |
| clearBypassRoutes(): void; |
| zoomToRoutes(routeIds: string[]): void; |
| } |
|
|
| interface TestHook { |
| lastHighlightedRouteIds?: string[]; |
| lastBypassRoutes?: Array<{ fromPort: [number, number]; toPort: [number, number] }>; |
| lastClearHighlight?: number; |
| lastClearBypass?: number; |
| } |
|
|
| declare global { |
| interface Window { |
| __routeExplorerTestHook?: TestHook; |
| } |
| } |
|
|
| export class RouteExplorer { |
| private root: HTMLDivElement | null = null; |
| private state: ExplorerUrlState; |
| private fromPicker!: CountryPicker; |
| private toPicker!: CountryPicker; |
| private hs2Picker!: Hs2Picker; |
| private cargoDropdown!: CargoTypeDropdown; |
| private tabStrip!: HTMLDivElement; |
| private contentEl!: HTMLDivElement; |
| private leftRail!: LeftRail; |
| private currentTab!: CurrentRouteTab; |
| private alternativesTab!: AlternativesTab; |
| private landTab!: LandTab; |
| private impactTab!: CountryImpactTab; |
| public impactData: GetRouteImpactResponse | null = null; |
| private cargoManual = false; |
| private isOpen = false; |
| private previousFocus: HTMLElement | null = null; |
| private helpOverlay: KeyboardHelp | null = null; |
| private mapRef: MapRef | null = null; |
| private generationId = 0; |
| private debounceTimer: ReturnType<typeof setTimeout> | null = null; |
| private laneData: GetRouteExplorerLaneResponse | null = null; |
| public isLoading = false; |
| private displayMode: 'idle' | 'loading' | 'data' | 'error' | 'gate' = 'idle'; |
| private openedAt = 0; |
| private queryCount = 0; |
| private gateHitTracked = false; |
|
|
| constructor() { |
| this.state = { ...DEFAULT_EXPLORER_STATE }; |
| this.installTestHook(); |
| } |
|
|
| public setMap(map: MapRef | null): void { |
| this.mapRef = map; |
| } |
|
|
| public open(source: 'cmdk' | 'url' | 'icon' = 'cmdk'): void { |
| if (this.isOpen) { |
| this.fromPicker?.focusInput(); |
| return; |
| } |
| this.state = this.readInitialState(); |
| this.laneData = null; |
| this.displayMode = 'idle'; |
| this.previousFocus = (document.activeElement as HTMLElement) ?? null; |
| this.root = this.buildRoot(); |
| const mapSection = document.getElementById('mapSection'); |
| if (mapSection) { |
| mapSection.insertAdjacentElement('beforebegin', this.root); |
| } else { |
| document.body.append(this.root); |
| } |
| this.isOpen = true; |
| this.openedAt = Date.now(); |
| this.queryCount = 0; |
| if (!this.gateHitTracked && this.tier === 'free') { |
| trackGateHit('route-explorer'); |
| this.gateHitTracked = true; |
| } |
| this.trackEvent('route-explorer:opened', { source }); |
| document.addEventListener('keydown', this.handleGlobalKeydown, { capture: true }); |
| this.focusInitial(); |
| if (this.isQueryComplete()) this.scheduleFetch(); |
| } |
|
|
| public close(): void { |
| if (!this.isOpen || !this.root) return; |
| this.generationId++; |
| if (this.debounceTimer) { clearTimeout(this.debounceTimer); this.debounceTimer = null; } |
| document.removeEventListener('keydown', this.handleGlobalKeydown, { capture: true }); |
| this.helpOverlay?.element.remove(); |
| this.helpOverlay = null; |
| this.trackEvent('route-explorer:closed', { |
| durationSec: Math.round((Date.now() - this.openedAt) / 1000), |
| queryCount: this.queryCount, |
| }); |
| this.clearMapState(); |
| this.root.remove(); |
| this.root = null; |
| this.isOpen = false; |
| this.laneData = null; |
| if (this.previousFocus && document.body.contains(this.previousFocus)) { |
| this.previousFocus.focus(); |
| } |
| this.previousFocus = null; |
| } |
|
|
| public isOpenNow(): boolean { |
| return this.isOpen; |
| } |
|
|
| |
|
|
| private readInitialState(): ExplorerUrlState { |
| if (typeof window === 'undefined') return { ...DEFAULT_EXPLORER_STATE }; |
| return parseExplorerUrl(window.location.search); |
| } |
|
|
| private writeStateToUrl(): void { |
| writeExplorerUrl(this.state); |
| } |
|
|
| private isQueryComplete(): boolean { |
| return Boolean(this.state.fromIso2 && this.state.toIso2 && this.state.hs2); |
| } |
|
|
| private getEffectiveCargo(): string { |
| return this.state.cargo ?? inferCargoFromHs2(this.state.hs2); |
| } |
|
|
| |
|
|
| private scheduleFetch(): void { |
| if (this.debounceTimer) clearTimeout(this.debounceTimer); |
| this.debounceTimer = setTimeout(() => { |
| this.debounceTimer = null; |
| this.queryCount++; |
| this.trackEvent('route-explorer:query', { |
| from: this.state.fromIso2 ?? '', |
| to: this.state.toIso2 ?? '', |
| hs2: this.state.hs2 ?? '', |
| cargo: this.getEffectiveCargo(), |
| }); |
| void this.fetchLane(); |
| }, FETCH_DEBOUNCE_MS); |
| } |
|
|
| private resetLaneState(mode?: 'loading' | 'error' | 'gate'): void { |
| this.laneData = null; |
| this.impactData = null; |
| this.clearMapState(); |
| this.leftRail?.updateLane(null, mode); |
| this.leftRail?.updateResilience(null); |
| this.currentTab?.update(null); |
| this.alternativesTab?.update(null); |
| this.landTab?.update(null); |
| this.impactTab?.update(null); |
| } |
|
|
| private async fetchLane(): Promise<void> { |
| if (!this.isQueryComplete()) return; |
| if (!hasPremiumAccess(getAuthState())) { |
| this.generationId++; |
| this.displayMode = 'gate'; |
| this.resetLaneState('gate'); |
| this.renderFreeGate(); |
| this.applyPublicRouteHighlight(); |
| return; |
| } |
|
|
| const gen = ++this.generationId; |
| this.displayMode = 'loading'; |
| this.resetLaneState('loading'); |
| this.isLoading = true; |
| this.showLoading(); |
|
|
| try { |
| const data = await fetchRouteExplorerLane({ |
| fromIso2: this.state.fromIso2!, |
| toIso2: this.state.toIso2!, |
| hs2: this.state.hs2!, |
| cargoType: this.getEffectiveCargo(), |
| }); |
| if (gen !== this.generationId) return; |
| this.laneData = data; |
| this.displayMode = 'data'; |
| this.applyData(data); |
| if (!data.noModeledLane) { |
| this.applyMapState(data); |
| } |
| void this.fetchResilience(data.toIso2); |
| void this.fetchImpact(data.fromIso2, data.toIso2, data.hs2); |
| } catch { |
| if (gen !== this.generationId) return; |
| this.displayMode = 'error'; |
| this.resetLaneState('error'); |
| this.showError(); |
| } finally { |
| if (gen === this.generationId) this.isLoading = false; |
| } |
| } |
|
|
| private async fetchResilience(iso2: string): Promise<void> { |
| const gen = this.generationId; |
| try { |
| const { getResilienceScore } = await import('@/services/resilience'); |
| const res = await getResilienceScore(iso2); |
| if (!this.isOpen || gen !== this.generationId) return; |
| this.leftRail.updateResilience(res); |
| this.impactTab.updateResilience(res); |
| } catch { |
| if (gen !== this.generationId) return; |
| this.leftRail.updateResilience(null); |
| this.impactTab.updateResilience(null); |
| } |
| } |
|
|
| private async fetchImpact(fromIso2: string, toIso2: string, hs2: string): Promise<void> { |
| const gen = this.generationId; |
| try { |
| const data = await fetchRouteImpact({ fromIso2, toIso2, hs2 }); |
| if (!this.isOpen || gen !== this.generationId) return; |
| this.impactData = data; |
| this.impactTab.update(data); |
| this.leftRail.updateDependencyFlags(data.dependencyFlags); |
| if (data.comtradeSource === 'bilateral-hs4') { |
| this.trackEvent('route-explorer:impact-viewed', { toIso2, hs2 }); |
| } |
| if (this.state.tab === 4) this.showActiveTab(); |
| } catch { |
| if (gen !== this.generationId) return; |
| this.impactTab.update(null); |
| } |
| } |
|
|
| |
|
|
| private applyMapState(data: GetRouteExplorerLaneResponse): void { |
| if (!this.mapRef || data.noModeledLane || !data.primaryRouteId) return; |
| this.mapRef.zoomToRoutes([data.primaryRouteId]); |
| this.mapRef.highlightRoute([data.primaryRouteId]); |
| if (typeof window !== 'undefined' && window.__routeExplorerTestHook) { |
| window.__routeExplorerTestHook.lastHighlightedRouteIds = [data.primaryRouteId]; |
| } |
| } |
|
|
| private handleBypassSelect(option: BypassCorridorOption): void { |
| if (!this.mapRef || !option.fromPort || !option.toPort) return; |
| this.trackEvent('route-explorer:alternative-selected', { corridorId: option.id }); |
| const corridors = [{ fromPort: [option.fromPort.lon, option.fromPort.lat] as [number, number], toPort: [option.toPort.lon, option.toPort.lat] as [number, number] }]; |
| this.mapRef.setBypassRoutes(corridors); |
| if (typeof window !== 'undefined' && window.__routeExplorerTestHook) { |
| window.__routeExplorerTestHook.lastBypassRoutes = corridors; |
| } |
| } |
|
|
| private clearMapState(): void { |
| if (!this.mapRef) return; |
| this.mapRef.clearHighlightedRoute(); |
| this.mapRef.clearBypassRoutes(); |
| if (typeof window !== 'undefined' && window.__routeExplorerTestHook) { |
| window.__routeExplorerTestHook.lastClearHighlight = Date.now(); |
| window.__routeExplorerTestHook.lastClearBypass = Date.now(); |
| } |
| } |
|
|
| |
|
|
| private applyData(data: GetRouteExplorerLaneResponse): void { |
| this.leftRail.element.classList.remove('re-leftrail--blurred'); |
| this.leftRail.element.removeAttribute('aria-hidden'); |
| this.leftRail.updateLane(data); |
| this.currentTab.update(data); |
| this.alternativesTab.update(data); |
| this.landTab.update(data); |
| this.showActiveTab(); |
| } |
|
|
| private showLoading(): void { |
| if (this.contentEl) { |
| setTrustedHtml(this.contentEl, trustedHtml('<div class="re-content__loading">Loading lane data\u2026</div>', "legacy direct innerHTML migration")); |
| } |
| } |
|
|
| private showError(): void { |
| if (this.contentEl) { |
| setTrustedHtml(this.contentEl, trustedHtml('<div class="re-content__error">Failed to load lane data. Try again.</div>', "legacy direct innerHTML migration")); |
| } |
| } |
|
|
| private renderFreeGate(): void { |
| this.leftRail?.element.classList.add('re-leftrail--blurred'); |
| this.leftRail?.element.setAttribute('aria-hidden', 'true'); |
| if (this.contentEl) { |
| setTrustedHtml(this.contentEl, trustedHtml('<div class="re-content__gate">' + |
| '<h3>Unlock route intelligence</h3>' + |
| '<ul><li>Current route with chokepoint risk</li><li>Ranked bypass alternatives</li><li>Overland corridor options</li></ul>' + |
| '<button class="re-content__upgrade" type="button">Upgrade to PRO</button>' + |
| '</div>', "legacy direct innerHTML migration")); |
| const btn = this.contentEl.querySelector<HTMLButtonElement>('.re-content__upgrade'); |
| btn?.addEventListener('click', () => { |
| this.trackEvent('route-explorer:free-cta-click', { |
| from: this.state.fromIso2 ?? '', |
| to: this.state.toIso2 ?? '', |
| hs2: this.state.hs2 ?? '', |
| }); |
| void import('@/services/checkout') |
| .then((m) => m.startCheckout('pro_monthly')) |
| .catch(() => window.open('https://worldmonitor.app/pro', '_blank', 'noopener,noreferrer')); |
| }, { once: true }); |
| } |
| } |
|
|
| private applyPublicRouteHighlight(): void { |
| if (!this.mapRef || !this.state.fromIso2 || !this.state.toIso2) return; |
| const clusters = COUNTRY_PORT_CLUSTERS as unknown as Record<string, { nearestRouteIds: string[] }>; |
| const fromRoutes = new Set(clusters[this.state.fromIso2]?.nearestRouteIds ?? []); |
| const toRoutes = new Set(clusters[this.state.toIso2]?.nearestRouteIds ?? []); |
| const shared = [...fromRoutes].filter((r) => toRoutes.has(r)); |
| if (shared.length === 0) return; |
| const cargoCategory = CARGO_TO_ROUTE_CATEGORY[this.getEffectiveCargo()] ?? 'container'; |
| const ranked = [...shared].sort((a, b) => { |
| const catA = ROUTE_CATEGORY_MAP.get(a) ?? ''; |
| const catB = ROUTE_CATEGORY_MAP.get(b) ?? ''; |
| return (catA === cargoCategory ? 0 : 1) - (catB === cargoCategory ? 0 : 1); |
| }); |
| const routeId = ranked[0] ?? ''; |
| if (routeId) { |
| this.mapRef.highlightRoute([routeId]); |
| this.mapRef.zoomToRoutes([routeId]); |
| } |
| } |
|
|
| private showActiveTab(): void { |
| if (!this.contentEl) return; |
| if (this.displayMode === 'loading' || this.displayMode === 'error' || this.displayMode === 'gate') { |
| return; |
| } |
| setTrustedHtml(this.contentEl, trustedHtml('', "legacy direct innerHTML migration")); |
| switch (this.state.tab) { |
| case 1: this.contentEl.append(this.currentTab.element); break; |
| case 2: this.contentEl.append(this.alternativesTab.element); break; |
| case 3: this.contentEl.append(this.landTab.element); break; |
| case 4: this.contentEl.append(this.impactTab.element); break; |
| } |
| } |
|
|
| |
|
|
| private buildRoot(): HTMLDivElement { |
| const root = document.createElement('div'); |
| root.className = 're-modal'; |
| root.setAttribute('role', 'complementary'); |
| root.setAttribute('aria-label', 'Route Explorer \u2014 plan a shipment'); |
|
|
| const surface = document.createElement('div'); |
| surface.className = 're-modal__surface'; |
| surface.append(this.buildQueryBar(), this.buildTabStrip(), this.buildBody()); |
|
|
| root.append(surface); |
| return root; |
| } |
|
|
| private buildQueryBar(): HTMLDivElement { |
| const bar = document.createElement('div'); |
| bar.className = 're-querybar'; |
|
|
| const back = document.createElement('button'); |
| back.type = 'button'; |
| back.className = 're-querybar__back'; |
| back.textContent = '\u2190 Back'; |
| back.setAttribute('aria-label', 'Close Route Explorer'); |
| back.addEventListener('click', () => this.close()); |
|
|
| this.fromPicker = new CountryPicker({ |
| placeholder: 'From country', |
| initialIso2: this.state.fromIso2, |
| onCommit: (iso2) => this.handleFromCommit(iso2), |
| onCancel: () => this.blurActiveInput(), |
| }); |
|
|
| const arrow = document.createElement('span'); |
| arrow.className = 're-querybar__arrow'; |
| arrow.textContent = '\u2192'; |
| arrow.setAttribute('aria-hidden', 'true'); |
|
|
| this.toPicker = new CountryPicker({ |
| placeholder: 'To country', |
| initialIso2: this.state.toIso2, |
| onCommit: (iso2) => this.handleToCommit(iso2), |
| onCancel: () => this.blurActiveInput(), |
| }); |
|
|
| this.hs2Picker = new Hs2Picker({ |
| placeholder: 'Pick a product', |
| initialHs2: this.state.hs2, |
| onCommit: (hs2) => this.handleHs2Commit(hs2), |
| onCancel: () => this.blurActiveInput(), |
| }); |
|
|
| const initialCargo = this.state.cargo ?? inferCargoFromHs2(this.state.hs2); |
| this.cargoManual = this.state.cargo !== null; |
| this.cargoDropdown = new CargoTypeDropdown({ |
| initialCargo, |
| initialAutoInferred: !this.cargoManual, |
| onChange: (cargo, manual) => this.handleCargoChange(cargo, manual), |
| }); |
|
|
| bar.append(back, this.fromPicker.element, arrow, this.toPicker.element, this.hs2Picker.element, this.cargoDropdown.element); |
| return bar; |
| } |
|
|
| private buildTabStrip(): HTMLDivElement { |
| this.tabStrip = document.createElement('div'); |
| this.tabStrip.className = 're-tabstrip'; |
| this.tabStrip.setAttribute('role', 'tablist'); |
| for (const n of [1, 2, 3, 4] as const) { |
| const button = document.createElement('button'); |
| button.type = 'button'; |
| button.className = 're-tabstrip__tab'; |
| button.dataset.tab = String(n); |
| button.setAttribute('role', 'tab'); |
| button.setAttribute('aria-selected', n === this.state.tab ? 'true' : 'false'); |
| if (n === this.state.tab) button.classList.add('re-tabstrip__tab--active'); |
| setTrustedHtml(button, trustedHtml(`<span class="re-tabstrip__digit">${n}</span><span class="re-tabstrip__label">${TAB_LABELS[n]}</span>`, "legacy direct innerHTML migration")); |
| button.addEventListener('click', () => this.setTab(n)); |
| this.tabStrip.append(button); |
| } |
| return this.tabStrip; |
| } |
|
|
| private buildBody(): HTMLDivElement { |
| const body = document.createElement('div'); |
| body.className = 're-body'; |
|
|
| this.leftRail = new LeftRail(); |
| this.currentTab = new CurrentRouteTab(); |
| this.alternativesTab = new AlternativesTab({ |
| onSelectBypass: (o) => this.handleBypassSelect(o), |
| }); |
| this.landTab = new LandTab({ |
| onSelectBypass: (o) => this.handleBypassSelect(o), |
| }); |
| this.impactTab = new CountryImpactTab({ |
| onDrillSideways: (hs2) => this.handleDrillSideways(hs2), |
| }); |
|
|
| this.contentEl = document.createElement('div'); |
| this.contentEl.className = 're-content'; |
| this.showActiveTab(); |
|
|
| body.append(this.leftRail.element, this.contentEl); |
| return body; |
| } |
|
|
| |
|
|
| private handleFromCommit(iso2: string): void { |
| this.state = { ...this.state, fromIso2: iso2 }; |
| this.writeStateToUrl(); |
| this.fromPicker.setValue(iso2); |
| if (!this.state.toIso2) this.toPicker.focusInput(); |
| else if (!this.state.hs2) this.hs2Picker.focusInput(); |
| else this.scheduleFetch(); |
| } |
|
|
| private handleToCommit(iso2: string): void { |
| this.state = { ...this.state, toIso2: iso2 }; |
| this.writeStateToUrl(); |
| this.toPicker.setValue(iso2); |
| if (!this.state.fromIso2) this.fromPicker.focusInput(); |
| else if (!this.state.hs2) this.hs2Picker.focusInput(); |
| else this.scheduleFetch(); |
| } |
|
|
| private handleHs2Commit(hs2: string): void { |
| this.state = { ...this.state, hs2 }; |
| this.writeStateToUrl(); |
| this.hs2Picker.setValue(hs2); |
| if (!this.cargoManual) { |
| const inferred = inferCargoFromHs2(hs2); |
| this.cargoDropdown.setAutoInferred(inferred); |
| } |
| if (this.isQueryComplete()) this.scheduleFetch(); |
| } |
|
|
| private handleCargoChange(cargo: ExplorerCargo, manual: boolean): void { |
| this.cargoManual = manual; |
| this.state = { ...this.state, cargo }; |
| this.writeStateToUrl(); |
| if (this.isQueryComplete()) this.scheduleFetch(); |
| } |
|
|
| private setTab(n: ExplorerTab): void { |
| if (n === this.state.tab) return; |
| this.state = { ...this.state, tab: n }; |
| this.writeStateToUrl(); |
| this.trackEvent('route-explorer:tab-switch', { tab: n }); |
| if (this.tabStrip) { |
| const buttons = this.tabStrip.querySelectorAll<HTMLButtonElement>('.re-tabstrip__tab'); |
| buttons.forEach((b) => { |
| const isActive = Number.parseInt(b.dataset.tab ?? '0', 10) === n; |
| b.classList.toggle('re-tabstrip__tab--active', isActive); |
| b.setAttribute('aria-selected', isActive ? 'true' : 'false'); |
| }); |
| } |
| this.showActiveTab(); |
| } |
|
|
| private handleDrillSideways(hs2: string): void { |
| this.state = { ...this.state, hs2 }; |
| this.writeStateToUrl(); |
| this.hs2Picker.setValue(hs2); |
| if (!this.cargoManual) { |
| this.cargoDropdown.setAutoInferred(inferCargoFromHs2(hs2)); |
| } |
| this.setTab(1); |
| this.scheduleFetch(); |
| } |
|
|
| private swapFromTo(): void { |
| const newFrom = this.state.toIso2; |
| const newTo = this.state.fromIso2; |
| this.state = { ...this.state, fromIso2: newFrom, toIso2: newTo }; |
| this.writeStateToUrl(); |
| this.fromPicker.setValue(newFrom); |
| this.toPicker.setValue(newTo); |
| if (this.isQueryComplete()) this.scheduleFetch(); |
| } |
|
|
| |
|
|
| private isFormControlFocused(): boolean { |
| const el = document.activeElement; |
| if (!el) return false; |
| const tag = el.tagName; |
| if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; |
| if ((el as HTMLElement).isContentEditable) return true; |
| return false; |
| } |
|
|
| private blurActiveInput(): void { |
| (document.activeElement as HTMLElement | null)?.blur(); |
| } |
|
|
| private handleGlobalKeydown = (e: KeyboardEvent): void => { |
| if (!this.isOpen || !this.root) return; |
|
|
| if (e.key === 'Escape') { |
| if (this.helpOverlay) { |
| e.preventDefault(); e.stopPropagation(); |
| this.closeHelp(); |
| return; |
| } |
| if (this.isFormControlFocused()) return; |
| e.preventDefault(); e.stopPropagation(); |
| this.close(); |
| return; |
| } |
|
|
| if ((e.metaKey || e.ctrlKey) && e.key === ',') { |
| e.preventDefault(); |
| this.copyShareUrl(); |
| return; |
| } |
|
|
|
|
| if (this.isFormControlFocused()) return; |
| if (e.metaKey || e.ctrlKey || e.altKey) return; |
|
|
| switch (e.key) { |
| case '1': case '2': case '3': case '4': |
| e.preventDefault(); |
| this.setTab(Number.parseInt(e.key, 10) as ExplorerTab); |
| return; |
| case 'F': case 'f': e.preventDefault(); this.fromPicker.focusInput(); return; |
| case 'T': case 't': e.preventDefault(); this.toPicker.focusInput(); return; |
| case 'P': case 'p': e.preventDefault(); this.hs2Picker.focusInput(); return; |
| case 'S': case 's': e.preventDefault(); this.swapFromTo(); return; |
| case ' ': |
| e.preventDefault(); |
| if (this.laneData?.primaryRouteId && this.mapRef) { |
| this.mapRef.clearHighlightedRoute(); |
| this.mapRef.highlightRoute([this.laneData.primaryRouteId]); |
| } |
| return; |
| case '?': e.preventDefault(); this.openHelp(); return; |
| default: return; |
| } |
| }; |
|
|
|
|
| private focusInitial(): void { |
| if (!this.state.fromIso2) this.fromPicker.focusInput(); |
| else if (!this.state.toIso2) this.toPicker.focusInput(); |
| else if (!this.state.hs2) this.hs2Picker.focusInput(); |
| else this.fromPicker.focusInput(); |
| } |
|
|
| |
|
|
| private helpPriorFocus: HTMLElement | null = null; |
|
|
| private openHelp(): void { |
| if (!this.root || this.helpOverlay) return; |
| this.helpPriorFocus = (document.activeElement as HTMLElement) ?? null; |
| this.helpOverlay = new KeyboardHelp({ onClose: () => this.closeHelp() }); |
| this.root.append(this.helpOverlay.element); |
| const closeBtn = this.helpOverlay.element.querySelector<HTMLButtonElement>('.re-help__close'); |
| closeBtn?.focus(); |
| } |
|
|
| private closeHelp(): void { |
| if (!this.helpOverlay) return; |
| this.helpOverlay.element.remove(); |
| this.helpOverlay = null; |
| if (this.helpPriorFocus && document.body.contains(this.helpPriorFocus)) { |
| this.helpPriorFocus.focus(); |
| } |
| this.helpPriorFocus = null; |
| } |
|
|
| private copyShareUrl(): void { |
| if (typeof window === 'undefined') return; |
| const url = new URL(window.location.href); |
| const serialized = serializeExplorerUrl(this.state); |
| if (serialized) url.searchParams.set('explorer', serialized); |
| if (navigator.clipboard?.writeText) { |
| void navigator.clipboard.writeText(url.toString()); |
| this.trackEvent('route-explorer:share-copied'); |
| } |
| } |
|
|
| |
|
|
| private get tier(): 'pro' | 'free' { |
| return hasPremiumAccess(getAuthState()) ? 'pro' : 'free'; |
| } |
|
|
| private trackEvent(event: UmamiEvent, props?: Record<string, unknown>): void { |
| track(event, { tier: this.tier, ...props }); |
| } |
|
|
| |
|
|
| private installTestHook(): void { |
| if (typeof window === 'undefined') return; |
| const isDev = (() => { |
| try { return Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV); } catch { return false; } |
| })(); |
| if (!isDev) return; |
| if (!window.__routeExplorerTestHook) window.__routeExplorerTestHook = {}; |
| } |
| } |
|
|
| let singleton: RouteExplorer | null = null; |
| export function getRouteExplorer(): RouteExplorer { |
| if (!singleton) singleton = new RouteExplorer(); |
| return singleton; |
| } |
|
|