@principal-ai/file-city-react 0.4.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.
@@ -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,34 @@ 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);
147
213
  (0, react_1.useEffect)(() => {
214
+ // Reset user interaction state when enableZoom is disabled
148
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) {
149
224
  setZoomState(prev => ({
150
225
  ...prev,
151
226
  scale: 1,
152
227
  offsetX: 0,
153
228
  offsetY: 0,
154
- isDragging: false,
155
- hasMouseMoved: false,
156
229
  }));
157
230
  setTargetZoom(null);
158
231
  }
159
- }, [enableZoom]);
232
+ }, [enableZoom, allowZoomToPath]);
160
233
  // Animation loop for smooth zoom transitions
161
234
  (0, react_1.useEffect)(() => {
162
235
  if (!targetZoom)
@@ -194,6 +267,34 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
194
267
  const frameId = requestAnimationFrame(animate);
195
268
  return () => cancelAnimationFrame(frameId);
196
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);
296
+ }
297
+ }, [isAnimating, targetZoom, zoomState.scale]);
197
298
  const [hitTestCache, setHitTestCache] = (0, react_1.useState)(null);
198
299
  const calculateCanvasResolution = (fileCount, _cityBounds) => {
199
300
  const minSize = 400;
@@ -232,8 +333,8 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
232
333
  }, [subdirectoryMode?.enabled, subdirectoryMode?.autoCenter, cityData, filteredCityData]);
233
334
  // Handle zoomToPath changes - calculate target zoom to frame the specified path
234
335
  (0, react_1.useEffect)(() => {
235
- // Skip if zoom is not enabled or path hasn't changed
236
- if (!enableZoom || zoomToPath === lastZoomToPathRef.current) {
336
+ // Skip if programmatic zoom is not allowed or path hasn't changed
337
+ if (!allowZoomToPath || zoomToPath === lastZoomToPathRef.current) {
237
338
  return;
238
339
  }
239
340
  lastZoomToPathRef.current = zoomToPath;
@@ -314,7 +415,7 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
314
415
  });
315
416
  }, [
316
417
  zoomToPath,
317
- enableZoom,
418
+ allowZoomToPath,
318
419
  filteredCityData,
319
420
  canvasSizingData,
320
421
  canvasSize,
@@ -323,19 +424,11 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
323
424
  // Build hit test cache with spatial indexing
324
425
  const buildHitTestCache = (0, react_1.useCallback)((cityData, scale, offsetX, offsetZ, zoomState, abstractedPaths) => {
325
426
  const spatialGrid = new SpatialGrid(cityData.bounds);
427
+ const pathLookup = new PathHierarchyLookup(abstractedPaths);
326
428
  // Only add visible buildings to spatial grid
429
+ // Use PathHierarchyLookup for O(depth) checks instead of O(A) iteration
327
430
  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) {
431
+ if (!pathLookup.isPathAbstracted(building.path)) {
339
432
  spatialGrid.addBuilding(building);
340
433
  }
341
434
  });
@@ -348,14 +441,7 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
348
441
  // For hit testing, we need to include abstracted districts too
349
442
  // because they have visual covers that users can click on.
350
443
  // 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) {
444
+ if (!pathLookup.isChildOfAbstracted(district.path)) {
359
445
  spatialGrid.addDistrict(district);
360
446
  }
361
447
  });
@@ -370,6 +456,7 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
370
456
  zoomOffsetY: zoomState.offsetY,
371
457
  },
372
458
  abstractedPaths,
459
+ pathLookup,
373
460
  timestamp: Date.now(),
374
461
  };
375
462
  }, []);
@@ -394,8 +481,9 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
394
481
  }
395
482
  const config = abstractionLayerDef.abstractionConfig;
396
483
  // Disable abstraction when zoomed in beyond a certain threshold
484
+ // Use stableZoomScale to avoid recalculating during animation
397
485
  const maxZoomForAbstraction = config?.maxZoomLevel ?? 5.0;
398
- if (zoomState.scale > maxZoomForAbstraction) {
486
+ if (stableZoomScale > maxZoomForAbstraction) {
399
487
  return null; // No abstractions when zoomed in
400
488
  }
401
489
  // Calculate which directories are too small at current zoom
@@ -407,7 +495,8 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
407
495
  ? cityData
408
496
  : filteredCityData;
409
497
  const { scale } = calculateScaleAndOffset(coordinateSystemData, displayWidth, displayHeight, displayOptions.padding);
410
- const totalScale = scale * zoomState.scale;
498
+ // Use stableZoomScale for abstraction calculation to avoid recalculating during animation
499
+ const totalScale = scale * stableZoomScale;
411
500
  // Get all highlighted paths from enabled layers
412
501
  const highlightedPaths = new Set();
413
502
  allLayersWithoutAbstraction.forEach(layer => {
@@ -587,7 +676,7 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
587
676
  filteredCityData,
588
677
  canvasSize,
589
678
  displayOptions.padding,
590
- zoomState.scale,
679
+ stableZoomScale, // Use stable zoom scale instead of live zoomState.scale
591
680
  subdirectoryMode,
592
681
  cityData,
593
682
  allLayersWithoutAbstraction,
@@ -605,6 +694,10 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
605
694
  return layers;
606
695
  }, [stableLayers, dynamicLayers, abstractionLayer]);
607
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
608
701
  (0, react_1.useEffect)(() => {
609
702
  if (!filteredCityData)
610
703
  return;
@@ -620,13 +713,22 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
620
713
  }
621
714
  });
