@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.
- package/dist/builder/cityDataUtils.d.ts +15 -0
- package/dist/builder/cityDataUtils.d.ts.map +1 -0
- package/dist/builder/cityDataUtils.js +348 -0
- package/dist/components/ArchitectureMapHighlightLayers.d.ts +63 -0
- package/dist/components/ArchitectureMapHighlightLayers.d.ts.map +1 -0
- package/dist/components/ArchitectureMapHighlightLayers.js +1040 -0
- package/dist/components/CityViewWithReactFlow.d.ts +14 -0
- package/dist/components/CityViewWithReactFlow.d.ts.map +1 -0
- package/dist/components/CityViewWithReactFlow.js +266 -0
- package/dist/config/files.json +996 -0
- package/dist/hooks/useCodeCityData.d.ts +21 -0
- package/dist/hooks/useCodeCityData.d.ts.map +1 -0
- package/dist/hooks/useCodeCityData.js +57 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/render/client/drawLayeredBuildings.d.ts +51 -0
- package/dist/render/client/drawLayeredBuildings.d.ts.map +1 -0
- package/dist/render/client/drawLayeredBuildings.js +650 -0
- package/dist/stories/ArchitectureMapGridLayout.stories.d.ts +73 -0
- package/dist/stories/ArchitectureMapGridLayout.stories.d.ts.map +1 -0
- package/dist/stories/ArchitectureMapGridLayout.stories.js +345 -0
- package/dist/stories/ArchitectureMapHighlightLayers.stories.d.ts +78 -0
- package/dist/stories/ArchitectureMapHighlightLayers.stories.d.ts.map +1 -0
- package/dist/stories/ArchitectureMapHighlightLayers.stories.js +270 -0
- package/dist/stories/CityViewWithReactFlow.stories.d.ts +24 -0
- package/dist/stories/CityViewWithReactFlow.stories.d.ts.map +1 -0
- package/dist/stories/CityViewWithReactFlow.stories.js +778 -0
- package/dist/stories/sample-data.d.ts +4 -0
- package/dist/stories/sample-data.d.ts.map +1 -0
- package/dist/stories/sample-data.js +268 -0
- package/dist/types/react-types.d.ts +17 -0
- package/dist/types/react-types.d.ts.map +1 -0
- package/dist/types/react-types.js +4 -0
- package/dist/utils/fileColorHighlightLayers.d.ts +86 -0
- package/dist/utils/fileColorHighlightLayers.d.ts.map +1 -0
- package/dist/utils/fileColorHighlightLayers.js +283 -0
- package/package.json +49 -0
- package/src/builder/cityDataUtils.ts +430 -0
- package/src/components/ArchitectureMapHighlightLayers.tsx +1518 -0
- package/src/components/CityViewWithReactFlow.tsx +365 -0
- package/src/config/files.json +996 -0
- package/src/hooks/useCodeCityData.ts +82 -0
- package/src/index.ts +64 -0
- package/src/render/client/drawLayeredBuildings.ts +946 -0
- package/src/stories/ArchitectureMapGridLayout.stories.tsx +410 -0
- package/src/stories/ArchitectureMapHighlightLayers.stories.tsx +312 -0
- package/src/stories/CityViewWithReactFlow.stories.tsx +787 -0
- package/src/stories/sample-data.ts +301 -0
- package/src/types/react-types.ts +18 -0
- 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;
|