diff --git a/client/src/components/Map/ReservationOverlay.tsx b/client/src/components/Map/ReservationOverlay.tsx index 79ea1570c..22e380dd2 100644 --- a/client/src/components/Map/ReservationOverlay.tsx +++ b/client/src/components/Map/ReservationOverlay.tsx @@ -6,6 +6,7 @@ import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, Tram import { escapeHtml } from '@trek/shared' import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry' import { geodesicArcs } from './flightGeodesy' +import { cleanEndpointName } from './reservationName' import { useSettingsStore } from '../../store/settingsStore' import type { Reservation, ReservationEndpoint } from '../../types' @@ -64,27 +65,6 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon { function toRad(d: number) { return d * Math.PI / 180 } -// "Wien Hbf (Vienna)" → "Wien Hbf". Done as a scan, not /\s*\([^)]*\)/g: the leading -// \s* overlaps the engine's own restart-at-every-position scan, so a name that is a -// long run of spaces with no "(" after it backtracks quadratically (1.0s at 32k), and -// so does a run of "(" with no ")". Same matches: the whitespace directly before a "(" -// that has a ")" after it goes too, and an unclosed "(" is left alone. -function cleanName(name: string): string { - let out = '' - let cursor = 0 - for (;;) { - const open = name.indexOf('(', cursor) - if (open === -1) break - const close = name.indexOf(')', open + 1) - if (close === -1) break - let start = open - while (start > cursor && /\s/.test(name[start - 1])) start-- - out += name.slice(cursor, start) - cursor = close + 1 - } - return (out + name.slice(cursor)).trim() -} - function haversineKm(a: [number, number], b: [number, number]): number { const R = 6371 const dLat = toRad(b[0] - a[0]) @@ -288,7 +268,7 @@ export default function ReservationOverlay({ reservations, showConnections, onEn onEndpointClick?.(item.res.id) }} diff --git a/client/src/components/Map/reservationName.test.ts b/client/src/components/Map/reservationName.test.ts new file mode 100644 index 000000000..4b2933458 --- /dev/null +++ b/client/src/components/Map/reservationName.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { cleanEndpointName } from './reservationName' + +// The pattern this replaced. Kept here so the equivalence claim in the module +// is a test rather than a comment. +const LEGACY = /\s*\([^)]*\)/g + +describe('cleanEndpointName', () => { + it('drops the parenthetical a booking confirmation appends', () => { + expect(cleanEndpointName('Wien Hbf (Vienna)')).toBe('Wien Hbf') + }) + + it('leaves an unclosed bracket alone', () => { + expect(cleanEndpointName('Gare de Lyon (Paris')).toBe('Gare de Lyon (Paris') + }) + + it('takes the whitespace directly before the bracket with it', () => { + expect(cleanEndpointName('Zurich HB (ZRH) ')).toBe('Zurich HB') + }) + + it('handles several brackets and an empty one', () => { + expect(cleanEndpointName('A (b) C () D')).toBe('A C D') + }) + + it('returns a name without brackets untouched', () => { + expect(cleanEndpointName('Amsterdam Centraal')).toBe('Amsterdam Centraal') + }) + + it('does not hang on the input that made the regex quadratic', () => { + // 32k spaces with no "(" after them: the shape that froze the map for + // every member of a trip once one member had typed it. + const started = performance.now() + expect(cleanEndpointName(' '.repeat(32_000) + 'x')).toBe('x') + expect(performance.now() - started).toBeLessThan(100) + }) + + it('agrees with the regex on every generated name', () => { + const alphabet = [...'ab() \t'] + let checked = 0 + for (let len = 0; len <= 6; len++) { + const total = alphabet.length ** len + for (let n = 0; n < total; n++) { + let s = '' + for (let k = n, i = 0; i < len; i++, k = Math.floor(k / alphabet.length)) { + s += alphabet[k % alphabet.length] + } + expect(cleanEndpointName(s)).toBe(s.replace(LEGACY, '').trim()) + checked++ + } + } + expect(checked).toBe(55987) + }) +}) diff --git a/client/src/components/Map/reservationName.ts b/client/src/components/Map/reservationName.ts new file mode 100644 index 000000000..a8719286a --- /dev/null +++ b/client/src/components/Map/reservationName.ts @@ -0,0 +1,31 @@ +/** + * "Wien Hbf (Vienna)" → "Wien Hbf" — the endpoint name as a map label wants it, + * without the parenthetical a booking confirmation likes to append. Both map + * renderers go through here so an endpoint never reads one way on the Leaflet + * map and another on the GL one. + * + * Written as a scan rather than the /\s*\([^)]*\)/g it replaces. That pattern's + * leading \s* overlaps the engine's own restart-at-every-position scan, so a + * name that is a long run of spaces with no "(" after it backtracks + * quadratically (1.0s at 32k), and so does a run of "(" with no ")". An + * endpoint name is free text any trip member types and every other member's + * browser renders, so that is reachable across users. + * + * Matches the regex exactly: the whitespace directly before a "(" that has a + * ")" after it goes too, and an unclosed "(" is left alone. + */ +export function cleanEndpointName(name: string): string { + let out = '' + let cursor = 0 + for (;;) { + const open = name.indexOf('(', cursor) + if (open === -1) break + const close = name.indexOf(')', open + 1) + if (close === -1) break + let start = open + while (start > cursor && /\s/.test(name[start - 1])) start-- + out += name.slice(cursor, start) + cursor = close + 1 + } + return (out + name.slice(cursor)).trim() +} diff --git a/client/src/components/Map/reservationsMapbox.ts b/client/src/components/Map/reservationsMapbox.ts index 77bb48c3b..8b6e64a63 100644 --- a/client/src/components/Map/reservationsMapbox.ts +++ b/client/src/components/Map/reservationsMapbox.ts @@ -12,6 +12,7 @@ import type mapboxgl from 'mapbox-gl' import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react' import { getTransitMapSegments } from './transitGeometry' import { geodesicArcs } from './flightGeodesy' +import { cleanEndpointName } from './reservationName' import { escapeHtml } from '@trek/shared' import type { Reservation, ReservationEndpoint } from '../../types' @@ -94,27 +95,6 @@ function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fal return h > 0 ? `${h}h ${m}m` : `${m}m` } -// "Wien Hbf (Vienna)" → "Wien Hbf". Done as a scan, not /\s*\([^)]*\)/g: the leading -// \s* overlaps the engine's own restart-at-every-position scan, so a name that is a -// long run of spaces with no "(" after it backtracks quadratically (1.2s at 40k). -// Same matches: the whitespace directly before a "(" that has a ")" after it goes -// too, and an unclosed "(" is left alone. -const cleanName = (name: string) => { - let out = '' - let cursor = 0 - for (;;) { - const open = name.indexOf('(', cursor) - if (open === -1) break - const close = name.indexOf(')', open + 1) - if (close === -1) break - let start = open - while (start > cursor && /\s/.test(name[start - 1])) start-- - out += name.slice(cursor, start) - cursor = close + 1 - } - return (out + name.slice(cursor)).trim() -} - // ── item building ───────────────────────────────────────────────────────── interface TransportItem { res: Reservation @@ -368,7 +348,7 @@ export class ReservationMapboxOverlay { for (const item of visibleItems) { const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id) for (const ep of item.waypoints) { - const label = showLabel ? (ep.code || cleanName(ep.name)) : null + const label = showLabel ? (ep.code || cleanEndpointName(ep.name)) : null const el = document.createElement('div') el.innerHTML = endpointMarkerHtml(item.type, label) const inner = el.firstElementChild as HTMLElement | null