@principal-ai/file-city-react 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,10 @@ export interface ArchitectureMapHighlightLayersProps {
12
12
  onDirectorySelect?: (directory: string | null) => void;
13
13
  onFileClick?: (path: string, type: 'file' | 'directory') => void;
14
14
  enableZoom?: boolean;
15
+ zoomToPath?: string | null;
16
+ onZoomComplete?: () => void;
17
+ zoomAnimationSpeed?: number;
18
+ allowZoomToPath?: boolean;
15
19
  fullSize?: boolean;
16
20
  showGrid?: boolean;
17
21
  showFileNames?: boolean;
@@ -56,7 +60,7 @@ export interface ArchitectureMapHighlightLayersProps {
56
60
  buildingBorderRadius?: number;
57
61
  districtBorderRadius?: number;
58
62
  }
59
- declare function ArchitectureMapHighlightLayersInner({ cityData, highlightLayers, onLayerToggle, focusDirectory, rootDirectoryName, onDirectorySelect, onFileClick, enableZoom, fullSize, showGrid, showFileNames, className, selectiveRender, canvasBackgroundColor, hoverBorderColor, disableOpacityDimming, defaultDirectoryColor, defaultBuildingColor, subdirectoryMode, showLayerControls, showFileTypeIcons, showDirectoryLabels, transform, // Default to no rotation
63
+ declare function ArchitectureMapHighlightLayersInner({ cityData, highlightLayers, onLayerToggle, focusDirectory, rootDirectoryName, onDirectorySelect, onFileClick, enableZoom, zoomToPath, onZoomComplete, zoomAnimationSpeed, allowZoomToPath, fullSize, showGrid, showFileNames, className, selectiveRender, canvasBackgroundColor, hoverBorderColor, disableOpacityDimming, defaultDirectoryColor, defaultBuildingColor, subdirectoryMode, showLayerControls, showFileTypeIcons, showDirectoryLabels, transform, // Default to no rotation
60
64
  onHover, buildingBorderRadius, districtBorderRadius, }: ArchitectureMapHighlightLayersProps): React.JSX.Element;
61
65
  export declare const ArchitectureMapHighlightLayers: typeof ArchitectureMapHighlightLayersInner;
62
66
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"ArchitectureMapHighlightLayers.d.ts","sourceRoot":"","sources":["../../src/components/ArchitectureMapHighlightLayers.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ArchitectureMapHighlightLayers.d.ts","sourceRoot":"","sources":["../../src/components/ArchitectureMapHighlightLayers.tsx"],"names":[],"mappings":"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"}
@@ -117,7 +117,67 @@ class SpatialGrid {
117
117
  this.grid.clear();
118
118
  }
119
119
  }
120
- function ArchitectureMapHighlightLayersInner({ cityData, highlightLayers = [], onLayerToggle, focusDirectory = null, rootDirectoryName, onDirectorySelect, onFileClick, enableZoom = false, fullSize = false, showGrid = false, showFileNames = false, className = '', selectiveRender, canvasBackgroundColor, hoverBorderColor, disableOpacityDimming = true, defaultDirectoryColor, defaultBuildingColor, subdirectoryMode, showLayerControls = false, showFileTypeIcons = true, showDirectoryLabels = true, transform = { rotation: 0 }, // Default to no rotation
120
+ /**
121
+ * Hierarchical path lookup for O(depth) containment checks instead of O(A) iteration.
122
+ * Given a set of abstracted paths, efficiently checks if a path is contained within any of them.
123
+ */
124
+ class PathHierarchyLookup {
125
+ constructor(paths) {
126
+ this.abstractedPaths = paths instanceof Set ? paths : new Set(paths);
127
+ }
128
+ /**
129
+ * Check if the given path is inside any abstracted directory.
130
+ * Walks up the path hierarchy checking each ancestor.
131
+ * Complexity: O(depth) where depth is the path depth, instead of O(A) for each abstracted path.
132
+ */
133
+ isPathAbstracted(path) {
134
+ // Check for root abstraction
135
+ if (this.abstractedPaths.has('')) {
136
+ return true;
137
+ }
138
+ // Walk up the path hierarchy
139
+ let currentPath = path;
140
+ while (currentPath.includes('/')) {
141
+ currentPath = currentPath.substring(0, currentPath.lastIndexOf('/'));
142
+ if (this.abstractedPaths.has(currentPath)) {
143
+ return true;
144
+ }
145
+ }
146
+ return false;
147
+ }
148
+ /**
149
+ * Check if the path is a direct child of an abstracted directory
150
+ * (the path itself is abstracted, not just its ancestors).
151
+ */
152
+ isDirectlyAbstracted(path) {
153
+ return this.abstractedPaths.has(path);
154
+ }
155
+ /**
156
+ * Check if the path is a child (not itself) of any abstracted directory.
157
+ */
158
+ isChildOfAbstracted(path) {
159
+ if (!path)
160
+ return false;
161
+ // Check for root abstraction first
162
+ if (this.abstractedPaths.has('')) {
163
+ return true;
164
+ }
165
+ // Check each abstracted path to see if this path starts with it
166
+ for (const abstractedPath of this.abstractedPaths) {
167
+ if (abstractedPath && path.startsWith(abstractedPath + '/')) {
168
+ return true;
169
+ }
170
+ }
171
+ return false;
172
+ }
173
+ get size() {
174
+ return this.abstractedPaths.size;
175
+ }
176
+ has(path) {
177
+ return this.abstractedPaths.has(path);
178
+ }
179
+ }
180
+ function ArchitectureMapHighlightLayersInner({ cityData, highlightLayers = [], onLayerToggle, focusDirectory = null, rootDirectoryName, onDirectorySelect, onFileClick, enableZoom = false, zoomToPath = null, onZoomComplete, zoomAnimationSpeed = 0.12, allowZoomToPath = true, fullSize = false, showGrid = false, showFileNames = false, className = '', selectiveRender, canvasBackgroundColor, hoverBorderColor, disableOpacityDimming = true, defaultDirectoryColor, defaultBuildingColor, subdirectoryMode, showLayerControls = false, showFileTypeIcons = true, showDirectoryLabels = true, transform = { rotation: 0 }, // Default to no rotation
121
181
  onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
122
182
  const { theme } = (0, industry_theme_1.useTheme)();
123
183
  // Use theme colors as defaults, with prop overrides
@@ -140,18 +200,101 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
140
200
  lastMousePos: { x: 0, y: 0 },
141
201
  hasMouseMoved: false,
142
202
  });
203
+ // Target zoom state for animated transitions
204
+ const [targetZoom, setTargetZoom] = (0, react_1.useState)(null);
205
+ // Stable zoom scale - only updates when animation completes or user stops zooming
206
+ // Used for expensive calculations that shouldn't run every frame
207
+ const [stableZoomScale, setStableZoomScale] = (0, react_1.useState)(1);
208
+ const stableZoomTimeoutRef = (0, react_1.useRef)(null);
209
+ // Track if we're currently animating
210
+ const isAnimating = targetZoom !== null;
211
+ // Track the last zoomToPath to detect changes
212
+ const lastZoomToPathRef = (0, react_1.useRef)(null);
143
213
  (0, react_1.useEffect)(() => {
214
+ // Reset user interaction state when enableZoom is disabled
144
215
  if (!enableZoom) {
216
+ setZoomState(prev => ({
217
+ ...prev,
218
+ isDragging: false,
219
+ hasMouseMoved: false,
220
+ }));
221
+ }
222
+ // Only reset zoom position when both user zoom AND programmatic zoom are disabled
223
+ if (!enableZoom && !allowZoomToPath) {
145
224
  setZoomState(prev => ({
146
225
  ...prev,
147
226
  scale: 1,
148
227
  offsetX: 0,
149
228
  offsetY: 0,
150
- isDragging: false,
151
- hasMouseMoved: false,
152
229
  }));
230
+ setTargetZoom(null);
231
+ }
232
+ }, [enableZoom, allowZoomToPath]);
233
+ // Animation loop for smooth zoom transitions
234
+ (0, react_1.useEffect)(() => {
235
+ if (!targetZoom)
236
+ return;
237
+ const animate = () => {
238
+ setZoomState(prev => {
239
+ const lerp = (a, b, t) => a + (b - a) * t;
240
+ const easing = zoomAnimationSpeed;
241
+ const newScale = lerp(prev.scale, targetZoom.scale, easing);
242
+ const newOffsetX = lerp(prev.offsetX, targetZoom.offsetX, easing);
243
+ const newOffsetY = lerp(prev.offsetY, targetZoom.offsetY, easing);
244
+ // Check if close enough to target (within 0.1% for scale, 0.5px for offset)
245
+ const scaleDone = Math.abs(newScale - targetZoom.scale) < 0.001;
246
+ const offsetXDone = Math.abs(newOffsetX - targetZoom.offsetX) < 0.5;
247
+ const offsetYDone = Math.abs(newOffsetY - targetZoom.offsetY) < 0.5;
248
+ if (scaleDone && offsetXDone && offsetYDone) {
249
+ // Animation complete - set exact target values
250
+ setTargetZoom(null);
251
+ onZoomComplete?.();
252
+ return {
253
+ ...prev,
254
+ scale: targetZoom.scale,
255
+ offsetX: targetZoom.offsetX,
256
+ offsetY: targetZoom.offsetY,
257
+ };
258
+ }
259
+ return {
260
+ ...prev,
261
+ scale: newScale,
262
+ offsetX: newOffsetX,
263
+ offsetY: newOffsetY,
264
+ };
265
+ });
266
+ };
267
+ const frameId = requestAnimationFrame(animate);
268
+ return () => cancelAnimationFrame(frameId);
269
+ }, [targetZoom, zoomState, zoomAnimationSpeed, onZoomComplete]);
270
+ // Update stable zoom scale with debouncing
271
+ // This ensures expensive calculations only run when zoom stabilizes
272
+ (0, react_1.useEffect)(() => {
273
+ // Clear any pending timeout
274
+ if (stableZoomTimeoutRef.current) {
275
+ clearTimeout(stableZoomTimeoutRef.current);
276
+ }
277
+ // If animating, wait for animation to complete
278
+ if (isAnimating) {
279
+ return;
280
+ }
281
+ // Debounce the stable zoom update (100ms after last zoom change)
282
+ stableZoomTimeoutRef.current = setTimeout(() => {
283
+ setStableZoomScale(zoomState.scale);
284
+ }, 100);
285
+ return () => {
286
+ if (stableZoomTimeoutRef.current) {
287
+ clearTimeout(stableZoomTimeoutRef.current);
288
+ }
289
+ };
290
+ }, [zoomState.scale, isAnimating]);
291
+ // Immediately update stable zoom when animation completes
292
+ (0, react_1.useEffect)(() => {
293
+ if (!isAnimating && targetZoom === null) {
294
+ // Animation just completed, update stable zoom immediately
295
+ setStableZoomScale(zoomState.scale);
153
296
  }
154
- }, [enableZoom]);
297
+ }, [isAnimating, targetZoom, zoomState.scale]);
155
298
  const [hitTestCache, setHitTestCache] = (0, react_1.useState)(null);
156
299
  const calculateCanvasResolution = (fileCount, _cityBounds) => {
157
300
  const minSize = 400;
@@ -188,22 +331,104 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
188
331
  }
189
332
  return filteredCityData;
190
333
  }, [subdirectoryMode?.enabled, subdirectoryMode?.autoCenter, cityData, filteredCityData]);
