Skip to content

Commit 091f643

Browse files
committed
subway lines, fixes for 2d / iso switch
1 parent c390660 commit 091f643

9 files changed

Lines changed: 541 additions & 112 deletions

File tree

‎src/components/Map/DroppedObjects.jsx‎

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useCallback, useMemo, useEffect, useState } from 'react';
22
import { Popup as MapLibrePopup } from 'maplibre-gl';
3-
import { quantizeAngleTo45, quantizeToSlices, addEnhancedSpritesToMap, buildSpriteImageId, getMapViewType, buildFlatSpriteUrl, computeSpriteTransform, extractCameraState } from '../../utils/enhancedRenderingUtils';
3+
import { quantizeAngleTo45, quantizeToSlices, addEnhancedSpritesToMap, buildSpriteImageId, getMapViewType, buildFlatSpriteUrl, computeSpriteTransform, extractCameraState, VIEW_TYPES } from '../../utils/enhancedRenderingUtils';
44
import { getCenterOffsetForPitch, quantizeBearingForView } from '../../utils/bearingUtils';
55
import { ensureViewportAlignedSymbols } from '../../utils/mapLayerUtils';
66
import { useMapViewState } from '../../hooks/useMapViewState';
@@ -629,13 +629,34 @@ const DroppedObjects = ({
629629
try {
630630
const id = e && e.id;
631631
if (!id || typeof id !== 'string') return;
632-
const parts = id.split('_');
633-
const vt = view?.viewType || getMapViewType(map);
634-
if (parts.length >= 2) {
635-
// Enhanced family: register only the requested angle to minimize churn
636-
const base = parts[0];
637-
let angle = 0;
638-
try { angle = parseInt(parts[1], 10); if (!isFinite(angle)) angle = 0; } catch (_) { angle = 0; }
632+
// Enhanced sprite family: id like "fire-hydrant_180" or "tree_maple_TOP_000" (for top-down)
633+
// Parse by checking for TOP pattern first, then extracting base and angle
634+
if (id.includes('_')) {
635+
let base, angle, isTopDown;
636+
637+
// Check if this is a top-down sprite (pattern: base_TOP_000)
638+
const topDownMatch = id.match(/^(.+)_TOP_(\d+)$/);
639+
if (topDownMatch) {
640+
base = topDownMatch[1];
641+
angle = parseInt(topDownMatch[2], 10);
642+
isTopDown = true;
643+
} else {
644+
// Regular sprite: base_000 (find last underscore before angle)
645+
const lastUnderscore = id.lastIndexOf('_');
646+
const beforeLastUnderscore = id.substring(0, lastUnderscore);
647+
const afterLastUnderscore = id.substring(lastUnderscore + 1);
648+
base = beforeLastUnderscore;
649+
try {
650+
angle = parseInt(afterLastUnderscore, 10);
651+
if (!isFinite(angle)) angle = 0;
652+
} catch (_) {
653+
angle = 0;
654+
}
655+
isTopDown = false;
656+
}
657+
658+
const vt = isTopDown ? VIEW_TYPES.TOP_DOWN : (view?.viewType || getMapViewType(map));
659+
639660
await addEnhancedSpritesToMap(map, {
640661
baseName: base,
641662
publicDir: `/static/${base}`,

‎src/components/Sidebar/LayersPanel.jsx‎

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// components/Sidebar/LayersPanel.jsx
2-
import React, { useState, useMemo } from 'react';
3-
import { Eye, EyeOff, X, Layers, ToggleLeft, ToggleRight, ChevronDown, ChevronRight, Loader2, CheckCircle, AlertCircle, Circle, FileText, Download } from 'lucide-react';
2+
import React, { useState, useMemo, useCallback, useEffect } from 'react';
3+
import { Eye, EyeOff, X, Layers, ToggleLeft, ToggleRight, ChevronDown, ChevronRight, Loader2, CheckCircle, AlertCircle, Circle, FileText, Download, Minus } from 'lucide-react';
44
import { LAYER_GROUPS, DISABLED_INFRASTRUCTURE_LAYERS, NON_RECOMMENDED_INFRASTRUCTURE_LAYERS } from '../../constants/layers';
55
import { INFRASTRUCTURE_ICONS, svgToDataUrl } from '../../utils/iconUtils';
6-
import { getCandidateSrcs } from '../../utils/spriteResolver';
6+
import { getCandidateSrcs, preloadChain, firstReadyInChain } from '../../utils/spriteResolver';
77

88
import { useMapViewState } from '../../hooks/useMapViewState';
99

@@ -19,7 +19,8 @@ const LayersPanel = ({
1919
permitAreas,
2020
hasSubFocus = false,
2121
onBeginSubFocus = null,
22-
onClearSubFocus = null
22+
onClearSubFocus = null,
23+
onToggleSubwayLines = null
2324
}) => {
2425
const view = useMapViewState(map);
2526
// State for tracking which groups are expanded
@@ -76,6 +77,8 @@ const LayersPanel = ({
7677
if (!group) return [];
7778
return group.layers.filter((layerId) => {
7879
const cfg = layers[layerId];
80+
// Hide subwayLines from UI - it's toggled automatically with subwayEntrances
81+
if (layerId === 'subwayLines') return false;
7982
return cfg && !cfg.disabled;
8083
});
8184
};
@@ -98,27 +101,78 @@ const LayersPanel = ({
98101
});
99102
};
100103

104+
// Memoize sprite candidate sources to avoid redundant computation
105+
// Key: layerId + viewType + spriteBase (stable identifiers)
106+
const spriteCacheRef = React.useRef(new Map());
107+
const preloadRef = React.useRef(new Set());
108+
109+
// Preload sprites for visible layers with enhanced rendering
110+
useEffect(() => {
111+
if (!view?.viewType) return;
112+
const viewType = view.viewType;
113+
const angle = viewType === 'isometric' ? 135 : 0;
114+
115+
Object.entries(layers || {}).forEach(([layerId, config]) => {
116+
if (!config?.enhancedRendering?.enabled || !config?.enhancedRendering?.spriteBase) return;
117+
118+
const spriteBase = config.enhancedRendering.spriteBase;
119+
const cacheKey = `${layerId}:${viewType}:${spriteBase}`;
120+
121+
// Only preload once per cache key
122+
if (preloadRef.current.has(cacheKey)) return;
123+
124+
const candidates = getCandidateSrcs(config, angle, viewType);
125+
if (candidates && candidates.length > 0) {
126+
// Preload the first candidate (primary sprite) to prevent pop-in
127+
preloadChain(candidates.slice(0, 2)).catch(() => {}); // Fire and forget
128+
preloadRef.current.add(cacheKey);
129+
}
130+
});
131+
}, [layers, view?.viewType]);
132+
101133
// Render the appropriate icon for a layer
102-
const renderLayerIcon = (layerId, config) => {
134+
const renderLayerIcon = useCallback((layerId, config) => {
103135
const icon = INFRASTRUCTURE_ICONS[layerId];
104136

105137
// If enhanced rendering is enabled for this layer, use the shared spriteResolver
106138
// to build a robust fallback chain (flat and nested structures, legacy as last resort)
107139
if (config?.enhancedRendering?.enabled) {
108140
const viewType = view?.viewType || 'isometric';
109141
const angle = viewType === 'isometric' ? 135 : 0;
110-
const candidates = getCandidateSrcs(config, angle, viewType);
111-
const src = candidates[0] || null;
142+
143+
// Create a stable cache key based on layer config that only changes when sprite data changes
144+
const spriteBase = config?.enhancedRendering?.spriteBase;
145+
const cacheKey = `${layerId}:${viewType}:${spriteBase || ''}`;
146+
147+
// Check cache first - only recompute if config or view changed
148+
let candidates = spriteCacheRef.current.get(cacheKey);
149+
if (!candidates) {
150+
candidates = getCandidateSrcs(config, angle, viewType);
151+
spriteCacheRef.current.set(cacheKey, candidates);
152+
// Limit cache size to prevent memory leaks
153+
if (spriteCacheRef.current.size > 100) {
154+
const firstKey = spriteCacheRef.current.keys().next().value;
155+
spriteCacheRef.current.delete(firstKey);
156+
}
157+
// Preload immediately when computing new candidates
158+
if (candidates && candidates.length > 0) {
159+
preloadChain(candidates.slice(0, 2)).catch(() => {});
160+
}
161+
}
162+
163+
// Use first ready image from cache to prevent pop-in, fallback to first candidate
164+
const readySrc = firstReadyInChain(candidates) || candidates[0] || null;
112165
return (
113166
<div
114167
className={`w-6 h-6 flex items-center justify-center flex-shrink-0 ${config.loading ? 'animate-pulse' : ''}`}
115168
style={{ opacity: config.requested ? 1 : 0.3 }}
116169
>
117-
{src ? (
170+
{readySrc ? (
118171
<img
119-
src={src}
172+
src={readySrc}
120173
alt={config.name}
121174
className="w-7 h-7 object-contain"
175+
loading="eager"
122176
style={{
123177
filter: config.loading ? 'grayscale(100%)' : 'none',
124178
opacity: config.requested ? 1 : 0.6
@@ -206,7 +260,7 @@ const LayersPanel = ({
206260
}}
207261
/>
208262
);
209-
};
263+
}, [view?.viewType]);
210264

211265
// Render a group header with toggle functionality
212266
const renderGroupHeader = (groupId, group) => {
@@ -277,6 +331,13 @@ const LayersPanel = ({
277331
const isEmpty = !!config.empty;
278332
const isError = !!config.error;
279333

334+
// Special handling for subway entrances: show both entrances and lines controls
335+
const isSubwayEntrances = layerId === 'subwayEntrances';
336+
const subwayLinesConfig = isSubwayEntrances ? layers.subwayLines : null;
337+
const subwayLinesRequested = subwayLinesConfig ? !!subwayLinesConfig.requested : false;
338+
const subwayLinesLoading = subwayLinesConfig ? !!subwayLinesConfig.loading : false;
339+
const subwayLinesEnabled = subwayLinesConfig ? (focusedArea && !subwayLinesConfig.disabled) : false;
340+
280341
const renderStatusIcon = () => {
281342
if (isError) return <AlertCircle className="w-4 h-4 text-red-500" title="Error loading" />;
282343
if (isLoading) return <Loader2 className="w-4 h-4 text-blue-600 animate-spin" title="Loading" />;
@@ -298,19 +359,36 @@ const LayersPanel = ({
298359
}`}
299360
>
300361
<div className="flex items-center space-x-2.5 min-w-0 flex-1">
362+
{/* Eye button for subway entrances (or main layer) */}
301363
<button
302364
onClick={() => isEnabled && !config.disabled && onToggleLayer(layerId)}
303365
className={`p-1 rounded flex-shrink-0 ${
304366
isEnabled ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700' : 'cursor-not-allowed'
305367
}`}
306368
disabled={!isEnabled || isLoading}
369+
title={isSubwayEntrances ? "Toggle subway entrances" : undefined}
307370
>
308371
{isRequested ? (
309372
<Eye className={`w-4 h-4 ${isEnabled ? 'text-blue-600' : 'text-gray-400 dark:text-gray-500'}`} />
310373
) : (
311374
<EyeOff className={`w-4 h-4 ${isEnabled ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500'}`} />
312375
)}
313376
</button>
377+
378+
{/* Line button for subway lines (only shown for subway entrances) */}
379+
{isSubwayEntrances && onToggleSubwayLines && (
380+
<button
381+
onClick={() => subwayLinesEnabled && !subwayLinesConfig?.disabled && onToggleSubwayLines('subwayLines')}
382+
className={`p-1 rounded flex-shrink-0 ${
383+
subwayLinesEnabled ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700' : 'cursor-not-allowed'
384+
}`}
385+
disabled={!subwayLinesEnabled || subwayLinesLoading}
386+
title="Toggle subway lines"
387+
>
388+
<Minus className={`w-4 h-4 ${subwayLinesEnabled ? (subwayLinesRequested ? 'text-blue-600' : 'text-gray-600 dark:text-gray-300') : 'text-gray-400 dark:text-gray-500'}`} />
389+
</button>
390+
)}
391+
314392
{renderLayerIcon(layerId, config)}
315393
<span className={`text-sm font-medium truncate ${
316394
isRequested && isEnabled ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500 dark:text-gray-400'

‎src/components/Sidebar/Sidebar.jsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ const Sidebar = ({
128128
layers={layers}
129129
focusedArea={focusedArea}
130130
onToggleLayer={onToggleLayer}
131+
onToggleSubwayLines={onToggleLayer}
131132
onClearFocus={onClearFocus}
132133
isSitePlanMode={isSitePlanMode}
133134
geographyType={geographyType}

‎src/constants/endpoints.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@ export const INFRASTRUCTURE_ENDPOINTS = {
4141
'entrance_georeference'
4242
]
4343
},
44+
subwayLines: {
45+
baseUrl: 'https://data.ny.gov/resource/s692-irgq.geojson',
46+
// Subway lines dataset uses geometry column (MultiLineString)
47+
geoField: 'geometry',
48+
isLocal: false,
49+
selectFields: [
50+
'service',
51+
'service_name',
52+
'geometry'
53+
]
54+
},
4455
fireLanes: {
4556
baseUrl: 'https://data.cityofnewyork.us/resource/inkn-q76z.geojson',
4657
geoField: 'the_geom',

‎src/constants/layers.js‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const LAYER_GROUPS = {
1515
'transit': {
1616
name: 'Transit',
1717
icon: '🚇',
18-
layers: ['subwayEntrances', 'stationEnvelopes', 'busStops']
18+
layers: ['subwayEntrances', 'subwayLines', 'stationEnvelopes', 'busStops']
1919
},
2020
'accessibility': {
2121
name: 'Accessibility',
@@ -88,7 +88,7 @@ export const INITIAL_LAYERS = {
8888
enhancedRendering: {
8989
enabled: true,
9090
spriteBase: 'bike-rack',
91-
publicDir: '/static/bike-rack/isometric/renders',
91+
publicDir: '/static/bike-rack',
9292
facingMode: 'awayFromStreet',
9393
angles: [0, 45, 90, 135, 180, 225, 270, 315]
9494
}
@@ -105,12 +105,21 @@ export const INITIAL_LAYERS = {
105105
subwayEntrances: {
106106
visible: false,
107107
requested: false,
108-
name: 'Subway Entrances',
108+
name: 'Subway',
109109
color: '#dc2626',
110110
loading: false,
111111
loaded: false,
112112
endpoint: '/api/subway-entrances'
113113
},
114+
subwayLines: {
115+
visible: false,
116+
requested: false,
117+
name: 'Subway Lines',
118+
color: '#dc2626',
119+
loading: false,
120+
loaded: false,
121+
endpoint: 'https://data.ny.gov/resource/s692-irgq.geojson'
122+
},
114123
fireLanes: {
115124
visible: false,
116125
requested: false,
@@ -151,7 +160,7 @@ export const INITIAL_LAYERS = {
151160
enhancedRendering: {
152161
enabled: true,
153162
spriteBase: 'parking-meter',
154-
publicDir: '/static/parking-meter/isometric/renders',
163+
publicDir: '/static/parking-meter',
155164
facingMode: 'towardStreet',
156165
angles: [0, 45, 90, 135, 180, 225, 270, 315]
157166
}
@@ -177,7 +186,7 @@ export const INITIAL_LAYERS = {
177186
enhancedRendering: {
178187
enabled: true,
179188
spriteBase: 'linknyc', // expects {base}_{000|045|...}.png
180-
publicDir: '/static/linknyc/isometric/renders',
189+
publicDir: '/static/linknyc',
181190
desiredParallelTo: 'cscl',
182191
angles: [0, 45, 90, 135, 180, 225, 270, 315]
183192
}
@@ -256,7 +265,7 @@ export const INITIAL_LAYERS = {
256265
enhancedRendering: {
257266
enabled: true,
258267
spriteBase: 'tree_maple',
259-
publicDir: '/static/tree_maple/isometric/renders',
268+
publicDir: '/static/tree_maple',
260269
angles: [0, 45, 90, 135, 180, 225, 270, 315]
261270
}
262271
},
@@ -271,7 +280,7 @@ export const INITIAL_LAYERS = {
271280
enhancedRendering: {
272281
enabled: true,
273282
spriteBase: 'fire-hydrant',
274-
publicDir: '/static/fire-hydrant/isometric/renders',
283+
publicDir: '/static/fire-hydrant',
275284
angles: [0, 45, 90, 135, 180, 225, 270, 315]
276285
}
277286
},
@@ -296,7 +305,7 @@ export const INITIAL_LAYERS = {
296305
enhancedRendering: {
297306
enabled: true,
298307
spriteBase: 'bench',
299-
publicDir: '/static/bench/isometric/renders',
308+
publicDir: '/static/bench',
300309
facingMode: 'awayFromStreet',
301310
angles: [0, 45, 90, 135, 180, 225, 270, 315]
302311
}
@@ -311,7 +320,7 @@ export const INITIAL_LAYERS = {
311320
enhancedRendering: {
312321
enabled: true,
313322
spriteBase: 'trash-can',
314-
publicDir: '/static/trash-can/isometric/renders',
323+
publicDir: '/static/trash-can',
315324
desiredParallelTo: 'cscl',
316325
angles: [0, 45, 90, 135, 180, 225, 270, 315]
317326
}

0 commit comments

Comments
 (0)