mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user