334
+ // Handle zoomToPath changes - calculate target zoom to frame the specified path
335
+ (0, react_1.useEffect)(() => {
336
+ // Skip if programmatic zoom is not allowed or path hasn't changed
337
+ if (!allowZoomToPath || zoomToPath === lastZoomToPathRef.current) {
338
+ return;
339
+ }
340
+ lastZoomToPathRef.current = zoomToPath;
341
+ // If zoomToPath is null, reset to default view
342
+ if (zoomToPath === null) {
343
+ setTargetZoom({
344
+ scale: 1,
345
+ offsetX: 0,
346
+ offsetY: 0,
347
+ });
348
+ return;
349
+ }
350
+ // Need city data and canvas ref to calculate zoom
351
+ if (!filteredCityData || !canvasRef.current) {
352
+ return;
353
+ }
354
+ // Get actual display size - the canvas is resized to match this during render
355
+ // so canvas coordinates = display coordinates
356
+ const displayWidth = canvasRef.current.clientWidth || canvasSize.width;
357
+ const displayHeight = canvasRef.current.clientHeight || canvasSize.height;
358
+ if (!displayWidth || !displayHeight) {
359
+ return;
360
+ }
361
+ // Find the target - first check districts, then buildings
362
+ const normalizedPath = zoomToPath.replace(/^\/+|\/+$/g, '');
363
+ const targetDistrict = filteredCityData.districts.find(d => d.path === normalizedPath || d.path === zoomToPath);
364
+ const targetBuilding = filteredCityData.buildings.find(b => b.path === normalizedPath || b.path === zoomToPath);
365
+ if (!targetDistrict && !targetBuilding) {
366
+ return;
367
+ }
368
+ // Get the bounds to zoom to
369
+ let targetBounds;
370
+ if (targetDistrict) {
371
+ targetBounds = targetDistrict.worldBounds;
372
+ }
373
+ else if (targetBuilding) {
374
+ // Create bounds around the building with some padding
375
+ const [width, , depth] = targetBuilding.dimensions;
376
+ const padding = Math.max(width, depth) * 2;
377
+ targetBounds = {
378
+ minX: targetBuilding.position.x - width / 2 - padding,
379
+ maxX: targetBuilding.position.x + width / 2 + padding,
380
+ minZ: targetBuilding.position.z - depth / 2 - padding,
381
+ maxZ: targetBuilding.position.z + depth / 2 + padding,
382
+ };
383
+ }
384
+ else {
385
+ return;
386
+ }
387
+ // Use the same coordinate system as rendering
388
+ const coordinateData = canvasSizingData || filteredCityData;
389
+ const { scale: baseScale, offsetX: baseOffsetX, offsetZ: baseOffsetZ } = calculateScaleAndOffset(coordinateData, displayWidth, displayHeight, displayOptions.padding);
390
+ // Calculate target center in world coordinates
391
+ const targetCenterX = (targetBounds.minX + targetBounds.maxX) / 2;
392
+ const targetCenterZ = (targetBounds.minZ + targetBounds.maxZ) / 2;
393
+ // Calculate target size in screen coordinates (at base zoom)
394
+ const targetScreenWidth = (targetBounds.maxX - targetBounds.minX) * baseScale;
395
+ const targetScreenHeight = (targetBounds.maxZ - targetBounds.minZ) * baseScale;
396
+ // Calculate zoom scale to fit target with padding (80% of display)
397
+ const paddingFactor = 0.8;
398
+ const scaleToFitWidth = (displayWidth * paddingFactor) / targetScreenWidth;
399
+ const scaleToFitHeight = (displayHeight * paddingFactor) / targetScreenHeight;
400
+ const newZoomScale = Math.min(scaleToFitWidth, scaleToFitHeight, 5); // Cap at 5x
401
+ // Calculate the base screen position of target center (before zoom transform)
402
+ // This matches the worldToCanvas formula: ((x - bounds.minX) * scale + offsetX)
403
+ const baseScreenX = (targetCenterX - coordinateData.bounds.minX) * baseScale + baseOffsetX;
404
+ const baseScreenY = (targetCenterZ - coordinateData.bounds.minZ) * baseScale + baseOffsetZ;
405
+ // Calculate offset to center the target
406
+ // Full formula: screenPos = baseScreenPos * zoomScale + zoomOffset
407
+ // To center: displayCenter = baseScreen * zoomScale + zoomOffset
408
+ // Therefore: zoomOffset = displayCenter - baseScreen * zoomScale
409
+ const newOffsetX = displayWidth / 2 - baseScreenX * newZoomScale;
410
+ const newOffsetY = displayHeight / 2 - baseScreenY * newZoomScale;
411
+ setTargetZoom({
412
+ scale: newZoomScale,
413
+ offsetX: newOffsetX,
414
+ offsetY: newOffsetY,
415
+ });
416
+ }, [
417
+ zoomToPath,
418
+ allowZoomToPath,
419
+ filteredCityData,
420
+ canvasSizingData,
421
+ canvasSize,
422
+ displayOptions.padding,
423
+ ]);
191
424
  // Build hit test cache with spatial indexing
