@principal-ai/file-city-react 0.3.0

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.
Files changed (51) hide show
  1. package/dist/builder/cityDataUtils.d.ts +15 -0
  2. package/dist/builder/cityDataUtils.d.ts.map +1 -0
  3. package/dist/builder/cityDataUtils.js +348 -0
  4. package/dist/components/ArchitectureMapHighlightLayers.d.ts +63 -0
  5. package/dist/components/ArchitectureMapHighlightLayers.d.ts.map +1 -0
  6. package/dist/components/ArchitectureMapHighlightLayers.js +1040 -0
  7. package/dist/components/CityViewWithReactFlow.d.ts +14 -0
  8. package/dist/components/CityViewWithReactFlow.d.ts.map +1 -0
  9. package/dist/components/CityViewWithReactFlow.js +266 -0
  10. package/dist/config/files.json +996 -0
  11. package/dist/hooks/useCodeCityData.d.ts +21 -0
  12. package/dist/hooks/useCodeCityData.d.ts.map +1 -0
  13. package/dist/hooks/useCodeCityData.js +57 -0
  14. package/dist/index.d.ts +14 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +29 -0
  17. package/dist/render/client/drawLayeredBuildings.d.ts +51 -0
  18. package/dist/render/client/drawLayeredBuildings.d.ts.map +1 -0
  19. package/dist/render/client/drawLayeredBuildings.js +650 -0
  20. package/dist/stories/ArchitectureMapGridLayout.stories.d.ts +73 -0
  21. package/dist/stories/ArchitectureMapGridLayout.stories.d.ts.map +1 -0
  22. package/dist/stories/ArchitectureMapGridLayout.stories.js +345 -0
  23. package/dist/stories/ArchitectureMapHighlightLayers.stories.d.ts +78 -0
  24. package/dist/stories/ArchitectureMapHighlightLayers.stories.d.ts.map +1 -0
  25. package/dist/stories/ArchitectureMapHighlightLayers.stories.js +270 -0
  26. package/dist/stories/CityViewWithReactFlow.stories.d.ts +24 -0
  27. package/dist/stories/CityViewWithReactFlow.stories.d.ts.map +1 -0
  28. package/dist/stories/CityViewWithReactFlow.stories.js +778 -0
  29. package/dist/stories/sample-data.d.ts +4 -0
  30. package/dist/stories/sample-data.d.ts.map +1 -0
  31. package/dist/stories/sample-data.js +268 -0
  32. package/dist/types/react-types.d.ts +17 -0
  33. package/dist/types/react-types.d.ts.map +1 -0
  34. package/dist/types/react-types.js +4 -0
  35. package/dist/utils/fileColorHighlightLayers.d.ts +86 -0
  36. package/dist/utils/fileColorHighlightLayers.d.ts.map +1 -0
  37. package/dist/utils/fileColorHighlightLayers.js +283 -0
  38. package/package.json +49 -0
  39. package/src/builder/cityDataUtils.ts +430 -0
  40. package/src/components/ArchitectureMapHighlightLayers.tsx +1518 -0
  41. package/src/components/CityViewWithReactFlow.tsx +365 -0
  42. package/src/config/files.json +996 -0
  43. package/src/hooks/useCodeCityData.ts +82 -0
  44. package/src/index.ts +64 -0
  45. package/src/render/client/drawLayeredBuildings.ts +946 -0
  46. package/src/stories/ArchitectureMapGridLayout.stories.tsx +410 -0
  47. package/src/stories/ArchitectureMapHighlightLayers.stories.tsx +312 -0
  48. package/src/stories/CityViewWithReactFlow.stories.tsx +787 -0
  49. package/src/stories/sample-data.ts +301 -0
  50. package/src/types/react-types.ts +18 -0
  51. package/src/utils/fileColorHighlightLayers.ts +378 -0
