refactor: one endpoint-name cleaner for both map renderers

The scan that replaced the parenthetical-stripping regex went into the Leaflet
overlay and its Mapbox GL counterpart as two byte-identical copies, which is
what the duplication gate caught. trackColors.ts next door exists for exactly
this reason - both renderers read a track's colour through one function so it
cannot come out different on one map than the other - so the cleaner follows it.

Behaviour is unchanged. The test brute-forces the new function against the regex
it replaced over all 55,987 names up to six characters of "ab() \t", and pins the
32k-space input that used to take a second.
This commit is contained in:
Maurice
2026-08-25 21:45:13 +02:00
committed by Maurice
parent a3ad53b3bf
commit c435033343
4 changed files with 88 additions and 44 deletions
@@ -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
<Marker
key={`wp-${item.res.id}-${wi}`}
position={[wp.lat, wp.lng]}
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (wp.code || cleanName(wp.name)) : null)}
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (wp.code || cleanEndpointName(wp.name)) : null)}
pane={ENDPOINT_PANE}
zIndexOffset={1000}
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
@@ -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)
})
})
@@ -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()
}
@@ -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