@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.
- package/dist/components/ArchitectureMapHighlightLayers.d.ts +2 -1
- package/dist/components/ArchitectureMapHighlightLayers.d.ts.map +1 -1
- package/dist/components/ArchitectureMapHighlightLayers.js +144 -88
- package/package.json +1 -1
- package/src/components/ArchitectureMapHighlightLayers.tsx +167 -98
- package/src/stories/ArchitectureMapHighlightLayers.stories.tsx +160 -0
|
@@ -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":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAC;AAQjF,OAAO,EAIL,cAAc,EAEf,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EACL,QAAQ,EACR,YAAY,EACZ,YAAY,EACZ,sBAAsB,EACvB,MAAM,iCAAiC,CAAC;AAWzC,MAAM,WAAW,mCAAmC;IAElD,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAGpB,eAAe,CAAC,EAAE,cAAc,EAAE,CAAC;IACnC,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IAC5D,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAG9B,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACvD,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,KAAK,IAAI,CAAC;IACjE,UAAU,CAAC,EAAE,OAAO,CAAC;IAGrB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B,kBAAkB,CAAC,EAAE,MAAM,CAAC;
|
|
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
|
-
|
|
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
|
|
236
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
778
|
+
let visibleDistricts = pathLookup.size > 0
|
|
675
779
|
? filteredCityData.districts.filter(district => {
|
|
676
780
|
// Check for root abstraction first
|
|
677
|
-
if (
|
|
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 (
|
|
788
|
+
if (pathLookup.has(district.path)) {
|
|
685
789
|
return true;
|
|
686
790
|
}
|
|
687
|
-
// Filter out children of abstracted directories
|
|
688
|
-
|
|
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
|
-
|
|
814
|
+
// Reuse the pathLookup created earlier for O(depth) containment checks
|
|
815
|
+
const visibleBuildings = pathLookup.size > 0
|
|
726
816
|
? filteredCityData.buildings.filter(building => {
|
|
727
|
-
//
|
|
728
|
-
|
|
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
|
@@ -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
|
|
397
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1125
|
+
pathLookup.size > 0
|
|
1000
1126
|
? filteredCityData.districts.filter(district => {
|
|
1001
1127
|
// Check for root abstraction first
|
|
1002
|
-
if (
|
|
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 (
|
|
1136
|
+
if (pathLookup.has(district.path)) {
|
|
1011
1137
|
return true;
|
|
1012
1138
|
}
|
|
1013
1139
|
|
|
1014
|
-
// Filter out children of abstracted directories
|
|
1015
|
-
|
|
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
|
-
|
|
1181
|
+
pathLookup.size > 0
|
|
1072
1182
|
? filteredCityData.buildings.filter(building => {
|
|
1073
|
-
//
|
|
1074
|
-
|
|
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() {
|