@principal-ai/file-city-react 0.4.0 → 0.4.2

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.
@@ -15,6 +15,7 @@ export interface ArchitectureMapHighlightLayersProps {
15
15
  zoomToPath?: string | null;
16
16
  onZoomComplete?: () => void;
17
17
  zoomAnimationSpeed?: number;
18
+ allowZoomToPath?: boolean;
18
19
  fullSize?: boolean;
19
20
  showGrid?: boolean;
20
21
  showFileNames?: boolean;
@@ -59,7 +60,7 @@ export interface ArchitectureMapHighlightLayersProps {
59
60
  buildingBorderRadius?: number;
60
61
  districtBorderRadius?: number;
61
62
  }
62
- declare function ArchitectureMapHighlightLayersInner({ cityData, highlightLayers, onLayerToggle, focusDirectory, rootDirectoryName, onDirectorySelect, onFileClick, enableZoom, zoomToPath, onZoomComplete, zoomAnimationSpeed, 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
63
64
  onHover, buildingBorderRadius, districtBorderRadius, }: ArchitectureMapHighlightLayersProps): React.JSX.Element;
64
65
  export declare const ArchitectureMapHighlightLayers: typeof ArchitectureMapHighlightLayersInner;
65
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, zoomToPath = null, onZoomComplete, zoomAnimationSpeed = 0.12, 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
@@ -142,21 +202,37 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
142
202
  });
143
203
  // Target zoom state for animated transitions
144
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;
145
211
  // Track the last zoomToPath to detect changes
146
212
  const lastZoomToPathRef = (0, react_1.useRef)(null);
213
+ // Throttle ref for hover updates (improves performance with large datasets)
214
+ const lastHoverUpdateRef = (0, react_1.useRef)(0);
215
+ const HOVER_THROTTLE_MS = 16; // ~60fps max for hover updates
147
216
  (0, react_1.useEffect)(() => {
217
+ // Reset user interaction state when enableZoom is disabled
148
218
  if (!enableZoom) {
219
+ setZoomState(prev => ({
220
+ ...prev,
221
+ isDragging: false,
222
+ hasMouseMoved: false,
223
+ }));
224
+ }
225
+ // Only reset zoom position when both user zoom AND programmatic zoom are disabled
226
+ if (!enableZoom && !allowZoomToPath) {
149
227
  setZoomState(prev => ({
150
228
  ...prev,
151
229
  scale: 1,
152
230
  offsetX: 0,
153
231
  offsetY: 0,
154
- isDragging: false,
155
- hasMouseMoved: false,
156
232
  }));
157
233
  setTargetZoom(null);
158
234
  }
159
- }, [enableZoom]);
235
+ }, [enableZoom, allowZoomToPath]);
160
236
  // Animation loop for smooth zoom transitions
161
237
  (0, react_1.useEffect)(() => {
162
238
  if (!targetZoom)
@@ -194,6 +270,34 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
194
270
  const frameId = requestAnimationFrame(animate);
195
271
  return () => cancelAnimationFrame(frameId);
196
272
  }, [targetZoom, zoomState, zoomAnimationSpeed, onZoomComplete]);
273
+ // Update stable zoom scale with debouncing
274
+ // This ensures expensive calculations only run when zoom stabilizes
275
+ (0, react_1.useEffect)(() => {
276
+ // Clear any pending timeout
277
+ if (stableZoomTimeoutRef.current) {
278
+ clearTimeout(stableZoomTimeoutRef.current);
279
+ }
280
+ // If animating, wait for animation to complete
281
+ if (isAnimating) {
282
+ return;
283
+ }
284
+ // Debounce the stable zoom update (100ms after last zoom change)
285
+ stableZoomTimeoutRef.current = setTimeout(() => {
286
+ setStableZoomScale(zoomState.scale);
287
+ }, 100);
288
+ return () => {
289
+ if (stableZoomTimeoutRef.current) {
290
+ clearTimeout(stableZoomTimeoutRef.current);
291
+ }
292
+ };
293
+ }, [zoomState.scale, isAnimating]);
294
+ // Immediately update stable zoom when animation completes
295
+ (0, react_1.useEffect)(() => {
296
+ if (!isAnimating && targetZoom === null) {
297
+ // Animation just completed, update stable zoom immediately
298
+ setStableZoomScale(zoomState.scale);
299
+ }
300
+ }, [isAnimating, targetZoom, zoomState.scale]);
197
301
  const [hitTestCache, setHitTestCache] = (0, react_1.useState)(null);
198
302
  const calculateCanvasResolution = (fileCount, _cityBounds) => {
199
303
  const minSize = 400;
@@ -232,8 +336,8 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
232
336
  }, [subdirectoryMode?.enabled, subdirectoryMode?.autoCenter, cityData, filteredCityData]);
233
337
  // Handle zoomToPath changes - calculate target zoom to frame the specified path
234
338
  (0, react_1.useEffect)(() => {
235
- // Skip if zoom is not enabled or path hasn't changed
236
- if (!enableZoom || zoomToPath === lastZoomToPathRef.current) {
339
+ // Skip if programmatic zoom is not allowed or path hasn't changed
340
+ if (!allowZoomToPath || zoomToPath === lastZoomToPathRef.current) {
237
341
  return;
238
342
  }
239
343
  lastZoomToPathRef.current = zoomToPath;
@@ -314,7 +418,7 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
314
418
  });
315
419
  }, [
316
420
  zoomToPath,
317
- enableZoom,
421
+ allowZoomToPath,
318
422
  filteredCityData,
319
423
  canvasSizingData,
320
424
  canvasSize,
@@ -323,19 +427,11 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
323
427
  // Build hit test cache with spatial indexing
324
428
  const buildHitTestCache = (0, react_1.useCallback)((cityData, scale, offsetX, offsetZ, zoomState, abstractedPaths) => {
325
429
  const spatialGrid = new SpatialGrid(cityData.bounds);
430
+ const pathLookup = new PathHierarchyLookup(abstractedPaths);
326
431
  // Only add visible buildings to spatial grid
432
+ // Use PathHierarchyLookup for O(depth) checks instead of O(A) iteration
327
433
  cityData.buildings.forEach(building => {
328
- // Check if this building is inside any abstracted directory
329
- let currentPath = building.path;
330
- let isAbstracted = false;
331
- while (currentPath.includes('/')) {
332
- currentPath = currentPath.substring(0, currentPath.lastIndexOf('/'));
333
- if (abstractedPaths.has(currentPath)) {
334
- isAbstracted = true;
335
- break;
336
- }
337
- }
338
- if (!isAbstracted) {
434
+ if (!pathLookup.isPathAbstracted(building.path)) {
339
435
  spatialGrid.addBuilding(building);
340
436
  }
341
437
  });
@@ -348,14 +444,7 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
348
444
  // For hit testing, we need to include abstracted districts too
349
445
  // because they have visual covers that users can click on.
350
446
  // Only skip children of abstracted directories.
351
- let isChildOfAbstracted = false;
352
- for (const abstractedPath of abstractedPaths) {
353
- if (abstractedPath && district.path.startsWith(abstractedPath + '/')) {
354
- isChildOfAbstracted = true;
355
- break;
356
- }
357
- }
358
- if (!isChildOfAbstracted) {
447
+ if (!pathLookup.isChildOfAbstracted(district.path)) {
359
448
  spatialGrid.addDistrict(district);
360
449
  }
361
450
  });
@@ -370,6 +459,7 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
370
459
  zoomOffsetY: zoomState.offsetY,
371
460
  },
372
461
  abstractedPaths,
462
+ pathLookup,
373
463
  timestamp: Date.now(),
374
464
  };
375
465
  }, []);