192
425
  const buildHitTestCache = (0, react_1.useCallback)((cityData, scale, offsetX, offsetZ, zoomState, abstractedPaths) => {
193
426
  const spatialGrid = new SpatialGrid(cityData.bounds);
427
+ const pathLookup = new PathHierarchyLookup(abstractedPaths);
194
428
  // Only add visible buildings to spatial grid
429
+ // Use PathHierarchyLookup for O(depth) checks instead of O(A) iteration
195
430
  cityData.buildings.forEach(building => {
196
- // Check if this building is inside any abstracted directory
197
- let currentPath = building.path;
198
- let isAbstracted = false;
199
- while (currentPath.includes('/')) {
200
- currentPath = currentPath.substring(0, currentPath.lastIndexOf('/'));
201
- if (abstractedPaths.has(currentPath)) {
202
- isAbstracted = true;
203
- break;
204
- }
205
- }
206
- if (!isAbstracted) {
431
+ if (!pathLookup.isPathAbstracted(building.path)) {
207
432
  spatialGrid.addBuilding(building);
208
433
  }
209
434
  });
@@ -216,14 +441,7 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
216
441
  // For hit testing, we need to include abstracted districts too
217
442
  // because they have visual covers that users can click on.
218
443
  // Only skip children of abstracted directories.
219
- let isChildOfAbstracted = false;
220
- for (const abstractedPath of abstractedPaths) {
221
- if (abstractedPath && district.path.startsWith(abstractedPath + '/')) {
222
- isChildOfAbstracted = true;
223
- break;
224
- }
225
- }
226
- if (!isChildOfAbstracted) {
444
+ if (!pathLookup.isChildOfAbstracted(district.path)) {
227
445
  spatialGrid.addDistrict(district);
228
446
  }
229
447
  });
@@ -238,6 +456,7 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
238
456
  zoomOffsetY: zoomState.offsetY,
239
457
  },
240
458
  abstractedPaths,
459
+ pathLookup,
241
460
  timestamp: Date.now(),
242
461
  };
