@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.
- package/dist/components/ArchitectureMapHighlightLayers.d.ts +2 -1
- package/dist/components/ArchitectureMapHighlightLayers.d.ts.map +1 -1
- package/dist/components/ArchitectureMapHighlightLayers.js +209 -132
- package/package.json +1 -1
- package/src/components/ArchitectureMapHighlightLayers.tsx +238 -152
- 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,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
|
|
236
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|
|
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,
|
|
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
|
@@ -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
|
|
397
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|