@@ -394,8 +484,9 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
394
484
  }
395
485
  const config = abstractionLayerDef.abstractionConfig;
396
486
  // Disable abstraction when zoomed in beyond a certain threshold
487
+ // Use stableZoomScale to avoid recalculating during animation
397
488
  const maxZoomForAbstraction = config?.maxZoomLevel ?? 5.0;
398
- if (zoomState.scale > maxZoomForAbstraction) {
489
+ if (stableZoomScale > maxZoomForAbstraction) {
399
490
  return null; // No abstractions when zoomed in
400
491
  }
401
492
  // Calculate which directories are too small at current zoom
@@ -407,7 +498,8 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
407
498
  ? cityData
408
499
  : filteredCityData;
409
500
  const { scale } = calculateScaleAndOffset(coordinateSystemData, displayWidth, displayHeight, displayOptions.padding);
410
- const totalScale = scale * zoomState.scale;
501
+ // Use stableZoomScale for abstraction calculation to avoid recalculating during animation
502
+ const totalScale = scale * stableZoomScale;
411
503
  // Get all highlighted paths from enabled layers
412
504
  const highlightedPaths = new Set();
413
505
  allLayersWithoutAbstraction.forEach(layer => {
@@ -587,7 +679,7 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
587
679
  filteredCityData,
588
680
  canvasSize,
589
681
  displayOptions.padding,
590
- zoomState.scale,
682
+ stableZoomScale, // Use stable zoom scale instead of live zoomState.scale
591
683
  subdirectoryMode,
592
684
  cityData,
593
685
  allLayersWithoutAbstraction,
@@ -604,7 +696,71 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
604
696
  }
605
697
  return layers;
606
698
  }, [stableLayers, dynamicLayers, abstractionLayer]);
699
+ // Memoize abstracted paths lookup - only recalculates when abstraction layer changes
700
+ const { abstractedPathsSet, abstractedPathLookup } = (0, react_1.useMemo)(() => {
701
+ const pathsSet = new Set();
702
+ const abstractionLayerDef = allLayers.find(l => l.id === 'directory-abstraction');
703
+ if (abstractionLayerDef && abstractionLayerDef.enabled) {
704
+ abstractionLayerDef.items.forEach(item => {
705
+ if (item.type === 'directory') {
706
+ pathsSet.add(item.path);
707
+ }
708
+ });
709
+ }
710
+ return {
711
+ abstractedPathsSet: pathsSet,
712
+ abstractedPathLookup: new PathHierarchyLookup(pathsSet),
713
+ };
714
+ }, [allLayers]);
715
+ // Memoize visible districts - only recalculates when data or abstraction changes, NOT on hover
716
+ const visibleDistrictsMemo = (0, react_1.useMemo)(() => {
717
+ if (!filteredCityData)
718
+ return [];
719
+ let districts = abstractedPathLookup.size > 0
720
+ ? filteredCityData.districts.filter(district => {
721
+ // Check for root abstraction first
722
+ if (abstractedPathLookup.has('')) {
723
+ return !district.path || district.path === '';
724
+ }
725
+ if (!district.path)
726
+ return true;
727
+ if (abstractedPathLookup.has(district.path))
728
+ return true;
729
+ return !abstractedPathLookup.isChildOfAbstracted(district.path);
730
+ })
731
+ : filteredCityData.districts;
732
+ // If root is abstracted and there's no root district, create one for the cover
733
+ if (abstractedPathsSet.has('')) {
734
+ const hasRootDistrict = districts.some(d => !d.path || d.path === '');
735
+ if (!hasRootDistrict) {
736
+ districts = [
737
+ {
738
+ path: '',
739
+ worldBounds: filteredCityData.bounds,
740
+ fileCount: filteredCityData.buildings.length,
741
+ type: 'directory',
742
+ },
743
+ ...districts,
744
+ ];
745
+ }
746
+ }
747
+ return districts;
748
+ }, [filteredCityData, abstractedPathLookup, abstractedPathsSet]);
749
+ // Memoize visible buildings - only recalculates when data or abstraction changes, NOT on hover
750
+ const visibleBuildingsMemo = (0, react_1.useMemo)(() => {
751
+ if (!filteredCityData)
752
+ return [];
753
+ return abstractedPathLookup.size > 0
754
+ ? filteredCityData.buildings.filter(building => {
755
+ return !abstractedPathLookup.isPathAbstracted(building.path);
756
+ })
757
+ : filteredCityData.buildings;
758
+ }, [filteredCityData, abstractedPathLookup]);
607
759
  // Update hit test cache when geometry or abstraction changes
760
+ // Note: We don't depend on zoomState here because:
761
+ // 1. The spatial grid only depends on buildings/districts and abstraction
762
+ // 2. performHitTest calculates coordinates using the current zoomState directly
763
+ // 3. This avoids expensive cache rebuilds during zoom animation
608
764
  (0, react_1.useEffect)(() => {
609
765
  if (!filteredCityData)
610
766
  return;
@@ -620,13 +776,22 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
620
776
  }
621
777
  });
622
778
  }