@@ -0,0 +1,1518 @@
1
+ import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
2
+ import { useTheme } from '@principal-ade/industry-theme';
3
+
4
+ import {
5
+ filterCityDataForSelectiveRender,
6
+ filterCityDataForSubdirectory,
7
+ filterCityDataForMultipleDirectories,
8
+ } from '../builder/cityDataUtils';
9
+ import {
10
+ drawLayeredBuildings,
11
+ drawLayeredDistricts,
12
+ drawGrid,
13
+ HighlightLayer,
14
+ LayerItem,
15
+ } from '../render/client/drawLayeredBuildings';
16
+ import {
17
+ CityData,
18
+ CityBuilding,
19
+ CityDistrict,
20
+ SelectiveRenderOptions,
21
+ } from '@principal-ai/file-city-builder';
22
+ import { MapInteractionState, MapDisplayOptions } from '../types/react-types';
23
+
24
+ const DEFAULT_DISPLAY_OPTIONS: MapDisplayOptions = {
25
+ showGrid: false,
26
+ showConnections: true,
27
+ maxConnections: 20,
28
+ gridSize: 50,
29
+ padding: 20,
30
+ };
31
+
32
+ export interface ArchitectureMapHighlightLayersProps {
33
+ // Core data
34
+ cityData?: CityData;
35
+
36
+ // Layer-based highlighting instead of path sets
37
+ highlightLayers?: HighlightLayer[];
38
+ onLayerToggle?: (layerId: string, enabled: boolean) => void;
39
+ showLayerControls?: boolean;
40
+ defaultBuildingColor?: string;
41
+
42
+ // Navigation and interaction
43
+ focusDirectory?: string | null;
44
+ rootDirectoryName?: string;
45
+ onDirectorySelect?: (directory: string | null) => void;
46
+ onFileClick?: (path: string, type: 'file' | 'directory') => void;
47
+ enableZoom?: boolean;
48
+
49
+ // Display options
50
+ fullSize?: boolean;
51
+ showGrid?: boolean;
52
+ showFileNames?: boolean;
53
+ className?: string;
54
+
55
+ // Selective rendering
56
+ selectiveRender?: SelectiveRenderOptions;
57
+
58
+ // Canvas appearance
59
+ canvasBackgroundColor?: string;
60
+
61
+ // Additional styling options
62
+ hoverBorderColor?: string;
63
+ disableOpacityDimming?: boolean;
64
+ defaultDirectoryColor?: string;
65
+
66
+ // Subdirectory mode
67
+ subdirectoryMode?: {
68
+ enabled?: boolean;
69
+ rootPath?: string;
70
+ autoCenter?: boolean;
71
+ filters?: Array<{ path: string; mode: 'include' | 'exclude' }>;
72
+ combineMode?: 'union' | 'intersection';
73
+ } | null;
74
+
75
+ // Additional display options
76
+ showFileTypeIcons?: boolean;
77
+ showDirectoryLabels?: boolean; // Control visibility of directory labels
78
+
79
+ // Transformation options
80
+ transform?: {
81
+ rotation?: 0 | 90 | 180 | 270; // Rotation in degrees
82
+ flipHorizontal?: boolean; // Mirror along vertical axis
83
+ flipVertical?: boolean; // Mirror along horizontal axis
84
+ };
85
+
86
+ onHover?: (info: {
87
+ hoveredDistrict: CityDistrict | null;
88
+ hoveredBuilding: CityBuilding | null;
89
+ mousePos: { x: number; y: number };
90
+ fileTooltip: { text: string } | null;
91
+ directoryTooltip: { text: string } | null;
92
+ fileCount: number | null;
93
+ }) => void;
94
+
95
+ // Border radius configuration
96
+ buildingBorderRadius?: number; // Border radius for buildings (files), default: 0 (sharp corners)
97
+ districtBorderRadius?: number; // Border radius for districts (directories), default: 0 (sharp corners)
98
+ }
99
+
100
+ // Spatial Grid for fast hit testing (copied from original for now)
101
+ class SpatialGrid {
102
+ private cellSize: number;
103
+ private grid: Map<string, (CityBuilding | CityDistrict)[]> = new Map();
104
+ private bounds: { minX: number; maxX: number; minZ: number; maxZ: number };
105
+
106
+ constructor(
107
+ bounds: { minX: number; maxX: number; minZ: number; maxZ: number },
108
+ cellSize: number = 100,
109
+ ) {
110
+ this.bounds = bounds;
111
+ this.cellSize = cellSize;
112
+ }
113
+
114
+ private getCellsForBounds(minX: number, maxX: number, minZ: number, maxZ: number): string[] {
115
+ const cells: string[] = [];
116
+ const startCellX = Math.floor((minX - this.bounds.minX) / this.cellSize);
117
+ const endCellX = Math.floor((maxX - this.bounds.minX) / this.cellSize);
118
+ const startCellZ = Math.floor((minZ - this.bounds.minZ) / this.cellSize);
119
+ const endCellZ = Math.floor((maxZ - this.bounds.minZ) / this.cellSize);
120
+
121
+ for (let x = startCellX; x <= endCellX; x++) {
122
+ for (let z = startCellZ; z <= endCellZ; z++) {
123
+ cells.push(`${x},${z}`);
124
+ }
125
+ }
126
+ return cells;
127
+ }
128
+
129
+ addBuilding(building: CityBuilding): void {
130
+ const size = Math.max(building.dimensions[0], building.dimensions[2]);
131
+ const halfSize = size / 2;
132
+ const minX = building.position.x - halfSize;
133
+ const maxX = building.position.x + halfSize;
134
+ const minZ = building.position.z - halfSize;
135
+ const maxZ = building.position.z + halfSize;
136
+
137
+ const cells = this.getCellsForBounds(minX, maxX, minZ, maxZ);
138
+ cells.forEach(cellKey => {
139
+ if (!this.grid.has(cellKey)) {
140
+ this.grid.set(cellKey, []);
141
+ }
142
+ const cellItems = this.grid.get(cellKey);
143
+ if (cellItems) {
144
+ cellItems.push(building);
145
+ }
146
+ });
147
+ }
148
+
149
+ addDistrict(district: CityDistrict): void {
150
+ const cells = this.getCellsForBounds(
151
+ district.worldBounds.minX,
152
+ district.worldBounds.maxX,
153
+ district.worldBounds.minZ,
154
+ district.worldBounds.maxZ,
155
+ );
156
+ cells.forEach(cellKey => {
157
+ if (!this.grid.has(cellKey)) {
158
+ this.grid.set(cellKey, []);
159
+ }
160
+ const cellItems = this.grid.get(cellKey);
161
+ if (cellItems) {
162
+ cellItems.push(district);
163
+ }
164
+ });
165
+ }
166
+
167
+ query(x: number, z: number, radius: number = 10): (CityBuilding | CityDistrict)[] {
168
+ const cells = this.getCellsForBounds(x - radius, x + radius, z - radius, z + radius);
169
+ const results: (CityBuilding | CityDistrict)[] = [];
170
+ const seen = new Set<string>();
171
+
172
+ cells.forEach(cellKey => {
173
+ const items = this.grid.get(cellKey);
174
+ if (items) {
175
+ items.forEach(item => {
176
+ const key = 'dimensions' in item ? `b_${item.path}` : `d_${item.path}`;
177
+ if (!seen.has(key)) {
178
+ seen.add(key);
179
+ results.push(item);
180
+ }
181
+ });
182
+ }
183
+ });
184
+
185
+ return results;
186
+ }
187
+
188
+ clear(): void {
189
+ this.grid.clear();
190
+ }
191
+ }
192
+
193
+ interface HitTestCache {
194
+ spatialGrid: SpatialGrid;
195
+ transformParams: {
196
+ scale: number;
197
+ offsetX: number;
198
+ offsetZ: number;
199
+ zoomScale: number;
200
+ zoomOffsetX: number;
201
+ zoomOffsetY: number;
202
+ };
203
+ abstractedPaths: Set<string>;
204
+ timestamp: number;
205
+ }
206
+
207
+ function ArchitectureMapHighlightLayersInner({
208
+ cityData,
209
+ highlightLayers = [],
210
+ onLayerToggle,
211
+ focusDirectory = null,
212
+ rootDirectoryName,
213
+ onDirectorySelect,
214
+ onFileClick,
215
+ enableZoom = false,
216
+ fullSize = false,
217
+ showGrid = false,
218
+ showFileNames = false,
219
+ className = '',
220
+ selectiveRender,
221
+ canvasBackgroundColor,
222
+ hoverBorderColor,
223
+ disableOpacityDimming = true,
224
+ defaultDirectoryColor,
225
+ defaultBuildingColor,
226
+ subdirectoryMode,
227
+ showLayerControls = false,
228
+ showFileTypeIcons = true,
229
+ showDirectoryLabels = true,
230
+ transform = { rotation: 0 }, // Default to no rotation
231
+ onHover,
232
+ buildingBorderRadius = 0,
233
+ districtBorderRadius = 0,
234
+ }: ArchitectureMapHighlightLayersProps) {
235
+ const { theme } = useTheme();
236
+
237
+ // Use theme colors as defaults, with prop overrides
238
+ const resolvedCanvasBackgroundColor = canvasBackgroundColor ?? theme.colors.background;
239
+ const resolvedHoverBorderColor = hoverBorderColor ?? theme.colors.text;
240
+ const resolvedDefaultDirectoryColor = defaultDirectoryColor ?? theme.colors.backgroundSecondary;
241
+ const resolvedDefaultBuildingColor = defaultBuildingColor ?? theme.colors.muted;
242
+ const canvasRef = useRef<HTMLCanvasElement>(null);
243
+ const containerRef = useRef<HTMLDivElement>(null);
244
+
245
+ const [interactionState, setInteractionState] = useState<MapInteractionState>({
246
+ hoveredDistrict: null,
247
+ hoveredBuilding: null,
248
+ mousePos: { x: 0, y: 0 },
249
+ });
250
+
251
+ const [zoomState, setZoomState] = useState({
252
+ scale: 1,
253
+ offsetX: 0,
254
+ offsetY: 0,
255
+ isDragging: false,
256
+ lastMousePos: { x: 0, y: 0 },
257
+ hasMouseMoved: false,
258
+ });
259
+
260
+ useEffect(() => {
261
+ if (!enableZoom) {
262
+ setZoomState(prev => ({
263
+ ...prev,
264
+ scale: 1,
265
+ offsetX: 0,
266
+ offsetY: 0,
267
+ isDragging: false,
268
+ hasMouseMoved: false,
269
+ }));
270
+ }
271
+ }, [enableZoom]);
272
+
273
+ const [hitTestCache, setHitTestCache] = useState<HitTestCache | null>(null);
274
+
275
+ const calculateCanvasResolution = (
276
+ fileCount: number,
277
+ _cityBounds?: { minX: number; maxX: number; minZ: number; maxZ: number },
278
+ ) => {
279
+ const minSize = 400;
280
+ const scaleFactor = Math.sqrt(fileCount / 5);
281
+ const resolution = Math.max(minSize, minSize + scaleFactor * 300);
282
+
283
+ return { width: resolution, height: resolution };
284
+ };
285
+
286
+ const [canvasSize, setCanvasSize] = useState(() =>
287
+ calculateCanvasResolution(cityData?.buildings?.length || 10, cityData?.bounds),
288
+ );
289
+
290
+ const [displayOptions] = useState<MapDisplayOptions>({
291
+ ...DEFAULT_DISPLAY_OPTIONS,
292
+ showGrid,
293
+ });
294
+
295
+ const filteredCityData = useMemo(() => {
296
+ if (!cityData) {
297
+ return undefined;
298
+ }
299
+
300
+ let processedData = cityData;
301
+ if (subdirectoryMode?.enabled) {
302
+ const autoCenter = subdirectoryMode.autoCenter === true;
303
+
304
+ // Use new multi-filter function if filters are provided
305
+ if (subdirectoryMode.filters && subdirectoryMode.filters.length > 0) {
306
+ processedData = filterCityDataForMultipleDirectories(
307
+ cityData,
308
+ subdirectoryMode.filters,
309
+ autoCenter,
310
+ subdirectoryMode.combineMode || 'union',
311
+ );
312
+ } else if (subdirectoryMode.rootPath) {
313
+ // Fallback to single path for backward compatibility
314
+ processedData = filterCityDataForSubdirectory(
315
+ cityData,
316
+ subdirectoryMode.rootPath,
317
+ autoCenter,
318
+ );
319
+ }
320
+ }
321
+
322
+ return filterCityDataForSelectiveRender(processedData, selectiveRender);
323
+ }, [cityData, selectiveRender, subdirectoryMode]);
324
+
325
+ const canvasSizingData = useMemo(() => {
326
+ if (subdirectoryMode?.enabled && subdirectoryMode.autoCenter !== true && cityData) {
327
+ return cityData;
328
+ }
329
+ return filteredCityData;
330
+ }, [subdirectoryMode?.enabled, subdirectoryMode?.autoCenter, cityData, filteredCityData]);
331
+
332
+ // Build hit test cache with spatial indexing
333
+ const buildHitTestCache = useCallback(
334
+ (
335
+ cityData: CityData,
336
+ scale: number,
337
+ offsetX: number,
338
+ offsetZ: number,
339
+ zoomState: {
340
+ scale: number;
341
+ offsetX: number;
342
+ offsetY: number;
343
+ isDragging: boolean;
344
+ lastMousePos: { x: number; y: number };
345
+ hasMouseMoved: boolean;
346
+ },
347
+ abstractedPaths: Set<string>,
348
+ ): HitTestCache => {
349
+ const spatialGrid = new SpatialGrid(cityData.bounds);
350
+
351
+ // Only add visible buildings to spatial grid
352
+ cityData.buildings.forEach(building => {
353
+ // Check if this building is inside any abstracted directory
354
+ let currentPath = building.path;
355
+ let isAbstracted = false;
356
+ while (currentPath.includes('/')) {
357
+ currentPath = currentPath.substring(0, currentPath.lastIndexOf('/'));
358
+ if (abstractedPaths.has(currentPath)) {
359
+ isAbstracted = true;
360
+ break;
361
+ }
362
+ }
363
+
364
+ if (!isAbstracted) {
365
+ spatialGrid.addBuilding(building);
366
+ }
367
+ });
368
+
369
+ // Add districts to spatial grid
370
+ cityData.districts.forEach(district => {
371
+ if (!district.path) {
372
+ spatialGrid.addDistrict(district); // Keep root
373
+ return;
374
+ }
375
+
376
+ // For hit testing, we need to include abstracted districts too
377
+ // because they have visual covers that users can click on.
378
+ // Only skip children of abstracted directories.
379
+ let isChildOfAbstracted = false;
380
+ for (const abstractedPath of abstractedPaths) {
381
+ if (abstractedPath && district.path.startsWith(abstractedPath + '/')) {
382
+ isChildOfAbstracted = true;
383
+ break;
384
+ }
385
+ }
386
+
387
+ if (!isChildOfAbstracted) {
388
+ spatialGrid.addDistrict(district);
389
+ }
390
+ });
391
+
392
+ return {
393
+ spatialGrid,
394
+ transformParams: {
395
+ scale,
396
+ offsetX,
397
+ offsetZ,
398
+ zoomScale: zoomState.scale,
399
+ zoomOffsetX: zoomState.offsetX,
400
+ zoomOffsetY: zoomState.offsetY,
401
+ },
402
+ abstractedPaths,
403
+ timestamp: Date.now(),
404
+ };
405
+ },
406
+ [],
407
+ );
408
+
409
+ // Update canvas size when city data changes
410
+ useEffect(() => {
411
+ if (canvasSizingData) {
412
+ const newSize = calculateCanvasResolution(
413
+ canvasSizingData.buildings.length,
414
+ canvasSizingData.bounds,
415
+ );
416
+ setCanvasSize(newSize);
417
+ }
418
+ }, [canvasSizingData, subdirectoryMode]);
419
+
420
+ // Separate stable and dynamic layers for performance optimization
421
+ const stableLayers = useMemo(
422
+ () => highlightLayers.filter(layer => layer.dynamic !== true),
423
+ [highlightLayers],
424
+ );
425
+
426
+ const dynamicLayers = useMemo(
427
+ () => highlightLayers.filter(layer => layer.dynamic === true),
428
+ [highlightLayers],
429
+ );
430
+
431
+ // Combine all layers for rendering (moved up so abstractionLayer can use it)
432
+ const allLayersWithoutAbstraction = useMemo(
433
+ () => [...stableLayers, ...dynamicLayers],
434
+ [stableLayers, dynamicLayers],
435
+ );
436
+
437
+ // Directory tree node for abstraction calculation
438
+ interface DirectoryNode {
439
+ path: string;
440
+ district: CityDistrict;
441
+ screenSize: { width: number; height: number };
442
+ children: DirectoryNode[];
443
+ buildings: CityBuilding[];
444
+ containsHighlights: boolean;
445
+ shouldAbstract: boolean;
446
+ }
447
+
448
+ // Calculate abstracted directories based on current zoom using tree structure
449
+ const abstractionLayer = useMemo(() => {
450
+ // Define the extended interface for abstraction layers
451
+ interface AbstractionLayer extends HighlightLayer {
452
+ abstractionLayer?: boolean;
453
+ abstractionConfig?: {
454
+ maxZoomLevel?: number;
455
+ minPercentage?: number;
456
+ backgroundColor?: string;
457
+ allowRootAbstraction?: boolean;
458
+ projectInfo?: {
459
+ repoName?: string;
460
+ rootDirectoryName?: string;
461
+ currentBranch?: string;
462
+ };
463
+ };
464
+ }
465
+
466
+ // Find the abstraction layer in our highlight layers
467
+ const abstractionLayerDef = highlightLayers.find(
468
+ layer => layer.id === 'directory-abstraction' && (layer as AbstractionLayer).abstractionLayer,
469
+ ) as AbstractionLayer | undefined;
470
+
471
+ if (!abstractionLayerDef || !abstractionLayerDef.enabled || !filteredCityData) {
472
+ return null;
473
+ }
474
+
475
+ const config = abstractionLayerDef.abstractionConfig;
476
+
477
+ // Disable abstraction when zoomed in beyond a certain threshold
478
+ const maxZoomForAbstraction = config?.maxZoomLevel ?? 5.0;
479
+ if (zoomState.scale > maxZoomForAbstraction) {
480
+ return null; // No abstractions when zoomed in
481
+ }
482
+
483
+ // Calculate which directories are too small at current zoom
484
+ const abstractedItems: LayerItem[] = [];
485
+
486
+ if (canvasRef.current) {
487
+ const displayWidth = canvasRef.current.clientWidth || canvasSize.width;
488
+ const displayHeight = canvasRef.current.clientHeight || canvasSize.height;
489
+
490
+ const coordinateSystemData =
491
+ subdirectoryMode?.enabled && subdirectoryMode.autoCenter !== true && cityData
492
+ ? cityData
493
+ : filteredCityData;
494
+
495
+ const { scale } = calculateScaleAndOffset(
496
+ coordinateSystemData,
497
+ displayWidth,
498
+ displayHeight,
499
+ displayOptions.padding,
500
+ );
501
+
502
+ const totalScale = scale * zoomState.scale;
503
+
504
+ // Get all highlighted paths from enabled layers
505
+ const highlightedPaths = new Set<string>();
506
+ allLayersWithoutAbstraction.forEach(layer => {
507
+ if (layer.enabled && layer.id !== 'directory-abstraction') {
508
+ layer.items.forEach(item => {
509
+ if (item.type === 'file') {
510
+ highlightedPaths.add(item.path);
511
+ }
512
+ });
513
+ }
514
+ });
515
+
516
+ // Build directory tree
517
+ const nodeMap = new Map<string, DirectoryNode>();
518
+ const rootNode: DirectoryNode = {
519
+ path: '',
520
+ district: {
521
+ path: '',
522
+ worldBounds: filteredCityData.bounds,
523
+ fileCount: 0,
524
+ type: 'directory',
525
+ },
526
+ screenSize: { width: Infinity, height: Infinity },
527
+ children: [],
528
+ buildings: [],
529
+ containsHighlights: false,
530
+ shouldAbstract: false,
531
+ };
532
+ nodeMap.set('', rootNode);
533
+
534
+ // Create nodes for all districts
535
+ filteredCityData.districts.forEach(district => {
536
+ if (!district.path) return;
537
+
538
+ const screenWidth = (district.worldBounds.maxX - district.worldBounds.minX) * totalScale;
539
+ const screenHeight = (district.worldBounds.maxZ - district.worldBounds.minZ) * totalScale;
540
+
541
+ const node: DirectoryNode = {
542
+ path: district.path,
543
+ district: district,
544
+ screenSize: { width: screenWidth, height: screenHeight },
545
+ children: [],
546
+ buildings: [],
547
+ containsHighlights: false,
548
+ shouldAbstract: false,
549
+ };
550
+ nodeMap.set(district.path, node);
551
+ });
552
+
553
+ // Build parent-child relationships
554
+ nodeMap.forEach((node, path) => {
555
+ if (!path) return; // Skip root
556
+
557
+ // Find parent path
558
+ const lastSlash = path.lastIndexOf('/');
559
+ const parentPath = lastSlash > 0 ? path.substring(0, lastSlash) : '';
560
+ const parent = nodeMap.get(parentPath);
561
+
562
+ if (parent) {
563
+ parent.children.push(node);
564
+ } else {
565
+ rootNode.children.push(node);
566
+ }
567
+ });
568
+
569
+ // Assign buildings to their directories and check for highlights
570
+ filteredCityData.buildings.forEach(building => {
571
+ const dirPath = building.path.substring(0, building.path.lastIndexOf('/')) || '';
572
+ const node = nodeMap.get(dirPath);
573
+ if (node) {
574
+ node.buildings.push(building);
575
+ if (highlightedPaths.has(building.path)) {
576
+ // Mark this node and all parents as containing highlights
577
+ let current: DirectoryNode | undefined = node;
578
+ let depth = 0;
579
+ while (current && depth < 100) {
580
+ // Add safety limit
581
+ depth++;
582
+ current.containsHighlights = true;
583
+ const parentPath =
584
+ current.path.lastIndexOf('/') > 0
585
+ ? current.path.substring(0, current.path.lastIndexOf('/'))
586
+ : '';
587
+ if (parentPath === current.path) break; // Prevent infinite loop
588
+ current = nodeMap.get(parentPath);
589
+ }
590
+ if (depth >= 100) {
591
+ console.error('[Abstraction] Highlight propagation exceeded depth limit');
592
+ }
593
+ }
594
+ }
595
+ });
596
+
597
+ if (config?.allowRootAbstraction) {
598
+ const cityWidth =
599
+ (filteredCityData.bounds.maxX - filteredCityData.bounds.minX) * totalScale;
600
+ const cityHeight =
601
+ (filteredCityData.bounds.maxZ - filteredCityData.bounds.minZ) * totalScale;
602
+ const cityArea = cityWidth * cityHeight;
603
+ const canvasArea = displayWidth * displayHeight;
604
+ const minPercentage = config?.minPercentage || 0.01;
605
+ const cityMeetsThreshold = cityArea >= canvasArea * minPercentage;
606
+
607
+ if (!cityMeetsThreshold && !highlightedPaths.size) {
608
+ // Abstract the entire city
609
+
610
+ // Build meaningful project info text
611
+ const projectInfo = config.projectInfo;
612
+ let displayText = '';
613
+
614
+ if (projectInfo?.repoName || projectInfo?.rootDirectoryName) {
615
+ displayText = projectInfo.repoName || projectInfo.rootDirectoryName || 'Project';
616
+ if (projectInfo.currentBranch) {
617
+ displayText += `\n${projectInfo.currentBranch}`;
618
+ }
619
+ } else {
620
+ displayText = 'Project Overview';
621
+ }
622
+
623
+ abstractedItems.push({
624
+ path: '', // Empty path for root
625
+ type: 'directory',
626
+ renderStrategy: 'cover',
627
+ coverOptions: {
628
+ text: displayText,
629
+ backgroundColor: config?.backgroundColor ?? '#1e40af',
630
+ opacity: 1.0,
631
+ borderRadius: 6,
632
+ textSize: 16,
633
+ },
634
+ });
635
+
636
+ // Return early with just the city abstraction
637
+ return {
638
+ ...abstractionLayerDef,
639
+ items: abstractedItems,
640
+ };
641
+ }
642
+ }
643
+
644
+ // BFS to determine abstractions for subdirectories
645
+ const queue: DirectoryNode[] = [...rootNode.children];
646
+
647
+ while (queue.length > 0) {
648
+ const node = queue.shift();
649
+ if (!node) continue;
650
+
651
+ // Decision logic - using percentage of canvas/viewport area
652
+ // Current screen area of the directory
653
+ const currentArea = node.screenSize.width * node.screenSize.height;
654
+
655
+ // Canvas/viewport area
656
+ const canvasArea = displayWidth * displayHeight;
657
+
658
+ // Check if current size is at least X% of canvas
659
+ const minPercentage = config?.minPercentage || 0.01; // Default 1% of canvas
660
+ const meetsThreshold = currentArea >= canvasArea * minPercentage;
661
+
662
+ if (meetsThreshold) {
663
+ // Large enough - show contents, continue to children
664
+ queue.push(...node.children);
665
+ } else if (node.containsHighlights) {
666
+ // Too small but has highlights - show contents, continue to children
667
+ queue.push(...node.children);
668
+ } else {
669
+ // Too small and no highlights - abstract this level
670
+ node.shouldAbstract = true;
671
+ const dirName = node.path.split('/').pop() || node.path;
672
+
673
+ abstractedItems.push({
674
+ path: node.path,
675
+ type: 'directory',
676
+ renderStrategy: 'cover',
677
+ coverOptions: {
678
+ text: dirName,
679
+ backgroundColor: config?.backgroundColor ?? '#1e40af',
680
+ opacity: 1.0,
681
+ borderRadius: 6,
682
+ textSize: 12,
683
+ },
684
+ });
685
+ // Don't process children - they're covered by this abstraction
686
+ }
687
+ }
688
+ }
689
+
690
+ // Return a new layer with calculated items
691
+ return {
692
+ ...abstractionLayerDef,
693
+ items: abstractedItems,
694
+ };
695
+ }, [
696
+ highlightLayers,
697
+ filteredCityData,
698
+ canvasSize,
699
+ displayOptions.padding,
700
+ zoomState.scale,
701
+ subdirectoryMode,
702
+ cityData,
703
+ allLayersWithoutAbstraction,
704
+ ]);
705
+
706
+ // Combine all layers for rendering, including calculated abstraction layer
707
+ const allLayers = useMemo(() => {
708
+ const layers = [...stableLayers, ...dynamicLayers];
709
+
710
+ // Replace abstraction layer with calculated one if it exists
711
+ if (abstractionLayer) {
712
+ const index = layers.findIndex(l => l.id === 'directory-abstraction');
713
+ if (index >= 0) {
714
+ layers[index] = abstractionLayer;
715
+ }
716
+ }
717
+
718
+ return layers;
719
+ }, [stableLayers, dynamicLayers, abstractionLayer]);
720
+
721
+ // Update hit test cache when geometry or abstraction changes
722
+ useEffect(() => {
723
+ if (!filteredCityData) return;
724
+
725
+ const width = canvasRef.current?.clientWidth || canvasSize.width;
726
+ const height = canvasRef.current?.clientHeight || canvasSize.height;
727
+ const { scale, offsetX, offsetZ } = calculateScaleAndOffset(
728
+ filteredCityData,
729
+ width,
730
+ height,
731
+ displayOptions.padding,
732
+ );
733
+
734
+ // Get abstracted paths from the abstraction layer
735
+ const abstractedPaths = new Set<string>();
736
+ if (abstractionLayer && abstractionLayer.enabled) {
737
+ abstractionLayer.items.forEach(item => {
738
+ if (item.type === 'directory') {
739
+ abstractedPaths.add(item.path);
740
+ }
741
+ });
742
+ }
743
+
744
+ const newCache = buildHitTestCache(
745
+ filteredCityData,
746
+ scale,
747
+ offsetX,
748
+ offsetZ,
749
+ zoomState,
750
+ abstractedPaths,
751
+ );
752
+ setHitTestCache(newCache);
753
+ }, [
754
+ filteredCityData,
755
+ canvasSize,
756
+ displayOptions.padding,
757
+ zoomState,
758
+ buildHitTestCache,
759
+ abstractionLayer,
760
+ ]);
761
+
762
+ // Main canvas drawing with layer-based highlighting
763
+ useEffect(() => {
764
+ if (!canvasRef.current || !filteredCityData) {
765
+ return;
766
+ }
767
+
768
+ const canvas = canvasRef.current;
769
+ const ctx = canvas.getContext('2d');
770
+ if (!ctx) {
771
+ return;
772
+ }
773
+
774
+ // Performance monitoring start available for debugging
775
+
776
+ const displayWidth = canvas.clientWidth || canvasSize.width;
777
+ const displayHeight = canvas.clientHeight || canvasSize.height;
778
+
779
+ canvas.width = displayWidth;
780
+ canvas.height = displayHeight;
781
+
782
+ ctx.fillStyle = resolvedCanvasBackgroundColor;
783
+ ctx.fillRect(0, 0, displayWidth, displayHeight);
784
+
785
+ if (displayOptions.showGrid) {
786
+ drawGrid(ctx, displayWidth, displayHeight, displayOptions.gridSize);
787
+ }
788
+
789
+ const coordinateSystemData =
790
+ subdirectoryMode?.enabled && subdirectoryMode.autoCenter !== true && cityData
791
+ ? cityData
792
+ : filteredCityData;
793
+
794
+ const { scale, offsetX, offsetZ } = calculateScaleAndOffset(
795
+ coordinateSystemData,
796
+ displayWidth,
797
+ displayHeight,
798
+ displayOptions.padding,
799
+ );
800
+
801
+ const worldToCanvas = (x: number, z: number) => ({
802
+ x:
803
+ ((x - coordinateSystemData.bounds.minX) * scale + offsetX) * zoomState.scale +
804
+ zoomState.offsetX,
805
+ y:
806
+ ((z - coordinateSystemData.bounds.minZ) * scale + offsetZ) * zoomState.scale +
807
+ zoomState.offsetY,
808
+ });
809
+
810
+ // Get abstracted paths for filtering child districts
811
+ const abstractedPathsForDistricts = new Set<string>();
812
+ const abstractionLayerForDistricts = allLayers.find(l => l.id === 'directory-abstraction');
813
+ if (abstractionLayerForDistricts && abstractionLayerForDistricts.enabled) {
814
+ abstractionLayerForDistricts.items.forEach(item => {
815
+ if (item.type === 'directory') {
816
+ abstractedPathsForDistricts.add(item.path);
817
+ }
818
+ });
819
+ }
820
+
821
+ // Keep abstracted districts (for covers) but filter out their children
822
+ let visibleDistricts =
823
+ abstractedPathsForDistricts.size > 0
824
+ ? filteredCityData.districts.filter(district => {
825
+ // Check for root abstraction first
826
+ if (abstractedPathsForDistricts.has('')) {
827
+ // If root is abstracted, only show root district
828
+ return !district.path || district.path === '';
829
+ }
830
+
831
+ if (!district.path) return true; // Keep root
832
+
833
+ // Keep the abstracted district itself (we need it for the cover)
834
+ if (abstractedPathsForDistricts.has(district.path)) {
835
+ return true;
836
+ }
837
+
838
+ // Filter out children of abstracted directories
839
+ for (const abstractedPath of abstractedPathsForDistricts) {
840
+ if (district.path.startsWith(abstractedPath + '/')) {
841
+ return false; // Skip child of abstracted directory
842
+ }
843
+ }
844
+
845
+ return true;
846
+ })
847
+ : filteredCityData.districts;
848
+
849
+ // If root is abstracted and there's no root district, create one for the cover
850
+ if (abstractedPathsForDistricts.has('')) {
851
+ const hasRootDistrict = visibleDistricts.some(d => !d.path || d.path === '');
852
+
853
+ if (!hasRootDistrict) {
854
+ visibleDistricts = [
855
+ {
856
+ path: '',
857
+ worldBounds: filteredCityData.bounds,
858
+ fileCount: filteredCityData.buildings.length, // Total file count
859
+ type: 'directory',
860
+ },
861
+ ...visibleDistricts,
862
+ ];
863
+ }
864
+ }
865
+
866
+ // Draw districts with layer support
867
+ drawLayeredDistricts(
868
+ ctx,
869
+ visibleDistricts,
870
+ worldToCanvas,
871
+ scale * zoomState.scale,
872
+ allLayers,
873
+ interactionState.hoveredDistrict,
874
+ fullSize,
875
+ resolvedDefaultDirectoryColor,
876
+ filteredCityData.metadata.layoutConfig,
877
+ abstractedPathsForDistricts, // Pass abstracted paths to skip labels
878
+ showDirectoryLabels,
879
+ districtBorderRadius,
880
+ );
881
+
882
+ // Get abstracted directory paths for filtering from the actual layers being rendered
883
+ const abstractedPaths = new Set<string>();
884
+ const activeAbstractionLayer = allLayers.find(l => l.id === 'directory-abstraction');
885
+ if (activeAbstractionLayer && activeAbstractionLayer.enabled) {
886
+ activeAbstractionLayer.items.forEach(item => {
887
+ if (item.type === 'directory') {
888
+ abstractedPaths.add(item.path);
889
+ }
890
+ });
891
+ }
892
+
893
+ // Filter out buildings that are in abstracted directories
894
+ const visibleBuildings =
895
+ abstractedPaths.size > 0
896
+ ? filteredCityData.buildings.filter(building => {
897
+ // Check for root abstraction first
898
+ if (abstractedPaths.has('')) {
899
+ // If root is abstracted, hide all buildings
900
+ return false;
901
+ }
902
+
903
+ const buildingDir = building.path.substring(0, building.path.lastIndexOf('/')) || '';
904
+
905
+ // Simple direct check first
906
+ for (const abstractedPath of abstractedPaths) {
907
+ if (buildingDir === abstractedPath) {
908
+ return false;
909
+ }
910
+ // Also check if building is in a subdirectory of abstracted path
911
+ if (buildingDir.startsWith(abstractedPath + '/')) {
912
+ return false;
913
+ }
914
+ }
915
+
916
+ return true;
917
+ })
918
+ : filteredCityData.buildings;
919
+
920
+ // Log only if we see buildings that should have been filtered
921
+ if (abstractedPaths.size > 0) {
922
+ const suspiciousBuildings = visibleBuildings.filter(building => {
923
+ const buildingDir = building.path.substring(0, building.path.lastIndexOf('/')) || '';
924
+ // Check if this building's parent is visually covered
925
+ for (const abstractedPath of abstractedPaths) {
926
+ if (buildingDir === abstractedPath || buildingDir.startsWith(abstractedPath + '/')) {
927
+ return true;
928
+ }
929
+ }
930
+ return false;
931
+ });
932
+
933
+ if (suspiciousBuildings.length > 0) {
934
+ console.error(
935
+ `[Building Filter] WARNING: ${suspiciousBuildings.length} buildings are being rendered in abstracted directories!`,
936
+ );
937
+ suspiciousBuildings.slice(0, 3).forEach(building => {
938
+ console.error(` - ${building.path}`);
939
+ });
940
+ }
941
+ }
942
+
943
+ // Draw buildings with layer support
944
+ drawLayeredBuildings(
945
+ ctx,
946
+ visibleBuildings,
947
+ worldToCanvas,
948
+ scale * zoomState.scale,
949
+ allLayers,
950
+ interactionState.hoveredBuilding,
951
+ resolvedDefaultBuildingColor,
952
+ showFileNames,
953
+ resolvedHoverBorderColor,
954
+ disableOpacityDimming,
955
+ showFileTypeIcons,
956
+ buildingBorderRadius,
957
+ );
958
+
959
+ // Performance monitoring end available for debugging
960
+ // Performance stats available but not logged to reduce console noise
961
+ // Uncomment for debugging: render time, buildings/districts counts, layer counts
962
+ }, [
963
+ filteredCityData,
964
+ canvasSize,
965
+ displayOptions,
966
+ zoomState,
967
+ selectiveRender,
968
+ rootDirectoryName,
969
+ highlightLayers,
970
+ interactionState.hoveredBuilding,
971
+ interactionState.hoveredDistrict,
972
+ resolvedDefaultDirectoryColor,
973
+ showFileNames,
974
+ fullSize,
975
+ resolvedHoverBorderColor,
976
+ disableOpacityDimming,
977
+ focusDirectory,
978
+ subdirectoryMode,
979
+ cityData,
980
+ resolvedCanvasBackgroundColor,
981
+ showDirectoryLabels,
982
+ allLayers,
983
+ buildingBorderRadius,
984
+ resolvedDefaultBuildingColor,
985
+ districtBorderRadius,
986
+ showFileTypeIcons,
987
+ ]);
988
+
989
+ // Optimized hit testing
990
+ const performHitTest = useCallback(
991
+ (
992
+ canvasX: number,
993
+ canvasY: number,
994
+ ): {
995
+ hoveredBuilding: CityBuilding | null;
996
+ hoveredDistrict: CityDistrict | null;
997
+ } => {
998
+ if (!hitTestCache || !filteredCityData) {
999
+ return { hoveredBuilding: null, hoveredDistrict: null };
1000
+ }
1001
+
1002
+ const width = canvasRef.current?.clientWidth || canvasSize.width;
1003
+ const height = canvasRef.current?.clientHeight || canvasSize.height;
1004
+
1005
+ const coordinateSystemData =
1006
+ subdirectoryMode?.enabled && subdirectoryMode.autoCenter !== true && cityData
1007
+ ? cityData
1008
+ : filteredCityData;
1009
+
1010
+ const { scale, offsetX, offsetZ } = calculateScaleAndOffset(
1011
+ coordinateSystemData,
1012
+ width,
1013
+ height,
1014
+ displayOptions.padding,
1015
+ );
1016
+
1017
+ const screenX = (canvasX - zoomState.offsetX) / zoomState.scale;
1018
+ const screenY = (canvasY - zoomState.offsetY) / zoomState.scale;
1019
+ const worldX = (screenX - offsetX) / scale + coordinateSystemData.bounds.minX;
1020
+ const worldZ = (screenY - offsetZ) / scale + coordinateSystemData.bounds.minZ;
1021
+
1022
+ const nearbyItems = hitTestCache.spatialGrid.query(worldX, worldZ, 20);
1023
+
1024
+ let hoveredBuilding: CityBuilding | null = null;
1025
+ let hoveredDistrict: CityDistrict | null = null;
1026
+ let minBuildingDistance = Infinity;
1027
+ let deepestDistrictLevel = -1;
1028
+
1029
+ for (const item of nearbyItems) {
1030
+ if ('dimensions' in item) {
1031
+ const building = item as CityBuilding;
1032
+
1033
+ const size = Math.max(building.dimensions[0], building.dimensions[2]);
1034
+ const halfSize = size / 2;
1035
+
1036
+ if (
1037
+ worldX >= building.position.x - halfSize &&
1038
+ worldX <= building.position.x + halfSize &&
1039
+ worldZ >= building.position.z - halfSize &&
1040
+ worldZ <= building.position.z + halfSize
1041
+ ) {
1042
+ const distance = Math.sqrt(
1043
+ Math.pow(worldX - building.position.x, 2) + Math.pow(worldZ - building.position.z, 2),
1044
+ );
1045
+
1046
+ if (distance < minBuildingDistance) {
1047
+ minBuildingDistance = distance;
1048
+ hoveredBuilding = building;
1049
+ }
1050
+ }
1051
+ }
1052
+ }
1053
+
1054
+ for (const item of nearbyItems) {
1055
+ if (!('dimensions' in item)) {
1056
+ const district = item as CityDistrict;
1057
+ const districtPath = district.path || '';
1058
+ const isRoot = !districtPath || districtPath === '';
1059
+
1060
+ // Skip root districts UNLESS they are abstracted (have covers)
1061
+ if (isRoot && !hitTestCache.abstractedPaths.has('')) continue;
1062
+
1063
+ if (
1064
+ worldX >= district.worldBounds.minX &&
1065
+ worldX <= district.worldBounds.maxX &&
1066
+ worldZ >= district.worldBounds.minZ &&
1067
+ worldZ <= district.worldBounds.maxZ
1068
+ ) {
1069
+ const level = districtPath.split('/').filter(Boolean).length;
1070
+ if (level > deepestDistrictLevel) {
1071
+ deepestDistrictLevel = level;
1072
+ hoveredDistrict = district;
1073
+ }
1074
+ }
1075
+ }
1076
+ }
1077
+ return { hoveredBuilding, hoveredDistrict };
1078
+ },
1079
+ [
1080
+ hitTestCache,
1081
+ filteredCityData,
1082
+ canvasSize,
1083
+ displayOptions.padding,
1084
+ zoomState,
1085
+ subdirectoryMode,
1086
+ cityData,
1087
+ ],
1088
+ );
1089
+
1090
+ // Mouse event handlers
1091
+ // Call onHover callback when interaction state changes
1092
+ useEffect(() => {
1093
+ if (onHover && interactionState) {
1094
+ const fileTooltip = interactionState.hoveredBuilding
1095
+ ? {
1096
+ text:
1097
+ interactionState.hoveredBuilding.path.split('/').pop() ||
1098
+ interactionState.hoveredBuilding.path,
1099
+ }
1100
+ : null;
1101
+
1102
+ const directoryTooltip =
1103
+ interactionState.hoveredDistrict && interactionState.hoveredDistrict.path !== '/'
1104
+ ? { text: interactionState.hoveredDistrict.path || '/' }
1105
+ : null;
1106
+
1107
+ onHover({
1108
+ hoveredDistrict: interactionState.hoveredDistrict,
1109
+ hoveredBuilding: interactionState.hoveredBuilding,
1110
+ mousePos: interactionState.mousePos,
1111
+ fileTooltip,
1112
+ directoryTooltip,
1113
+ fileCount: interactionState.hoveredDistrict
1114
+ ? Math.round(interactionState.hoveredDistrict.fileCount || 0)
1115
+ : null,
1116
+ });
1117
+ }
1118
+ }, [interactionState, onHover]);
1119
+
1120
+ // Helper function to transform mouse coordinates based on rotation/flip
1121
+ const transformMouseCoordinates = useCallback(
1122
+ (x: number, y: number, canvasWidth: number, canvasHeight: number) => {
1123
+ const centerX = canvasWidth / 2;
1124
+ const centerY = canvasHeight / 2;
1125
+
1126
+ // Translate to center
1127
+ let transformedX = x - centerX;
1128
+ let transformedY = y - centerY;
1129
+
1130
+ // Apply inverse rotation (negative angle to undo visual rotation)
1131
+ const rotationDegrees = transform?.rotation || 0;
1132
+ if (rotationDegrees) {
1133
+ const angle = -(rotationDegrees * Math.PI) / 180;
1134
+ const cos = Math.cos(angle);
1135
+ const sin = Math.sin(angle);
1136
+ const newX = transformedX * cos - transformedY * sin;
1137
+ const newY = transformedX * sin + transformedY * cos;
1138
+ transformedX = newX;
1139
+ transformedY = newY;
1140
+ }
1141
+
1142
+ // Apply inverse flips
1143
+ if (transform?.flipHorizontal) {
1144
+ transformedX = -transformedX;
1145
+ }
1146
+ if (transform?.flipVertical) {
1147
+ transformedY = -transformedY;
1148
+ }
1149
+
1150
+ // Translate back from center
1151
+ transformedX += centerX;
1152
+ transformedY += centerY;
1153
+
1154
+ return { x: transformedX, y: transformedY };
1155
+ },
1156
+ [transform],
1157
+ );
1158
+
1159
+ const handleMouseMoveInternal = useCallback(
1160
+ (e: React.MouseEvent<HTMLCanvasElement>) => {
1161
+ if (!canvasRef.current || !containerRef.current || !filteredCityData || zoomState.isDragging)
1162
+ return;
1163
+
1164
+ // Get the container rect for mouse position
1165
+ const containerRect = containerRef.current.getBoundingClientRect();
1166
+ // Get mouse position relative to the container
1167
+ const rawX = e.clientX - containerRect.left;
1168
+ const rawY = e.clientY - containerRect.top;
1169
+
1170
+ // Use canvas dimensions for transformation since that's what the hit testing uses
1171
+ const canvasWidth = canvasRef.current.width;
1172
+ const canvasHeight = canvasRef.current.height;
1173
+
1174
+ // Transform the mouse coordinates to account for rotation/flips
1175
+ const { x, y } = transformMouseCoordinates(rawX, rawY, canvasWidth, canvasHeight);
1176
+
1177
+ const { hoveredBuilding, hoveredDistrict } = performHitTest(x, y);
1178
+
1179
+ setInteractionState(prev => {
1180
+ const hoveredItemChanged =
1181
+ prev.hoveredBuilding !== hoveredBuilding || prev.hoveredDistrict !== hoveredDistrict;
1182
+ const positionChanged = prev.mousePos.x !== x || prev.mousePos.y !== y;
1183
+
1184
+ if (!hoveredItemChanged && !positionChanged) {
1185
+ return prev;
1186
+ }
1187
+
1188
+ // Tooltip data will be calculated in onHover callback
1189
+ return {
1190
+ hoveredBuilding,
1191
+ hoveredDistrict,
1192
+ mousePos: { x, y },
1193
+ };
1194
+ });
1195
+ },
1196
+ [filteredCityData, zoomState.isDragging, performHitTest, transformMouseCoordinates],
1197
+ );
1198
+
1199
+ const handleMouseMove = useCallback(
1200
+ (e: React.MouseEvent<HTMLDivElement>) => {
1201
+ if (enableZoom && zoomState.isDragging) {
1202
+ const rawDeltaX = e.clientX - zoomState.lastMousePos.x;
1203
+ const rawDeltaY = e.clientY - zoomState.lastMousePos.y;
1204
+
1205
+ // Transform the drag deltas based on rotation/flips
1206
+ let deltaX = rawDeltaX;
1207
+ let deltaY = rawDeltaY;
1208
+
1209
+ const rotationDegrees = transform?.rotation || 0;
1210
+ if (rotationDegrees) {
1211
+ // Apply inverse rotation to the deltas
1212
+ const angle = -(rotationDegrees * Math.PI) / 180;
1213
+ const cos = Math.cos(angle);
1214
+ const sin = Math.sin(angle);
1215
+ deltaX = rawDeltaX * cos - rawDeltaY * sin;
1216
+ deltaY = rawDeltaX * sin + rawDeltaY * cos;
1217
+ }
1218
+
1219
+ // Apply inverse flips
1220
+ if (transform?.flipHorizontal) {
1221
+ deltaX = -deltaX;
1222
+ }
1223
+ if (transform?.flipVertical) {
1224
+ deltaY = -deltaY;
1225
+ }
1226
+
1227
+ setZoomState(prev => ({
1228
+ ...prev,
1229
+ offsetX: prev.offsetX + deltaX,
1230
+ offsetY: prev.offsetY + deltaY,
1231
+ lastMousePos: { x: e.clientX, y: e.clientY },
1232
+ hasMouseMoved: true,
1233
+ }));
1234
+ return;
1235
+ }
1236
+
1237
+ handleMouseMoveInternal(e as unknown as React.MouseEvent<HTMLCanvasElement>);
1238
+ },
1239
+ [enableZoom, zoomState.isDragging, zoomState.lastMousePos, handleMouseMoveInternal, transform],
1240
+ );
1241
+
1242
+ const handleClick = () => {
1243
+ if (zoomState.hasMouseMoved) {
1244
+ return;
1245
+ }
1246
+
1247
+ if (interactionState.hoveredBuilding && onFileClick) {
1248
+ /*const fullPath = subdirectoryMode?.enabled && subdirectoryMode.rootPath
1249
+ ? `${subdirectoryMode.rootPath}/${interactionState.hoveredBuilding.path}`
1250
+ : interactionState.hoveredBuilding.path;*/
1251
+ onFileClick(interactionState.hoveredBuilding.path, 'file');
1252
+ return;
1253
+ }
1254
+
1255
+ if (interactionState.hoveredDistrict) {
1256
+ const districtPath = interactionState.hoveredDistrict.path || '';
1257
+ const isRoot = !districtPath || districtPath === '';
1258
+
1259
+ const fullPath =
1260
+ subdirectoryMode?.enabled && subdirectoryMode.rootPath && !isRoot
1261
+ ? `${subdirectoryMode.rootPath}/${districtPath}`
1262
+ : districtPath;
1263
+
1264
+ if (onFileClick && !isRoot) {
1265
+ onFileClick(fullPath, 'directory');
1266
+ } else if (onDirectorySelect) {
1267
+ onDirectorySelect(focusDirectory === fullPath ? null : fullPath);
1268
+ }
1269
+ }
1270
+ };
1271
+
1272
+ const handleMouseLeave = useCallback(() => {
1273
+ setInteractionState(prev => ({
1274
+ ...prev,
1275
+ hoveredDistrict: null,
1276
+ hoveredBuilding: null,
1277
+ }));
1278
+ }, []);
1279
+
1280
+ const handleWheel = (e: React.WheelEvent<HTMLDivElement>) => {
1281
+ if (!enableZoom || !canvasRef.current) {
1282
+ return;
1283
+ }
1284
+
1285
+ const rect = canvasRef.current.getBoundingClientRect();
1286
+ const mouseX = e.clientX - rect.left;
1287
+ const mouseY = e.clientY - rect.top;
1288
+
1289
+ const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
1290
+ const newScale = Math.max(0.1, Math.min(5, zoomState.scale * zoomFactor));
1291
+
1292
+ const scaleChange = newScale / zoomState.scale;
1293
+ const newOffsetX = mouseX - (mouseX - zoomState.offsetX) * scaleChange;
1294
+ const newOffsetY = mouseY - (mouseY - zoomState.offsetY) * scaleChange;
1295
+
1296
+ setZoomState(prev => ({
1297
+ ...prev,
1298
+ scale: newScale,
1299
+ offsetX: newOffsetX,
1300
+ offsetY: newOffsetY,
1301
+ }));
1302
+ };
1303
+
1304
+ const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
1305
+ setZoomState(prev => ({
1306
+ ...prev,
1307
+ isDragging: enableZoom ? true : false,
1308
+ lastMousePos: enableZoom ? { x: e.clientX, y: e.clientY } : prev.lastMousePos,
1309
+ hasMouseMoved: false,
1310
+ }));
1311
+ };
1312
+
1313
+ const handleMouseUp = useCallback(() => {
1314
+ setZoomState(prev => ({ ...prev, isDragging: false }));
1315
+ }, []);
1316
+
1317
+ const interactionCursor = useMemo(() => {
1318
+ if (enableZoom) {
1319
+ if (zoomState.isDragging) {
1320
+ return 'grabbing';
1321
+ }
1322
+ if (interactionState.hoveredBuilding || interactionState.hoveredDistrict) {
1323
+ return 'pointer';
1324
+ }
1325
+ return 'grab';
1326
+ }
1327
+
1328
+ if (interactionState.hoveredBuilding || interactionState.hoveredDistrict) {
1329
+ return 'pointer';
1330
+ }
1331
+
1332
+ return 'default';
1333
+ }, [
1334
+ enableZoom,
1335
+ zoomState.isDragging,
1336
+ interactionState.hoveredBuilding,
1337
+ interactionState.hoveredDistrict,
1338
+ ]);
1339
+
1340
+ if (!filteredCityData) {
1341
+ return (
1342
+ <div
1343
+ className={className}
1344
+ style={{
1345
+ width: '100%',
1346
+ height: '100%',
1347
+ minHeight: '250px',
1348
+ backgroundColor: theme.colors.backgroundSecondary,
1349
+ borderRadius: `${theme.radii[2]}px`,
1350
+ padding: `${theme.space[4]}px`,
1351
+ display: 'flex',
1352
+ alignItems: 'center',
1353
+ justifyContent: 'center',
1354
+ }}
1355
+ >
1356
+ <div style={{ color: theme.colors.textMuted, fontFamily: theme.fonts.body }}>
1357
+ No city data available
1358
+ </div>
1359
+ </div>
1360
+ );
1361
+ }
1362
+
1363
+ return (
1364
+ <div
1365
+ ref={containerRef}
1366
+ className={className}
1367
+ style={{
1368
+ position: 'relative',
1369
+ width: '100%',
1370
+ height: '100%',
1371
+ overflow: 'hidden',
1372
+ backgroundColor: resolvedCanvasBackgroundColor,
1373
+ transform: (() => {
1374
+ const transforms = [];
1375
+
1376
+ // Apply rotation
1377
+ const rotationDegrees = transform?.rotation || 0;
1378
+ if (rotationDegrees) {
1379
+ transforms.push(`rotate(${rotationDegrees}deg)`);
1380
+ }
1381
+
1382
+ // Apply flips
1383
+ if (transform?.flipHorizontal) {
1384
+ transforms.push('scaleX(-1)');
1385
+ }
1386
+ if (transform?.flipVertical) {
1387
+ transforms.push('scaleY(-1)');
1388
+ }
1389
+
1390
+ return transforms.length > 0 ? transforms.join(' ') : undefined;
1391
+ })(),
1392
+ }}
1393
+ >
1394
+ {/* Layer Controls - Toggle Buttons */}
1395
+ {showLayerControls && highlightLayers.length > 0 && (
1396
+ <div
1397
+ style={{
1398
+ position: 'absolute',
1399
+ bottom: `${theme.space[4]}px`,
1400
+ left: `${theme.space[4]}px`,
1401
+ zIndex: 10,
1402
+ display: 'flex',
1403
+ flexDirection: 'column',
1404
+ gap: `${theme.space[2]}px`,
1405
+ }}
1406
+ >
1407
+ {highlightLayers.map(layer => (
1408
+ <button
1409
+ key={layer.id}
1410
+ onClick={() => onLayerToggle?.(layer.id, !layer.enabled)}
1411
+ style={{
1412
+ padding: `${theme.space[2]}px ${theme.space[3]}px`,
1413
+ borderRadius: `${theme.radii[2]}px`,
1414
+ boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
1415
+ transition: 'all 0.2s ease',
1416
+ display: 'flex',
1417
+ alignItems: 'center',
1418
+ gap: `${theme.space[2]}px`,
1419
+ fontSize: `${theme.fontSizes[0]}px`,
1420
+ fontWeight: theme.fontWeights.medium,
1421
+ fontFamily: theme.fonts.body,
1422
+ backgroundColor: layer.enabled
1423
+ ? theme.colors.backgroundSecondary
1424
+ : theme.colors.background,
1425
+ color: layer.enabled ? theme.colors.text : theme.colors.textSecondary,
1426
+ border: `2px solid ${layer.enabled ? layer.color : theme.colors.border}`,
1427
+ minWidth: '120px',
1428
+ cursor: 'pointer',
1429
+ }}
1430
+ title={`Toggle ${layer.name}`}
1431
+ >
1432
+ <div
1433
+ style={{
1434
+ width: '12px',
1435
+ height: '12px',
1436
+ borderRadius: '50%',
1437
+ backgroundColor: layer.color,
1438
+ opacity: layer.enabled ? 1 : 0.4,
1439
+ transition: 'opacity 0.2s ease',
1440
+ }}
1441
+ />
1442
+ <span style={{ textAlign: 'left', flex: 1 }}>{layer.name}</span>
1443
+ {layer.items && layer.items.length > 0 && (
1444
+ <span
1445
+ style={{
1446
+ fontSize: `${theme.fontSizes[0]}px`,
1447
+ color: layer.enabled ? theme.colors.textSecondary : theme.colors.textMuted,
1448
+ }}
1449
+ >
1450
+ {layer.items.length}
1451
+ </span>
1452
+ )}
1453
+ </button>
1454
+ ))}
1455
+ </div>
1456
+ )}
1457
+
1458
+ {/* Main canvas */}
1459
+ <canvas
1460
+ ref={canvasRef}
1461
+ style={{
1462
+ position: 'absolute',
1463
+ top: 0,
1464
+ left: 0,
1465
+ width: '100%',
1466
+ height: '100%',
1467
+ objectFit: 'contain',
1468
+ zIndex: 1,
1469
+ }}
1470
+ />
1471
+
1472
+ {/* Interaction layer */}
1473
+ <div
1474
+ style={{
1475
+ position: 'absolute',
1476
+ top: 0,
1477
+ left: 0,
1478
+ width: '100%',
1479
+ height: '100%',
1480
+ zIndex: 2,
1481
+ cursor: interactionCursor,
1482
+ }}
1483
+ onMouseMove={handleMouseMove}
1484
+ onMouseDown={handleMouseDown}
1485
+ onMouseUp={handleMouseUp}
1486
+ onClick={handleClick}
1487
+ onMouseLeave={handleMouseLeave}
1488
+ onWheel={handleWheel}
1489
+ />
1490
+ </div>
1491
+ );
1492
+ }
1493
+
1494
+ function calculateScaleAndOffset(
1495
+ cityData: CityData,
1496
+ width: number,
1497
+ height: number,
1498
+ padding: number,
1499
+ ) {
1500
+ const cityWidth = cityData.bounds.maxX - cityData.bounds.minX;
1501
+ const cityDepth = cityData.bounds.maxZ - cityData.bounds.minZ;
1502
+
1503
+ const horizontalPadding = padding;
1504
+ const verticalPadding = padding * 2;
1505
+
1506
+ const scaleX = (width - horizontalPadding) / cityDepth;
1507
+ const scaleZ = (height - verticalPadding) / cityWidth;
1508
+ const scale = Math.min(scaleX, scaleZ);
1509
+
1510
+ const scaledCityWidth = cityDepth * scale;
1511
+ const scaledCityHeight = cityWidth * scale;
1512
+ const offsetX = (width - scaledCityWidth) / 2;
1513
+ const offsetZ = (height - scaledCityHeight) / 2;
1514
+
1515
+ return { scale, offsetX, offsetZ };
1516
+ }
1517
+
1518
+ export const ArchitectureMapHighlightLayers = ArchitectureMapHighlightLayersInner;