243
462
  }, []);
@@ -262,8 +481,9 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
262
481
  }
263
482
  const config = abstractionLayerDef.abstractionConfig;
264
483
  // Disable abstraction when zoomed in beyond a certain threshold
484
+ // Use stableZoomScale to avoid recalculating during animation
265
485
  const maxZoomForAbstraction = config?.maxZoomLevel ?? 5.0;
266
- if (zoomState.scale > maxZoomForAbstraction) {
486
+ if (stableZoomScale > maxZoomForAbstraction) {
267
487
  return null; // No abstractions when zoomed in
268
488
  }
269
489
  // Calculate which directories are too small at current zoom
@@ -275,7 +495,8 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
275
495
  ? cityData
276
496
  : filteredCityData;
277
497
  const { scale } = calculateScaleAndOffset(coordinateSystemData, displayWidth, displayHeight, displayOptions.padding);
278
- const totalScale = scale * zoomState.scale;
498
+ // Use stableZoomScale for abstraction calculation to avoid recalculating during animation
499
+ const totalScale = scale * stableZoomScale;
279
500
  // Get all highlighted paths from enabled layers
280
501
  const highlightedPaths = new Set();
281
502
  allLayersWithoutAbstraction.forEach(layer => {
@@ -455,7 +676,7 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
455
676
  filteredCityData,
456
677
  canvasSize,
457
678
  displayOptions.padding,
458
- zoomState.scale,
679
+ stableZoomScale, // Use stable zoom scale instead of live zoomState.scale
459
680
  subdirectoryMode,
460
681
  cityData,
461
682
  allLayersWithoutAbstraction,
@@ -473,6 +694,10 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
473
694
  return layers;
474
695
  }, [stableLayers, dynamicLayers, abstractionLayer]);
475
696
  // Update hit test cache when geometry or abstraction changes
697
+ // Note: We don't depend on zoomState here because:
698
+ // 1. The spatial grid only depends on buildings/districts and abstraction
699
+ // 2. performHitTest calculates coordinates using the current zoomState directly
700
+ // 3. This avoids expensive cache rebuilds during zoom animation
476
701
  (0, react_1.useEffect)(() => {
477
702
  if (!filteredCityData)
478
703
  return;
@@ -488,13 +713,22 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
488
713
  }
489
714
  });