622
715
  }
623
- 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);
624
726
  setHitTestCache(newCache);
625
727
  }, [
626
728
  filteredCityData,
627
729
  canvasSize,
628
730
  displayOptions.padding,
629
- zoomState,
731
+ stableZoomScale, // Only rebuild when zoom stabilizes
630
732
  buildHitTestCache,
631
733
  abstractionLayer,
632
734
  ]);
@@ -670,27 +772,24 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
670
772
  }
671
773
  });
672
774
  }
775
+ // Create PathHierarchyLookup for O(depth) containment checks
776
+ const pathLookup = new PathHierarchyLookup(abstractedPathsForDistricts);
673
777
  // Keep abstracted districts (for covers) but filter out their children
674
- let visibleDistricts = abstractedPathsForDistricts.size > 0
778
+ let visibleDistricts = pathLookup.size > 0
675
779
  ? filteredCityData.districts.filter(district => {
676
780
  // Check for root abstraction first
677
- if (abstractedPathsForDistricts.has('')) {
781
+ if (pathLookup.has('')) {
678
782
  // If root is abstracted, only show root district
679
783
  return !district.path || district.path === '';
680
784
  }
681
785
  if (!district.path)
682
786
  return true; // Keep root
683
787
  // Keep the abstracted district itself (we need it for the cover)
684
- if (abstractedPathsForDistricts.has(district.path)) {
788
+ if (pathLookup.has(district.path)) {
685
789
  return true;
686
790
  }
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;
791
+ // Filter out children of abstracted directories using O(depth) lookup
792
+ return !pathLookup.isChildOfAbstracted(district.path);
694
793
  })
695
794
  : filteredCityData.districts;
696
795
  // If root is abstracted and there's no root district, create one for the cover
@@ -711,57 +810,14 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
711
810
  // Draw districts with layer support
712
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
713
812
  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
813
  // Filter out buildings that are in abstracted directories
725
- const visibleBuildings = abstractedPaths.size > 0
814
+ // Reuse the pathLookup created earlier for O(depth) containment checks
815
+ const visibleBuildings = pathLookup.size > 0
726
816
  ? 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;
817
+ // Use PathHierarchyLookup for efficient O(depth) check
818
+ return !pathLookup.isPathAbstracted(building.path);
744
819
  })
745
820
  : 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
821
  // Draw buildings with layer support
766
822
  (0, drawLayeredBuildings_1.drawLayeredBuildings)(ctx, visibleBuildings, worldToCanvas, scale * zoomState.scale, allLayers, interactionState.hoveredBuilding, resolvedDefaultBuildingColor, showFileNames, resolvedHoverBorderColor, disableOpacityDimming, showFileTypeIcons, buildingBorderRadius);
767
823
  // Performance monitoring end available for debugging
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.1",
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,37 @@ 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
 
278
361
  useEffect(() => {
362
+ // Reset user interaction state when enableZoom is disabled
279
363
  if (!enableZoom) {
364
+ setZoomState(prev => ({
365
+ ...prev,
366
+ isDragging: false,
367
+ hasMouseMoved: false,
368
+ }));
369
+ }
370
+ // Only reset zoom position when both user zoom AND programmatic zoom are disabled
371
+ if (!enableZoom && !allowZoomToPath) {
280
372
  setZoomState(prev => ({
281
373
  ...prev,
282
374
  scale: 1,
283
375
  offsetX: 0,
284
376
  offsetY: 0,
285
- isDragging: false,
286
- hasMouseMoved: false,
287
377
  }));
288
378
  setTargetZoom(null);
289
379
  }
290
- }, [enableZoom]);
380
+ }, [enableZoom, allowZoomToPath]);
291
381
 
292
382
  // Animation loop for smooth zoom transitions
