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