490
715
  }
491
- const newCache = buildHitTestCache(filteredCityData, scale, offsetX, offsetZ, zoomState, abstractedPaths);
716
+ // Pass a stable zoom state for cache metadata (not used for coordinate calculations)
717
+ const stableZoomState = {
718
+ scale: stableZoomScale,
719
+ offsetX: 0,
720
+ offsetY: 0,
721
+ isDragging: false,
722
+ lastMousePos: { x: 0, y: 0 },
723
+ hasMouseMoved: false,
724
+ };
725
+ const newCache = buildHitTestCache(filteredCityData, scale, offsetX, offsetZ, stableZoomState, abstractedPaths);
492
726
  setHitTestCache(newCache);
493
727
  }, [
494
728
  filteredCityData,
495
729
  canvasSize,
496
730
  displayOptions.padding,
497
- zoomState,
731
+ stableZoomScale, // Only rebuild when zoom stabilizes
498
732
  buildHitTestCache,
499
733
  abstractionLayer,
500
734
  ]);
@@ -538,27 +772,24 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
538
772
  }
539
773
  });
540
774
  }
775
+ // Create PathHierarchyLookup for O(depth) containment checks
776
+ const pathLookup = new PathHierarchyLookup(abstractedPathsForDistricts);
541
777
  // Keep abstracted districts (for covers) but filter out their children