623
- const newCache = buildHitTestCache(filteredCityData, scale, offsetX, offsetZ, zoomState, abstractedPaths);
779
+ // Pass a stable zoom state for cache metadata (not used for coordinate calculations)
780
+ const stableZoomState = {
781
+ scale: stableZoomScale,
782
+ offsetX: 0,
783
+ offsetY: 0,
784
+ isDragging: false,
785
+ lastMousePos: { x: 0, y: 0 },
786
+ hasMouseMoved: false,
787
+ };
788
+ const newCache = buildHitTestCache(filteredCityData, scale, offsetX, offsetZ, stableZoomState, abstractedPaths);
624
789
  setHitTestCache(newCache);
625
790
  }, [
626
791
  filteredCityData,
627
792
  canvasSize,
628
793
  displayOptions.padding,
629
- zoomState,
794
+ stableZoomScale, // Only rebuild when zoom stabilizes
630
795
  buildHitTestCache,
631
796
  abstractionLayer,
632
797
  ]);
@@ -660,110 +825,12 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
660
825
  y: ((z - coordinateSystemData.bounds.minZ) * scale + offsetZ) * zoomState.scale +
661
826
  zoomState.offsetY,
662
827
  });
663
- // Get abstracted paths for filtering child districts
664
- const abstractedPathsForDistricts = new Set();
665
- const abstractionLayerForDistricts = allLayers.find(l => l.id === 'directory-abstraction');
666
- if (abstractionLayerForDistricts && abstractionLayerForDistricts.enabled) {
667
- abstractionLayerForDistricts.items.forEach(item => {
668
- if (item.type === 'directory') {
669
- abstractedPathsForDistricts.add(item.path);
670
- }
671
- });
672
- }
673
- // Keep abstracted districts (for covers) but filter out their children
674
- let visibleDistricts = abstractedPathsForDistricts.size > 0
675
- ? filteredCityData.districts.filter(district => {
676
- // Check for root abstraction first
677
- if (abstractedPathsForDistricts.has('')) {
678
- // If root is abstracted, only show root district
679
- return !district.path || district.path === '';
680
- }
681
- if (!district.path)
682
- return true; // Keep root
683
- // Keep the abstracted district itself (we need it for the cover)
684
- if (abstractedPathsForDistricts.has(district.path)) {
685
- return true;
686
- }
687
- // Filter out children of abstracted directories
688
- for (const abstractedPath of abstractedPathsForDistricts) {
689
- if (district.path.startsWith(abstractedPath + '/')) {
690
- return false; // Skip child of abstracted directory
691
- }
692
- }
693
- return true;
694
- })
695
- : filteredCityData.districts;
696
- // If root is abstracted and there's no root district, create one for the cover
697
- if (abstractedPathsForDistricts.has('')) {
698
- const hasRootDistrict = visibleDistricts.some(d => !d.path || d.path === '');
699
- if (!hasRootDistrict) {
700
- visibleDistricts = [
701
- {
702
- path: '',
703
- worldBounds: filteredCityData.bounds,
704
- fileCount: filteredCityData.buildings.length, // Total file count
705
- type: 'directory',
706
- },
707
- ...visibleDistricts,
708
- ];
709
- }
710
- }
828
+ // Use memoized visible districts and buildings (pre-filtered, doesn't recalculate on hover)
711
829
  // Draw districts with layer support
712
- (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
830
+ (0, drawLayeredBuildings_1.drawLayeredDistricts)(ctx, visibleDistrictsMemo, worldToCanvas, scale * zoomState.scale, allLayers, interactionState.hoveredDistrict, fullSize, resolvedDefaultDirectoryColor, filteredCityData.metadata.layoutConfig, abstractedPathsSet, // Pass abstracted paths to skip labels
713
831
  showDirectoryLabels, districtBorderRadius);
714
- // Get abstracted directory paths for filtering from the actual layers being rendered
715
- const abstractedPaths = new Set();
716
- const activeAbstractionLayer = allLayers.find(l => l.id === 'directory-abstraction');
717
- if (activeAbstractionLayer && activeAbstractionLayer.enabled) {
718
- activeAbstractionLayer.items.forEach(item => {
719
- if (item.type === 'directory') {
720
- abstractedPaths.add(item.path);
721
- }
722
- });
723
- }
724
- // Filter out buildings that are in abstracted directories
725
- const visibleBuildings = abstractedPaths.size > 0
726
- ? filteredCityData.buildings.filter(building => {
727
- // Check for root abstraction first
728
- if (abstractedPaths.has('')) {
729
- // If root is abstracted, hide all buildings
730
- return false;
731
- }
732
- const buildingDir = building.path.substring(0, building.path.lastIndexOf('/')) || '';
733
- // Simple direct check first
734
- for (const abstractedPath of abstractedPaths) {
735
- if (buildingDir === abstractedPath) {
736
- return false;
737
- }
738
- // Also check if building is in a subdirectory of abstracted path
739
- if (buildingDir.startsWith(abstractedPath + '/')) {
740
- return false;
741
- }
742
- }
743
- return true;
744
- })
745
- : filteredCityData.buildings;
746
- // Log only if we see buildings that should have been filtered
747
- if (abstractedPaths.size > 0) {
748
- const suspiciousBuildings = visibleBuildings.filter(building => {
749
- const buildingDir = building.path.substring(0, building.path.lastIndexOf('/')) || '';
750
- // Check if this building's parent is visually covered
751
- for (const abstractedPath of abstractedPaths) {
752
- if (buildingDir === abstractedPath || buildingDir.startsWith(abstractedPath + '/')) {
753
- return true;
754
- }
755
- }
756
- return false;
757
- });
758
- if (suspiciousBuildings.length > 0) {
759
- console.error(`[Building Filter] WARNING: ${suspiciousBuildings.length} buildings are being rendered in abstracted directories!`);
760
- suspiciousBuildings.slice(0, 3).forEach(building => {
761
- console.error(` - ${building.path}`);
762
- });
763
- }
764
- }
765
832
  // Draw buildings with layer support
766
- (0, drawLayeredBuildings_1.drawLayeredBuildings)(ctx, visibleBuildings, worldToCanvas, scale * zoomState.scale, allLayers, interactionState.hoveredBuilding, resolvedDefaultBuildingColor, showFileNames, resolvedHoverBorderColor, disableOpacityDimming, showFileTypeIcons, buildingBorderRadius);
833
+ (0, drawLayeredBuildings_1.drawLayeredBuildings)(ctx, visibleBuildingsMemo, worldToCanvas, scale * zoomState.scale, allLayers, interactionState.hoveredBuilding, resolvedDefaultBuildingColor, showFileNames, resolvedHoverBorderColor, disableOpacityDimming, showFileTypeIcons, buildingBorderRadius);
767
834
  // Performance monitoring end available for debugging
768
835
  // Performance stats available but not logged to reduce console noise
769
836
  // Uncomment for debugging: render time, buildings/districts counts, layer counts
@@ -792,6 +859,10 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
792
859
  resolvedDefaultBuildingColor,
793
860
  districtBorderRadius,
794
861
  showFileTypeIcons,
862
+ // Memoized values for performance (don't recalculate on hover)
863
+ visibleDistrictsMemo,
864
+ visibleBuildingsMemo,
865
+ abstractedPathsSet,
795
866
  ]);