293
383
  useEffect(() => {
@@ -332,6 +422,39 @@ function ArchitectureMapHighlightLayersInner({
332
422
  return () => cancelAnimationFrame(frameId);
333
423
  }, [targetZoom, zoomState, zoomAnimationSpeed, onZoomComplete]);
334
424
 
425
+ // Update stable zoom scale with debouncing
426
+ // This ensures expensive calculations only run when zoom stabilizes
427
+ useEffect(() => {
428
+ // Clear any pending timeout
429
+ if (stableZoomTimeoutRef.current) {
430
+ clearTimeout(stableZoomTimeoutRef.current);
431
+ }
432
+
433
+ // If animating, wait for animation to complete
434
+ if (isAnimating) {
435
+ return;
436
+ }
437
+
438
+ // Debounce the stable zoom update (100ms after last zoom change)
439
+ stableZoomTimeoutRef.current = setTimeout(() => {
440
+ setStableZoomScale(zoomState.scale);
441
+ }, 100);
442
+
443
+ return () => {
444
+ if (stableZoomTimeoutRef.current) {
445
+ clearTimeout(stableZoomTimeoutRef.current);
446
+ }
447
+ };
448
+ }, [zoomState.scale, isAnimating]);
449
+
450
+ // Immediately update stable zoom when animation completes
451
+ useEffect(() => {
452
+ if (!isAnimating && targetZoom === null) {
453
+ // Animation just completed, update stable zoom immediately
454
+ setStableZoomScale(zoomState.scale);
455
+ }
456
+ }, [isAnimating, targetZoom, zoomState.scale]);
457
+
335
458
  const [hitTestCache, setHitTestCache] = useState<HitTestCache | null>(null);
336
459
 
337
460
  const calculateCanvasResolution = (
@@ -393,8 +516,8 @@ function ArchitectureMapHighlightLayersInner({
393
516
 
394
517
  // Handle zoomToPath changes - calculate target zoom to frame the specified path
395
518
  useEffect(() => {
396
- // Skip if zoom is not enabled or path hasn't changed
397
- if (!enableZoom || zoomToPath === lastZoomToPathRef.current) {
519
+ // Skip if programmatic zoom is not allowed or path hasn't changed
520
+ if (!allowZoomToPath || zoomToPath === lastZoomToPathRef.current) {
398
521
  return;
399
522
  }
400
523
 
@@ -498,7 +621,7 @@ function ArchitectureMapHighlightLayersInner({
498
621
  });
499
622
  }, [
500
623
  zoomToPath,
501
- enableZoom,
624
+ allowZoomToPath,
502
625
  filteredCityData,
503
626
  canvasSizingData,
504
627
  canvasSize,
@@ -523,21 +646,12 @@ function ArchitectureMapHighlightLayersInner({
523
646
  abstractedPaths: Set<string>,
524
647
  ): HitTestCache => {
525
648
  const spatialGrid = new SpatialGrid(cityData.bounds);
649
+ const pathLookup = new PathHierarchyLookup(abstractedPaths);
526
650
 
527
651
  // Only add visible buildings to spatial grid
652
+ // Use PathHierarchyLookup for O(depth) checks instead of O(A) iteration
528
653
  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) {
654
+ if (!pathLookup.isPathAbstracted(building.path)) {
541
655
  spatialGrid.addBuilding(building);
542
656
  }
543
657
  });
@@ -552,15 +666,7 @@ function ArchitectureMapHighlightLayersInner({
552
666
  // For hit testing, we need to include abstracted districts too
553
667
  // because they have visual covers that users can click on.
554
668
  // 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) {
669
+ if (!pathLookup.isChildOfAbstracted(district.path)) {
564
670
  spatialGrid.addDistrict(district);
565
671
  }
566
672
  });
@@ -576,6 +682,7 @@ function ArchitectureMapHighlightLayersInner({
576
682
  zoomOffsetY: zoomState.offsetY,
577
683
  },
578
684
  abstractedPaths,
685
+ pathLookup,
579
686
  timestamp: Date.now(),
580
687
  };
581
688
  },
@@ -651,8 +758,9 @@ function ArchitectureMapHighlightLayersInner({
651
758
  const config = abstractionLayerDef.abstractionConfig;
652
759
 
653
760
  // Disable abstraction when zoomed in beyond a certain threshold
761
+ // Use stableZoomScale to avoid recalculating during animation
654
762
  const maxZoomForAbstraction = config?.maxZoomLevel ?? 5.0;
655
- if (zoomState.scale > maxZoomForAbstraction) {
763
+ if (stableZoomScale > maxZoomForAbstraction) {
656
764
  return null; // No abstractions when zoomed in
657
765
  }
658
766
 
@@ -675,7 +783,8 @@ function ArchitectureMapHighlightLayersInner({
675
783
  displayOptions.padding,
676
784
  );
677
785
 
678
- const totalScale = scale * zoomState.scale;
786
+ // Use stableZoomScale for abstraction calculation to avoid recalculating during animation
787
+ const totalScale = scale * stableZoomScale;
679
788
 
680
789
  // Get all highlighted paths from enabled layers
681
790
  const highlightedPaths = new Set<string>();
@@ -873,7 +982,7 @@ function ArchitectureMapHighlightLayersInner({
873
982
  filteredCityData,
874
983
  canvasSize,
875
984
  displayOptions.padding,
876
- zoomState.scale,
985
+ stableZoomScale, // Use stable zoom scale instead of live zoomState.scale
877
986
  subdirectoryMode,
878
987
  cityData,
879
988
  allLayersWithoutAbstraction,
@@ -895,6 +1004,10 @@ function ArchitectureMapHighlightLayersInner({
895
1004
  }, [stableLayers, dynamicLayers, abstractionLayer]);
896
1005
 
897
1006
  // Update hit test cache when geometry or abstraction changes
1007
+ // Note: We don't depend on zoomState here because:
1008
+ // 1. The spatial grid only depends on buildings/districts and abstraction
1009
+ // 2. performHitTest calculates coordinates using the current zoomState directly
1010
+ // 3. This avoids expensive cache rebuilds during zoom animation
898
1011
  useEffect(() => {
899
1012
  if (!filteredCityData) return;
900
1013
 
@@ -917,12 +1030,22 @@ function ArchitectureMapHighlightLayersInner({
917
1030
  });
918
1031
  }
919
1032
 
1033
+ // Pass a stable zoom state for cache metadata (not used for coordinate calculations)
1034
+ const stableZoomState = {
1035
+ scale: stableZoomScale,
1036
+ offsetX: 0,
1037
+ offsetY: 0,
1038
+ isDragging: false,
1039
+ lastMousePos: { x: 0, y: 0 },
1040
+ hasMouseMoved: false,
1041
+ };
1042
+
920
1043
  const newCache = buildHitTestCache(
921
1044
  filteredCityData,
922
1045
  scale,
923
1046
  offsetX,
924
1047
  offsetZ,
925
- zoomState,
1048
+ stableZoomState,
926
1049
  abstractedPaths,
927
1050
  );
928
1051
  setHitTestCache(newCache);
@@ -930,7 +1053,7 @@ function ArchitectureMapHighlightLayersInner({
930
1053
  filteredCityData,
931
1054
  canvasSize,
932
1055
  displayOptions.padding,
933
- zoomState,
1056
+ stableZoomScale, // Only rebuild when zoom stabilizes
934
1057
  buildHitTestCache,
935
1058
  abstractionLayer,
936
1059
  ]);
@@ -994,12 +1117,15 @@ function ArchitectureMapHighlightLayersInner({
994
1117
  });
995
1118
  }
996
1119
 
1120
+ // Create PathHierarchyLookup for O(depth) containment checks
1121
+ const pathLookup = new PathHierarchyLookup(abstractedPathsForDistricts);
1122
+
997
1123
  // Keep abstracted districts (for covers) but filter out their children
998
1124
  let visibleDistricts =
999
- abstractedPathsForDistricts.size > 0
1125
+ pathLookup.size > 0
1000
1126
  ? filteredCityData.districts.filter(district => {
1001
1127
  // Check for root abstraction first
1002
- if (abstractedPathsForDistricts.has('')) {
1128
+ if (pathLookup.has('')) {
1003
1129
  // If root is abstracted, only show root district
1004
1130
  return !district.path || district.path === '';
1005
1131
  }
@@ -1007,18 +1133,12 @@ function ArchitectureMapHighlightLayersInner({
1007
1133
  if (!district.path) return true; // Keep root
1008
1134
 
1009
1135
  // Keep the abstracted district itself (we need it for the cover)
1010
- if (abstractedPathsForDistricts.has(district.path)) {
1136
+ if (pathLookup.has(district.path)) {
1011
1137
  return true;
1012
1138
  }
1013
1139
 
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;
1140
+ // Filter out children of abstracted directories using O(depth) lookup
1141
+ return !pathLookup.isChildOfAbstracted(district.path);
1022
1142
  })
1023
1143
  : filteredCityData.districts;
1024
1144
 
@@ -1055,67 +1175,16 @@ function ArchitectureMapHighlightLayersInner({
1055
1175
  districtBorderRadius,
1056
1176
  );
1057
1177
 
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
1178
  // Filter out buildings that are in abstracted directories
1179
+ // Reuse the pathLookup created earlier for O(depth) containment checks
1070
1180
  const visibleBuildings =
1071
- abstractedPaths.size > 0
1181
+ pathLookup.size > 0
1072
1182
  ? 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;
1183
+ // Use PathHierarchyLookup for efficient O(depth) check
1184
+ return !pathLookup.isPathAbstracted(building.path);
1093
1185
  })
1094
1186
  : filteredCityData.buildings;
1095
1187
 
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
1188
  // Draw buildings with layer support
1120
1189
  drawLayeredBuildings(
1121
1190
  ctx,
@@ -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() {