diff --git a/.jules/bolt.md b/.jules/bolt.md index ea6a1af..0edd473 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -5,3 +5,6 @@ ## 2024-04-15 - [Avoid O(N log N) Sorting on Massive Geographical Collections] **Learning:** In spatial queries like `findNearbyStations` where we scan `railwayData` containing thousands of stations to find the top K nearest points, allocating all elements to an array and running `Array.prototype.sort()` results in massive temporary object allocation and $O(N \log N)$ execution time (taking ~8.5ms in benchmarks). **Action:** Replace full array sorts with a bounded Top-K array using a simple $O(K)$ insertion sort during the $O(N)$ iteration phase. This brings the time complexity effectively down to $O(N)$, speeding up operations by ~36x (taking ~0.24ms). Remember to apply a final sort if total elements found are less than $K$. +## 2026-04-28 - [O(1) Station Lookup for List Rendering] +**Learning:** Found an opportunity to replace O(N) array loops inside O(T) trip list rendering loops with a single O(1) indexed lookup Map. Previously, fetching station names by their ID within UI components involved iterating over the entire massive `railwayData` object on every render cycle, severely impacting performance. +**Action:** Created `getStationById` in `src/core/railwayRouting.ts` utilizing a local cached Map object tied to `railwayData` reference equality. Replaced all `Object.values(railwayData).forEach` station name searches in `TripsPage.tsx`, `WalkTripEditor.tsx`, and `RailRound.jsx` with this O(1) lookup method. diff --git a/src/RailRound.jsx b/src/RailRound.jsx index ce38ed8..eff1751 100644 --- a/src/RailRound.jsx +++ b/src/RailRound.jsx @@ -9,6 +9,7 @@ import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import * as turf from '@turf/turf'; import { isMobile } from 'react-device-detect'; +import { getStationById } from './core/railwayRouting'; // Quick import-time log to ensure the module loads when Vite imports it. try { console.log('[icon] module loaded'); } catch { } @@ -1267,12 +1268,12 @@ const RecordsView = ({ trips, railwayData, setTrips, onEdit, onDelete, onAdd, se if (isWalk) { let startName = t.fromId || ''; let endName = t.toId || ''; - Object.values(railwayData).forEach(line => { - const s = line.stations.find(st => st.id === t.fromId); - if (s) startName = s.name_ja; - const e = line.stations.find(st => st.id === t.toId); - if (e) endName = e.name_ja; - }); + + const s = getStationById(railwayData, t.fromId || ''); + if (s) startName = s.name_ja; + + const e = getStationById(railwayData, t.toId || ''); + if (e) endName = e.name_ja; const isTree = t.walkType === 'tree'; const cls = { diff --git a/src/components/modals/WalkTripEditor.tsx b/src/components/modals/WalkTripEditor.tsx index 1d3cf52..1ab0c57 100644 --- a/src/components/modals/WalkTripEditor.tsx +++ b/src/components/modals/WalkTripEditor.tsx @@ -6,6 +6,7 @@ import { useUserData } from '../../hooks/useUserData'; import * as turf from '@turf/turf'; import { useTranslation } from 'react-i18next'; import { showAlert, showConfirm } from '../../utils/alerts'; +import { getStationById } from '../../core/railwayRouting'; export const WalkTripEditor: React.FC = () => { const { t } = useTranslation(); @@ -82,12 +83,12 @@ export const WalkTripEditor: React.FC = () => { // Find coordinates for the Bezier curve let startCoords = null; let endCoords = null; - Object.values(railwayData).forEach(line => { - const s = line.stations.find(st => st.id === form.fromId); - if (s) startCoords = [s.lng, s.lat]; - const e = line.stations.find(st => st.id === form.toId); - if (e) endCoords = [e.lng, e.lat]; - }); + + const s = getStationById(railwayData, form.fromId || ''); + if (s) startCoords = [s.lng, s.lat]; + + const e = getStationById(railwayData, form.toId || ''); + if (e) endCoords = [e.lng, e.lat]; if (startCoords && endCoords) { walkPath = generateBezierPath(startCoords as [number, number], endCoords as [number, number]); @@ -130,12 +131,12 @@ export const WalkTripEditor: React.FC = () => { // Resolving station names for read-only display let startName = t('walk.unknownStart', "未知起点"); let endName = t('walk.unknownEnd', "未知终点"); - Object.values(railwayData).forEach(line => { - const s = line.stations.find(st => st.id === form.fromId); - if (s) startName = s.name_ja; - const e = line.stations.find(st => st.id === form.toId); - if (e) endName = e.name_ja; - }); + + const s = getStationById(railwayData, form.fromId || ''); + if (s) startName = s.name_ja; + + const e = getStationById(railwayData, form.toId || ''); + if (e) endName = e.name_ja; const isTree = form.walkType === 'tree'; diff --git a/src/core/railwayRouting.ts b/src/core/railwayRouting.ts index 3db3cbd..650a379 100644 --- a/src/core/railwayRouting.ts +++ b/src/core/railwayRouting.ts @@ -5,6 +5,9 @@ import { calcDist } from '../core/tripCalculator'; // Ensure calcDist is exporte let stationNameIndexCache: Map | null = null; let lastRailwayDataRef: RailwayMap | null = null; +let stationIdIndexCache: Map | null = null; +let lastRailwayDataForIdRef: RailwayMap | null = null; + export const isCompanyCompatible = (meta1: CompanyMeta | undefined, meta2: CompanyMeta | undefined) => { if (!meta1 || !meta2) return false; if (meta1.company === meta2.company && meta1.company !== "上传数据" && meta1.company !== "未知") return true; @@ -34,6 +37,36 @@ export const buildStationIndex = (railwayData: RailwayMap) => { return index; }; +/** + * O(1) Station Lookup by ID + * + * Replaces expensive O(N) nested iterations across all railway lines/stations. + * Caches the station index map and invalidates when `railwayData` reference changes. + * Measurably improves React rendering performance in lists mapping trip items. + */ +export const getStationById = (railwayData: RailwayMap, stationId: string): Station | undefined => { + if (!stationId) return undefined; + + if (stationIdIndexCache && lastRailwayDataForIdRef === railwayData) { + return stationIdIndexCache.get(stationId); + } + + const index = new Map(); + for (const lineKey in railwayData) { + if (!Object.prototype.hasOwnProperty.call(railwayData, lineKey)) continue; + const line = railwayData[lineKey]; + if (!line.stations) continue; + for (let i = 0; i < line.stations.length; i++) { + const st = line.stations[i]; + index.set(st.id, st); + } + } + + stationIdIndexCache = index; + lastRailwayDataForIdRef = railwayData; + return index.get(stationId); +}; + export const getTransferableLines = (station: Station | undefined, currentLineKey: string, railwayData: RailwayMap, strictMode = true) => { if (!station) return []; const currentMeta = railwayData[currentLineKey]?.meta; diff --git a/src/pages/TripsPage.tsx b/src/pages/TripsPage.tsx index f200f4c..75e7946 100644 --- a/src/pages/TripsPage.tsx +++ b/src/pages/TripsPage.tsx @@ -3,7 +3,7 @@ import { Train, Edit2, Trash2, Star, Plus, MapPin, Upload } from 'lucide-react'; import { useStore } from '../store'; import { DropZone } from '../components/DragContext'; import { getRouteVisualData } from '../core/tripCalculator'; -import { computeLoopVia, getLandmarks } from '../core/railwayRouting'; +import { computeLoopVia, getLandmarks, getStationById } from '../core/railwayRouting'; import { isMobile } from 'react-device-detect'; import { useShallow } from 'zustand/react/shallow'; import { useTranslation } from 'react-i18next'; @@ -172,12 +172,12 @@ export const TripsPage: React.FC = () => { if (isWalk) { let startName = trip.fromId || ''; let endName = trip.toId || ''; - Object.values(railwayData).forEach(line => { - const s = line.stations.find(st => st.id === trip.fromId); - if (s) startName = s.name_ja; - const e = line.stations.find(st => st.id === trip.toId); - if (e) endName = e.name_ja; - }); + + const s = getStationById(railwayData, trip.fromId || ''); + if (s) startName = s.name_ja; + + const e = getStationById(railwayData, trip.toId || ''); + if (e) endName = e.name_ja; const isTree = trip.walkType === 'tree'; const cls = {