542
- let visibleDistricts = abstractedPathsForDistricts.size > 0
778
+ let visibleDistricts = pathLookup.size > 0
543
779
  ? filteredCityData.districts.filter(district => {
544
780
  // Check for root abstraction first
545
- if (abstractedPathsForDistricts.has('')) {
781
+ if (pathLookup.has('')) {
546
782
  // If root is abstracted, only show root district
547
783
  return !district.path || district.path === '';
548
784
  }
549
785
  if (!district.path)
550
786
  return true; // Keep root
551
787
  // Keep the abstracted district itself (we need it for the cover)
552
- if (abstractedPathsForDistricts.has(district.path)) {
788
+ if (pathLookup.has(district.path)) {
553
789
  return true;
554
790
  }
555
- // Filter out children of abstracted directories
556
- for (const abstractedPath of abstractedPathsForDistricts) {
557
- if (district.path.startsWith(abstractedPath + '/')) {
558
- return false; // Skip child of abstracted directory
559
- }
560
- }
561
- return true;
791
+ // Filter out children of abstracted directories using O(depth) lookup
792
+ return !pathLookup.isChildOfAbstracted(district.path);
562
793
  })
563
794
  : filteredCityData.districts;
564
795
  // If root is abstracted and there's no root district, create one for the cover
@@ -579,57 +810,14 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
579
810
  // Draw districts with layer support
580
811
  (0, drawLayeredBuildings_1.drawLayeredDistricts)(ctx, visibleDistricts, worldToCanvas, scale * zoomState.scale, allLayers, interactionState.hoveredDistrict, fullSize, resolvedDefaultDirectoryColor, filteredCityData.metadata.layoutConfig, abstractedPathsForDistricts, // Pass abstracted paths to skip labels
581
812
  showDirectoryLabels, districtBorderRadius);
582
- // Get abstracted directory paths for filtering from the actual layers being rendered
583
- const abstractedPaths = new Set();
584
- const activeAbstractionLayer = allLayers.find(l => l.id === 'directory-abstraction');
585
- if (activeAbstractionLayer && activeAbstractionLayer.enabled) {
586
- activeAbstractionLayer.items.forEach(item => {
587
- if (item.type === 'directory') {
588
- abstractedPaths.add(item.path);
589
- }
590
- });
591
- }
592
813
  // Filter out buildings that are in abstracted directories
593
- const visibleBuildings = abstractedPaths.size > 0
814
+ // Reuse the pathLookup created earlier for O(depth) containment checks
815
+ const visibleBuildings = pathLookup.size > 0
594
816
  ? filteredCityData.buildings.filter(building => {
595
- // Check for root abstraction first
596
- if (abstractedPaths.has('')) {
597
- // If root is abstracted, hide all buildings
598
- return false;
599
- }
600
- const buildingDir = building.path.substring(0, building.path.lastIndexOf('/')) || '';
601
- // Simple direct check first
602
- for (const abstractedPath of abstractedPaths) {
603
- if (buildingDir === abstractedPath) {
604
- return false;
605
- }
606
- // Also check if building is in a subdirectory of abstracted path
607
- if (buildingDir.startsWith(abstractedPath + '/')) {
608
- return false;
609
- }
610
- }
611
- return true;
817
+ // Use PathHierarchyLookup for efficient O(depth) check
818
+ return !pathLookup.isPathAbstracted(building.path);
612
819
  })
613
820
  : filteredCityData.buildings;
614
- // Log only if we see buildings that should have been filtered
615
- if (abstractedPaths.size > 0) {
616
- const suspiciousBuildings = visibleBuildings.filter(building => {
617
- const buildingDir = building.path.substring(0, building.path.lastIndexOf('/')) || '';
618
- // Check if this building's parent is visually covered
619
- for (const abstractedPath of abstractedPaths) {
620
- if (buildingDir === abstractedPath || buildingDir.startsWith(abstractedPath + '/')) {
621
- return true;
622
- }
623
- }
624
- return false;
625
- });
626
- if (suspiciousBuildings.length > 0) {
627
- console.error(`[Building Filter] WARNING: ${suspiciousBuildings.length} buildings are being rendered in abstracted directories!`);
628
- suspiciousBuildings.slice(0, 3).forEach(building => {
629
- console.error(` - ${building.path}`);
630
- });
631
- }
632
- }
633
821
  // Draw buildings with layer support
634
822
  (0, drawLayeredBuildings_1.drawLayeredBuildings)(ctx, visibleBuildings, worldToCanvas, scale * zoomState.scale, allLayers, interactionState.hoveredBuilding, resolvedDefaultBuildingColor, showFileNames, resolvedHoverBorderColor, disableOpacityDimming, showFileTypeIcons, buildingBorderRadius);
635
823
  // Performance monitoring end available for debugging
@@ -1 +1 @@
1
- {"version":3,"file":"sample-data.d.ts","sourceRoot":"","sources":["../../src/stories/sample-data.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAA8B,MAAM,iCAAiC,CAAC;AAGvF,wBAAgB,oBAAoB,IAAI,QAAQ,CAmP/C;AAGD,wBAAgB,yBAAyB,IAAI,QAAQ,CAmDpD"}
1
+ {"version":3,"file":"sample-data.d.ts","sourceRoot":"","sources":["../../src/stories/sample-data.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,QAAQ,EAGT,MAAM,iCAAiC,CAAC;AAqFzC,wBAAgB,oBAAoB,IAAI,QAAQ,CAmB/C;AAaD,wBAAgB,yBAAyB,IAAI,QAAQ,CAmBpD"}