796
867
  // Optimized hit testing
797
868
  const performHitTest = (0, react_1.useCallback)((canvasX, canvasY) => {
@@ -918,6 +989,12 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
918
989
  const handleMouseMoveInternal = (0, react_1.useCallback)((e) => {
919
990
  if (!canvasRef.current || !containerRef.current || !filteredCityData || zoomState.isDragging)
920
991
  return;
992
+ // Throttle hover updates to improve performance with large datasets
993
+ const now = performance.now();
994
+ if (now - lastHoverUpdateRef.current < HOVER_THROTTLE_MS) {
995
+ return;
996
+ }
997
+ lastHoverUpdateRef.current = now;
921
998
  // Get the container rect for mouse position
922
999
  const containerRect = containerRef.current.getBoundingClientRect();
923
1000
  // Get mouse position relative to the container
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principal-ai/file-city-react",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "React components for File City visualization",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -50,6 +50,7 @@ export interface ArchitectureMapHighlightLayersProps {
50
50
  zoomToPath?: string | null; // When set, animates zoom to frame this directory/file
51
51
  onZoomComplete?: () => void; // Called when zoom animation completes
52
52
  zoomAnimationSpeed?: number; // Animation easing factor (0-1), default 0.12
53
+ allowZoomToPath?: boolean; // Allow programmatic zoomToPath even when enableZoom is false (default: true)
53
54
 
54
55
  // Display options
55
56
  fullSize?: boolean;
@@ -195,6 +196,78 @@ class SpatialGrid {
195
196
  }
196
197
  }
197
198
 
199
+ /**
200
+ * Hierarchical path lookup for O(depth) containment checks instead of O(A) iteration.
201
+ * Given a set of abstracted paths, efficiently checks if a path is contained within any of them.
202
+ */
203
+ class PathHierarchyLookup {
204
+ private abstractedPaths: Set<string>;
205
+
206
+ constructor(paths: Set<string> | string[]) {
207
+ this.abstractedPaths = paths instanceof Set ? paths : new Set(paths);
208
+ }
209
+
210
+ /**
211
+ * Check if the given path is inside any abstracted directory.
212
+ * Walks up the path hierarchy checking each ancestor.
213
+ * Complexity: O(depth) where depth is the path depth, instead of O(A) for each abstracted path.
214
+ */
215
+ isPathAbstracted(path: string): boolean {
216
+ // Check for root abstraction
217
+ if (this.abstractedPaths.has('')) {
218
+ return true;
219
+ }
220
+
221
+ // Walk up the path hierarchy
222
+ let currentPath = path;
223
+ while (currentPath.includes('/')) {
224
+ currentPath = currentPath.substring(0, currentPath.lastIndexOf('/'));
225
+ if (this.abstractedPaths.has(currentPath)) {
226
+ return true;
227
+ }
228
+ }
229
+
230
+ return false;
231
+ }
232
+
233
+ /**
234
+ * Check if the path is a direct child of an abstracted directory
235
+ * (the path itself is abstracted, not just its ancestors).
236
+ */
237
+ isDirectlyAbstracted(path: string): boolean {
238
+ return this.abstractedPaths.has(path);
239
+ }
240
+
241
+ /**
242
+ * Check if the path is a child (not itself) of any abstracted directory.
243
+ */
244
+ isChildOfAbstracted(path: string): boolean {
245
+ if (!path) return false;
246
+
247
+ // Check for root abstraction first
248
+ if (this.abstractedPaths.has('')) {
249
+ return true;
250
+ }
251
+
252
+ // Check each abstracted path to see if this path starts with it
253
+ for (const abstractedPath of this.abstractedPaths) {
254
+ if (abstractedPath && path.startsWith(abstractedPath + '/')) {
255
+ return true;
256
+ }
257
+ }
258
+
259
+ return false;
260
+ }
261
+
262
+ get size(): number {
263
+ return this.abstractedPaths.size;
264
+ }
265
+
266
+ has(path: string): boolean {
267
+ return this.abstractedPaths.has(path);
268
+ }
269
+ }
270
+
198
271
  interface HitTestCache {
199
272
  spatialGrid: SpatialGrid;
200
273
  transformParams: {
@@ -206,6 +279,7 @@ interface HitTestCache {
206
279
  zoomOffsetY: number;
207
280
  };
208
281
  abstractedPaths: Set<string>;
282
+ pathLookup: PathHierarchyLookup;
209
283
  timestamp: number;
210
284
  }
211
285
 
@@ -221,6 +295,7 @@ function ArchitectureMapHighlightLayersInner({
221
295
  zoomToPath = null,
222
296
  onZoomComplete,
223
297
  zoomAnimationSpeed = 0.12,
298
+ allowZoomToPath = true,
224
299
  fullSize = false,
225
300
  showGrid = false,
226
301
  showFileNames = false,
@@ -272,22 +347,41 @@ function ArchitectureMapHighlightLayersInner({
272
347
  offsetY: number;
273
348
  } | null>(null);
274
349
 
350
+ // Stable zoom scale - only updates when animation completes or user stops zooming
351
+ // Used for expensive calculations that shouldn't run every frame
352
+ const [stableZoomScale, setStableZoomScale] = useState(1);
353
+ const stableZoomTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
354
+
355
+ // Track if we're currently animating
356
+ const isAnimating = targetZoom !== null;
357
+
275
358
  // Track the last zoomToPath to detect changes
276
359
  const lastZoomToPathRef = useRef<string | null>(null);
277
360
 
361
+ // Throttle ref for hover updates (improves performance with large datasets)
362
+ const lastHoverUpdateRef = useRef<number>(0);
363
+ const HOVER_THROTTLE_MS = 16; // ~60fps max for hover updates
364
+
278
365
  useEffect(() => {
366
+ // Reset user interaction state when enableZoom is disabled
279
367
  if (!enableZoom) {
368
+ setZoomState(prev => ({
369
+ ...prev,
370
+ isDragging: false,
371
+ hasMouseMoved: false,
372
+ }));
373
+ }
374
+ // Only reset zoom position when both user zoom AND programmatic zoom are disabled
375
+ if (!enableZoom && !allowZoomToPath) {
280
376
  setZoomState(prev => ({
281
377
  ...prev,
282
378
  scale: 1,
283
379
  offsetX: 0,
284
380
  offsetY: 0,
285
- isDragging: false,
286
- hasMouseMoved: false,
287
381
  }));
288
382
  setTargetZoom(null);
289
383
  }
290
- }, [enableZoom]);
384
+ }, [enableZoom, allowZoomToPath]);
291
385
 
292
386
  // Animation loop for smooth zoom transitions
293
387
  useEffect(() => {
@@ -332,6 +426,39 @@ function ArchitectureMapHighlightLayersInner({
332
426
  return () => cancelAnimationFrame(frameId);
333
427
  }, [targetZoom, zoomState, zoomAnimationSpeed, onZoomComplete]);
334
428
 
429
+ // Update stable zoom scale with debouncing
430
+ // This ensures expensive calculations only run when zoom stabilizes
431
+ useEffect(() => {
432
+ // Clear any pending timeout
433
+ if (stableZoomTimeoutRef.current) {
434
+ clearTimeout(stableZoomTimeoutRef.current);
435
+ }
436
+
437
+ // If animating, wait for animation to complete
438
+ if (isAnimating) {
439
+ return;
440
+ }
441
+
442
+ // Debounce the stable zoom update (100ms after last zoom change)
443
+ stableZoomTimeoutRef.current = setTimeout(() => {
444
+ setStableZoomScale(zoomState.scale);
445
+ }, 100);
446
+
447
+ return () => {
448
+ if (stableZoomTimeoutRef.current) {
449
+ clearTimeout(stableZoomTimeoutRef.current);
450
+ }
451
+ };
452
+ }, [zoomState.scale, isAnimating]);
453
+
454
+ // Immediately update stable zoom when animation completes
455
+ useEffect(() => {
456
+ if (!isAnimating && targetZoom === null) {
457
+ // Animation just completed, update stable zoom immediately
458
+ setStableZoomScale(zoomState.scale);
459
+ }
460
+ }, [isAnimating, targetZoom, zoomState.scale]);
461
+
335
462
  const [hitTestCache, setHitTestCache] = useState<HitTestCache | null>(null);
336
463
 
337
464
  const calculateCanvasResolution = (
@@ -393,8 +520,8 @@ function ArchitectureMapHighlightLayersInner({
393
520
 
394
521
  // Handle zoomToPath changes - calculate target zoom to frame the specified path
395
522
  useEffect(() => {
396
- // Skip if zoom is not enabled or path hasn't changed
397
- if (!enableZoom || zoomToPath === lastZoomToPathRef.current) {
523
+ // Skip if programmatic zoom is not allowed or path hasn't changed
524
+ if (!allowZoomToPath || zoomToPath === lastZoomToPathRef.current) {
398
525
  return;
399
526
  }
400
527
 
@@ -498,7 +625,7 @@ function ArchitectureMapHighlightLayersInner({
498
625
  });
499
626
  }, [
500
627
  zoomToPath,
501
- enableZoom,
628
+ allowZoomToPath,
502
629
  filteredCityData,
503
630
  canvasSizingData,
504
631
  canvasSize,
@@ -523,21 +650,12 @@ function ArchitectureMapHighlightLayersInner({
523
650
  abstractedPaths: Set<string>,
524
651
  ): HitTestCache => {
525
652
  const spatialGrid = new SpatialGrid(cityData.bounds);
653
+ const pathLookup = new PathHierarchyLookup(abstractedPaths);
526
654
 
527
655
  // Only add visible buildings to spatial grid
656
+ // Use PathHierarchyLookup for O(depth) checks instead of O(A) iteration
528
657
  cityData.buildings.forEach(building => {
529
- // Check if this building is inside any abstracted directory
530
- let currentPath = building.path;
531
- let isAbstracted = false;
532
- while (currentPath.includes('/')) {
533
- currentPath = currentPath.substring(0, currentPath.lastIndexOf('/'));
534
- if (abstractedPaths.has(currentPath)) {
535
- isAbstracted = true;
536
- break;
537
- }
538
- }
539
-
540
- if (!isAbstracted) {
658
+ if (!pathLookup.isPathAbstracted(building.path)) {
541
659
  spatialGrid.addBuilding(building);
542
660
  }
543
661
  });
@@ -552,15 +670,7 @@ function ArchitectureMapHighlightLayersInner({
552
670
  // For hit testing, we need to include abstracted districts too
553
671
  // because they have visual covers that users can click on.
554
672
  // Only skip children of abstracted directories.
555
- let isChildOfAbstracted = false;
556
- for (const abstractedPath of abstractedPaths) {
557
- if (abstractedPath && district.path.startsWith(abstractedPath + '/')) {
558
- isChildOfAbstracted = true;
559
- break;
560
- }
561
- }
562
-
563
- if (!isChildOfAbstracted) {
673
+ if (!pathLookup.isChildOfAbstracted(district.path)) {
564
674
  spatialGrid.addDistrict(district);
565
675
  }
566
676
  });
@@ -576,6 +686,7 @@ function ArchitectureMapHighlightLayersInner({
576
686
  zoomOffsetY: zoomState.offsetY,
577
687
  },
578
688
  abstractedPaths,
689
+ pathLookup,
579
690
  timestamp: Date.now(),
580
691
  };
581
692
  },
@@ -651,8 +762,9 @@ function ArchitectureMapHighlightLayersInner({
651
762
  const config = abstractionLayerDef.abstractionConfig;
652
763
 
653
764
  // Disable abstraction when zoomed in beyond a certain threshold
765
+ // Use stableZoomScale to avoid recalculating during animation
654
766
  const maxZoomForAbstraction = config?.maxZoomLevel ?? 5.0;
655
- if (zoomState.scale > maxZoomForAbstraction) {
767
+ if (stableZoomScale > maxZoomForAbstraction) {
656
768
  return null; // No abstractions when zoomed in
657
769
  }
658
770
 
@@ -675,7 +787,8 @@ function ArchitectureMapHighlightLayersInner({
675
787
  displayOptions.padding,
676
788
  );
677
789
 
678
- const totalScale = scale * zoomState.scale;
790
+ // Use stableZoomScale for abstraction calculation to avoid recalculating during animation
791
+ const totalScale = scale * stableZoomScale;
679
792
 
680
793
  // Get all highlighted paths from enabled layers
681
794
  const highlightedPaths = new Set<string>();
@@ -873,7 +986,7 @@ function ArchitectureMapHighlightLayersInner({
873
986
  filteredCityData,
874
987
  canvasSize,
875
988
  displayOptions.padding,
876
- zoomState.scale,
989
+ stableZoomScale, // Use stable zoom scale instead of live zoomState.scale
877
990
  subdirectoryMode,
878
991
  cityData,
879
992
  allLayersWithoutAbstraction,
@@ -894,7 +1007,75 @@ function ArchitectureMapHighlightLayersInner({
894
1007
  return layers;
895
1008
  }, [stableLayers, dynamicLayers, abstractionLayer]);
896
1009
 
1010
+ // Memoize abstracted paths lookup - only recalculates when abstraction layer changes
1011
+ const { abstractedPathsSet, abstractedPathLookup } = useMemo(() => {
1012
+ const pathsSet = new Set<string>();
1013
+ const abstractionLayerDef = allLayers.find(l => l.id === 'directory-abstraction');
1014
+ if (abstractionLayerDef && abstractionLayerDef.enabled) {
1015
+ abstractionLayerDef.items.forEach(item => {
1016
+ if (item.type === 'directory') {
1017
+ pathsSet.add(item.path);
1018
+ }
1019
+ });
1020
+ }
1021
+ return {
1022
+ abstractedPathsSet: pathsSet,
1023
+ abstractedPathLookup: new PathHierarchyLookup(pathsSet),
1024
+ };
1025
+ }, [allLayers]);
1026
+
1027
+ // Memoize visible districts - only recalculates when data or abstraction changes, NOT on hover
1028
+ const visibleDistrictsMemo = useMemo(() => {
1029
+ if (!filteredCityData) return [];
1030
+
1031
+ let districts =
1032
+ abstractedPathLookup.size > 0
1033
+ ? filteredCityData.districts.filter(district => {
1034
+ // Check for root abstraction first
1035
+ if (abstractedPathLookup.has('')) {
1036
+ return !district.path || district.path === '';
1037
+ }
1038
+ if (!district.path) return true;
1039
+ if (abstractedPathLookup.has(district.path)) return true;
1040
+ return !abstractedPathLookup.isChildOfAbstracted(district.path);
1041
+ })
1042
+ : filteredCityData.districts;
1043
+
1044
+ // If root is abstracted and there's no root district, create one for the cover
1045
+ if (abstractedPathsSet.has('')) {
1046
+ const hasRootDistrict = districts.some(d => !d.path || d.path === '');
1047
+ if (!hasRootDistrict) {
1048
+ districts = [
1049
+ {
1050
+ path: '',
1051
+ worldBounds: filteredCityData.bounds,
1052
+ fileCount: filteredCityData.buildings.length,
1053
+ type: 'directory' as const,
1054
+ },
1055
+ ...districts,
1056
+ ];
1057
+ }
1058
+ }
1059
+
1060
+ return districts;
1061
+ }, [filteredCityData, abstractedPathLookup, abstractedPathsSet]);
1062
+
1063
+ // Memoize visible buildings - only recalculates when data or abstraction changes, NOT on hover
1064
+ const visibleBuildingsMemo = useMemo(() => {
1065
+ if (!filteredCityData) return [];
1066
+
1067
+ return abstractedPathLookup.size > 0
1068
+ ? filteredCityData.buildings.filter(building => {
1069
+ return !abstractedPathLookup.isPathAbstracted(building.path);
1070
+ })
1071
+ : filteredCityData.buildings;
1072
+ }, [filteredCityData, abstractedPathLookup]);
1073
+
897
1074
  // Update hit test cache when geometry or abstraction changes
1075
+ // Note: We don't depend on zoomState here because:
1076
+ // 1. The spatial grid only depends on buildings/districts and abstraction
1077
+ // 2. performHitTest calculates coordinates using the current zoomState directly
1078
+ // 3. This avoids expensive cache rebuilds during zoom animation
898
1079
  useEffect(() => {
899
1080
  if (!filteredCityData) return;
900
1081
 
@@ -917,12 +1098,22 @@ function ArchitectureMapHighlightLayersInner({
917
1098
  });
918
1099
  }
919
1100
 
1101
+ // Pass a stable zoom state for cache metadata (not used for coordinate calculations)
1102
+ const stableZoomState = {
1103
+ scale: stableZoomScale,
1104
+ offsetX: 0,
1105
+ offsetY: 0,
1106
+ isDragging: false,
1107
+ lastMousePos: { x: 0, y: 0 },
1108
+ hasMouseMoved: false,
1109
+ };
1110
+
920
1111
  const newCache = buildHitTestCache(
921
1112
  filteredCityData,
922
1113
  scale,
923
1114
  offsetX,
924
1115
  offsetZ,
925
- zoomState,
1116
+ stableZoomState,
926
1117
  abstractedPaths,
927
1118
  );
928
1119
  setHitTestCache(newCache);
@@ -930,7 +1121,7 @@ function ArchitectureMapHighlightLayersInner({
930
1121
  filteredCityData,
931
1122
  canvasSize,
932
1123
  displayOptions.padding,
933
- zoomState,
1124
+ stableZoomScale, // Only rebuild when zoom stabilizes
934
1125
  buildHitTestCache,
935
1126
  abstractionLayer,
936
1127
  ]);
@@ -983,66 +1174,11 @@ function ArchitectureMapHighlightLayersInner({
983
1174
  zoomState.offsetY,
984
1175
  });
985
1176
 
986
- // Get abstracted paths for filtering child districts
987
- const abstractedPathsForDistricts = new Set<string>();
988
- const abstractionLayerForDistricts = allLayers.find(l => l.id === 'directory-abstraction');
989
- if (abstractionLayerForDistricts && abstractionLayerForDistricts.enabled) {
990
- abstractionLayerForDistricts.items.forEach(item => {
991
- if (item.type === 'directory') {
992
- abstractedPathsForDistricts.add(item.path);
993
- }
994
- });
995
- }
996
-
997
- // Keep abstracted districts (for covers) but filter out their children
998
- let visibleDistricts =
999
- abstractedPathsForDistricts.size > 0
1000
- ? filteredCityData.districts.filter(district => {
1001
- // Check for root abstraction first
1002
- if (abstractedPathsForDistricts.has('')) {
1003
- // If root is abstracted, only show root district
1004
- return !district.path || district.path === '';
1005
- }
1006
-
1007
- if (!district.path) return true; // Keep root
1008
-
1009
- // Keep the abstracted district itself (we need it for the cover)
1010
- if (abstractedPathsForDistricts.has(district.path)) {
1011
- return true;
1012
- }
1013
-
1014
- // Filter out children of abstracted directories
1015
- for (const abstractedPath of abstractedPathsForDistricts) {
1016
- if (district.path.startsWith(abstractedPath + '/')) {
1017
- return false; // Skip child of abstracted directory
1018
- }
1019
- }
1020
-
1021
- return true;
1022
- })
1023
- : filteredCityData.districts;
1024
-
1025
- // If root is abstracted and there's no root district, create one for the cover
1026
- if (abstractedPathsForDistricts.has('')) {
1027
- const hasRootDistrict = visibleDistricts.some(d => !d.path || d.path === '');
1028
-
1029
- if (!hasRootDistrict) {
1030
- visibleDistricts = [
1031
- {
1032
- path: '',
1033
- worldBounds: filteredCityData.bounds,
1034
- fileCount: filteredCityData.buildings.length, // Total file count
1035
- type: 'directory',
1036
- },
1037
- ...visibleDistricts,
1038
- ];
1039
- }
1040
- }
1041
-
1177
+ // Use memoized visible districts and buildings (pre-filtered, doesn't recalculate on hover)
1042
1178
  // Draw districts with layer support
1043
1179
  drawLayeredDistricts(
1044
1180
  ctx,
1045
- visibleDistricts,
1181
+ visibleDistrictsMemo,
1046
1182
  worldToCanvas,
1047
1183
  scale * zoomState.scale,
1048
1184
  allLayers,
@@ -1050,76 +1186,15 @@ function ArchitectureMapHighlightLayersInner({
1050
1186
  fullSize,
1051
1187
  resolvedDefaultDirectoryColor,
1052
1188
  filteredCityData.metadata.layoutConfig,
1053
- abstractedPathsForDistricts, // Pass abstracted paths to skip labels
1189
+ abstractedPathsSet, // Pass abstracted paths to skip labels
1054
1190
  showDirectoryLabels,
1055
1191
  districtBorderRadius,
1056
1192
  );
1057
1193
 
1058
- // Get abstracted directory paths for filtering from the actual layers being rendered
1059
- const abstractedPaths = new Set<string>();
1060
- const activeAbstractionLayer = allLayers.find(l => l.id === 'directory-abstraction');
1061
- if (activeAbstractionLayer && activeAbstractionLayer.enabled) {
1062
- activeAbstractionLayer.items.forEach(item => {
1063
- if (item.type === 'directory') {
1064
- abstractedPaths.add(item.path);
1065
- }
1066
- });
1067
- }
1068
-
1069
- // Filter out buildings that are in abstracted directories
1070
- const visibleBuildings =
1071
- abstractedPaths.size > 0
1072
- ? filteredCityData.buildings.filter(building => {
1073
- // Check for root abstraction first
1074
- if (abstractedPaths.has('')) {
1075
- // If root is abstracted, hide all buildings
1076
- return false;
1077
- }
1078
-
1079
- const buildingDir = building.path.substring(0, building.path.lastIndexOf('/')) || '';
1080
-
1081
- // Simple direct check first
1082
- for (const abstractedPath of abstractedPaths) {
1083
- if (buildingDir === abstractedPath) {
1084
- return false;
1085
- }
1086
- // Also check if building is in a subdirectory of abstracted path
1087
- if (buildingDir.startsWith(abstractedPath + '/')) {
1088
- return false;
1089
- }
1090
- }
1091
-
1092
- return true;
1093
- })
1094
- : filteredCityData.buildings;
1095
-
1096
- // Log only if we see buildings that should have been filtered
1097
- if (abstractedPaths.size > 0) {
1098
- const suspiciousBuildings = visibleBuildings.filter(building => {
1099
- const buildingDir = building.path.substring(0, building.path.lastIndexOf('/')) || '';
1100
- // Check if this building's parent is visually covered
1101
- for (const abstractedPath of abstractedPaths) {
1102
- if (buildingDir === abstractedPath || buildingDir.startsWith(abstractedPath + '/')) {
1103
- return true;
1104
- }
1105
- }
1106
- return false;
1107
- });
1108
-
1109
- if (suspiciousBuildings.length > 0) {
1110
- console.error(
1111
- `[Building Filter] WARNING: ${suspiciousBuildings.length} buildings are being rendered in abstracted directories!`,
1112
- );
1113
- suspiciousBuildings.slice(0, 3).forEach(building => {
1114
- console.error(` - ${building.path}`);
1115
- });
1116
- }
1117
- }
1118
-
1119
1194
  // Draw buildings with layer support
1120
1195
  drawLayeredBuildings(
1121
1196
  ctx,
1122
- visibleBuildings,
1197
+ visibleBuildingsMemo,
1123
1198
  worldToCanvas,
1124
1199
  scale * zoomState.scale,
1125
1200
  allLayers,
@@ -1160,6 +1235,10 @@ function ArchitectureMapHighlightLayersInner({
1160
1235
  resolvedDefaultBuildingColor,
1161
1236
  districtBorderRadius,
1162
1237
  showFileTypeIcons,
1238
+ // Memoized values for performance (don't recalculate on hover)
1239
+ visibleDistrictsMemo,
1240
+ visibleBuildingsMemo,
1241
+ abstractedPathsSet,
1163
1242
  ]);
1164
1243
 
1165
1244
  // Optimized hit testing
@@ -1337,6 +1416,13 @@ function ArchitectureMapHighlightLayersInner({
1337
1416
  if (!canvasRef.current || !containerRef.current || !filteredCityData || zoomState.isDragging)
1338
1417
  return;
1339
1418
 
1419
+ // Throttle hover updates to improve performance with large datasets
1420
+ const now = performance.now();
1421
+ if (now - lastHoverUpdateRef.current < HOVER_THROTTLE_MS) {
1422
+ return;
1423
+ }
1424
+ lastHoverUpdateRef.current = now;
1425
+
1340
1426
  // Get the container rect for mouse position
1341
1427
  const containerRect = containerRef.current.getBoundingClientRect();
1342
1428
  // Get mouse position relative to the container
@@ -311,6 +311,166 @@ export const WithBorderRadius: Story = {
311
311
  },
312
312
  };
313
313
 
314
+ // Story with programmatic zoom only (no user interaction)
315
+ // Demonstrates allowZoomToPath={true} with enableZoom={false}
316
+ export const ProgrammaticZoomOnly: Story = {
317
+ render: function RenderProgrammaticZoomOnly() {
318
+ const [zoomToPath, setZoomToPath] = useState<string | null>(null);
319
+ const [isAnimating, setIsAnimating] = useState(false);
320
+ const cityData = createSampleCityData();
321
+
322
+ // Get unique top-level directories for navigation buttons
323
+ const topLevelDirs = Array.from(
324
+ new Set(
325
+ cityData.districts
326
+ .map(d => d.path.split('/')[0])
327
+ .filter(Boolean),
328
+ ),
329
+ ).sort();
330
+
331
+ const handleZoomTo = (path: string | null) => {
332
+ setIsAnimating(true);
333
+ setZoomToPath(path);
334
+ };
335
+
336
+ const handleZoomComplete = () => {
337
+ setIsAnimating(false);
338
+ };
339
+
340
+ // Create highlight layer for the focused directory
341
+ const highlightLayers: HighlightLayer[] = zoomToPath
342
+ ? [
343
+ {
344
+ id: 'zoom-focus',
345
+ name: 'Zoom Focus',
346
+ enabled: true,
347
+ color: '#10b981',
348
+ priority: 1,
349
+ items: [{ path: zoomToPath, type: 'directory' }],
350
+ },
351
+ ]
352
+ : [];
353
+
354
+ return (
355
+ <div style={{ position: 'relative', width: '100%', height: '100%' }}>
356
+ <ArchitectureMapHighlightLayers
357
+ cityData={cityData}
358
+ fullSize={true}
359
+ enableZoom={false} // User interactions disabled
360
+ allowZoomToPath={true} // But programmatic zoom works (this is the default)
361
+ zoomToPath={zoomToPath}
362
+ onZoomComplete={handleZoomComplete}
363
+ zoomAnimationSpeed={0.1}
364
+ highlightLayers={highlightLayers}
365
+ />
366
+ {/* Navigation Controls */}
367
+ <div
368
+ style={{
369
+ position: 'absolute',
370
+ top: 20,
371
+ left: 20,
372
+ zIndex: 100,
373
+ display: 'flex',
374
+ flexDirection: 'column',
375
+ gap: '8px',
376
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
377
+ padding: '16px',
378
+ borderRadius: '8px',
379
+ maxWidth: '220px',
380
+ }}
381
+ >
382
+ <div
383
+ style={{
384
+ color: '#10b981',
385
+ fontFamily: 'monospace',
386
+ fontSize: '12px',
387
+ fontWeight: 'bold',
388
+ marginBottom: '4px',
389
+ }}
390
+ >
391
+ Programmatic Zoom Only
392
+ </div>
393
+ <div
394
+ style={{
395
+ color: '#9ca3af',
396
+ fontFamily: 'monospace',
397
+ fontSize: '10px',
398
+ marginBottom: '8px',
399
+ }}
400
+ >
401
+ enableZoom=false, allowZoomToPath=true
402
+ <br />
403
+ (Try scroll/drag - they won't work)
404
+ </div>
405
+
406
+ {/* Reset button */}
407
+ <button
408
+ onClick={() => handleZoomTo(null)}
409
+ disabled={isAnimating}
410
+ style={{
411
+ padding: '8px 12px',
412
+ backgroundColor: zoomToPath === null ? '#10b981' : '#374151',
413
+ color: 'white',
414
+ border: 'none',
415
+ borderRadius: '4px',
416
+ cursor: isAnimating ? 'not-allowed' : 'pointer',
417
+ fontFamily: 'monospace',
418
+ fontSize: '12px',
419
+ opacity: isAnimating ? 0.6 : 1,
420
+ }}
421
+ >
422
+ Reset View
423
+ </button>
424
+
425
+ {/* Directory buttons */}
426
+ {topLevelDirs.map(dir => (
427
+ <button
428
+ key={dir}
429
+ onClick={() => handleZoomTo(dir)}
430
+ disabled={isAnimating}
431
+ style={{
432
+ padding: '8px 12px',
433
+ backgroundColor: zoomToPath === dir ? '#10b981' : '#374151',
434
+ color: 'white',
435
+ border: 'none',
436
+ borderRadius: '4px',
437
+ cursor: isAnimating ? 'not-allowed' : 'pointer',
438
+ fontFamily: 'monospace',
439
+ fontSize: '12px',
440
+ textAlign: 'left',
441
+ opacity: isAnimating ? 0.6 : 1,
442
+ }}
443
+ >
444
+ {dir}
445
+ </button>
446
+ ))}
447
+ </div>
448
+
449
+ {/* Status info */}
450
+ <div
451
+ style={{
452
+ position: 'absolute',
453
+ bottom: 20,
454
+ left: 20,
455
+ zIndex: 100,
456
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
457
+ padding: '12px',
458
+ borderRadius: '8px',
459
+ color: 'white',
460
+ fontFamily: 'monospace',
461
+ fontSize: '11px',
462
+ }}
463
+ >
464
+ <div>Zoomed to: {zoomToPath || '(root)'}</div>
465
+ <div style={{ color: '#9ca3af', marginTop: '4px' }}>
466
+ {isAnimating ? 'Animating...' : 'Use buttons to navigate'}
467
+ </div>
468
+ </div>
469
+ </div>
470
+ );
471
+ },
472
+ };
473
+
314
474
  // Story with animated zoom to directory
315
475
  export const AnimatedZoomToDirectory: Story = {
316
476
  render: function RenderAnimatedZoom() {