@principal-ai/file-city-react 0.5.8 → 0.5.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/FileCity3D/FileCity3D.d.ts +9 -1
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +318 -82
- package/package.json +1 -3
- package/src/components/FileCity3D/FileCity3D.tsx +474 -229
- package/src/stories/FileCity3D.stories.tsx +443 -20
|
@@ -7,22 +7,11 @@
|
|
|
7
7
|
* Supports animated transition from 2D (flat) to 3D (grown buildings).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import React, {
|
|
11
|
-
useMemo,
|
|
12
|
-
useRef,
|
|
13
|
-
useState,
|
|
14
|
-
useEffect,
|
|
15
|
-
useCallback,
|
|
16
|
-
} from 'react';
|
|
10
|
+
import React, { useMemo, useRef, useState, useEffect, useCallback } from 'react';
|
|
17
11
|
import { Canvas, useFrame, ThreeEvent, useThree } from '@react-three/fiber';
|
|
18
|
-
|
|
12
|
+
|
|
19
13
|
import { animated, useSpring, config } from '@react-spring/three';
|
|
20
|
-
import {
|
|
21
|
-
OrbitControls,
|
|
22
|
-
PerspectiveCamera,
|
|
23
|
-
Text,
|
|
24
|
-
RoundedBox,
|
|
25
|
-
} from '@react-three/drei';
|
|
14
|
+
import { OrbitControls, PerspectiveCamera, Text, RoundedBox } from '@react-three/drei';
|
|
26
15
|
import { getFileConfig } from '@principal-ai/file-city-builder';
|
|
27
16
|
import type {
|
|
28
17
|
CityData,
|
|
@@ -159,7 +148,7 @@ function isCodeFile(extension: string): boolean {
|
|
|
159
148
|
function calculateBuildingHeight(
|
|
160
149
|
building: CityBuilding,
|
|
161
150
|
scaling: HeightScaling = 'logarithmic',
|
|
162
|
-
linearScale: number = 0.05
|
|
151
|
+
linearScale: number = 0.05,
|
|
163
152
|
): number {
|
|
164
153
|
const minHeight = 2;
|
|
165
154
|
|
|
@@ -194,15 +183,23 @@ function calculateBuildingHeight(
|
|
|
194
183
|
const LUCIDE_ICONS: Record<string, string> = {
|
|
195
184
|
Atom: '<circle cx="12" cy="12" r="1"/><path d="M20.2 20.2c2.04-2.03.02-7.36-4.5-11.9-4.54-4.52-9.87-6.54-11.9-4.5-2.04 2.03-.02 7.36 4.5 11.9 4.54 4.52 9.87 6.54 11.9 4.5Z"/><path d="M15.7 15.7c4.52-4.54 6.54-9.87 4.5-11.9-2.03-2.04-7.36-.02-11.9 4.5-4.52 4.54-6.54 9.87-4.5 11.9 2.03 2.04 7.36.02 11.9-4.5Z"/>',
|
|
196
185
|
Lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
|
197
|
-
EyeOff:
|
|
186
|
+
EyeOff:
|
|
187
|
+
'<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>',
|
|
198
188
|
Key: '<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/>',
|
|
199
|
-
GitBranch:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
189
|
+
GitBranch:
|
|
190
|
+
'<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
|
|
191
|
+
TestTube:
|
|
192
|
+
'<path d="M14.5 2v17.5c0 1.4-1.1 2.5-2.5 2.5c-1.4 0-2.5-1.1-2.5-2.5V2"/><path d="M8.5 2h7"/><path d="M14.5 16h-5"/>',
|
|
193
|
+
FlaskConical:
|
|
194
|
+
'<path d="M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2"/><path d="M8.5 2h7"/><path d="M7 16h10"/>',
|
|
195
|
+
BookText:
|
|
196
|
+
'<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/><path d="M8 11h8"/><path d="M8 7h6"/>',
|
|
197
|
+
BookOpen:
|
|
198
|
+
'<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
|
|
199
|
+
ScrollText:
|
|
200
|
+
'<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
|
|
201
|
+
Settings:
|
|
202
|
+
'<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
|
206
203
|
Home: '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
|
207
204
|
};
|
|
208
205
|
|
|
@@ -283,7 +280,7 @@ function getColorForFile(building: CityBuilding): string {
|
|
|
283
280
|
*/
|
|
284
281
|
function getHighlightForPath(
|
|
285
282
|
path: string,
|
|
286
|
-
layers: HighlightLayer[]
|
|
283
|
+
layers: HighlightLayer[],
|
|
287
284
|
): { color: string; opacity: number } | null {
|
|
288
285
|
for (const layer of layers) {
|
|
289
286
|
if (!layer.enabled) continue;
|
|
@@ -292,10 +289,7 @@ function getHighlightForPath(
|
|
|
292
289
|
if (item.type === 'file' && item.path === path) {
|
|
293
290
|
return { color: layer.color, opacity: layer.opacity ?? 1 };
|
|
294
291
|
}
|
|
295
|
-
if (
|
|
296
|
-
item.type === 'directory' &&
|
|
297
|
-
(path === item.path || path.startsWith(item.path + '/'))
|
|
298
|
-
) {
|
|
292
|
+
if (item.type === 'directory' && (path === item.path || path.startsWith(item.path + '/'))) {
|
|
299
293
|
return { color: layer.color, opacity: layer.opacity ?? 1 };
|
|
300
294
|
}
|
|
301
295
|
}
|
|
@@ -304,12 +298,15 @@ function getHighlightForPath(
|
|
|
304
298
|
}
|
|
305
299
|
|
|
306
300
|
function hasActiveHighlights(layers: HighlightLayer[]): boolean {
|
|
307
|
-
return layers.some(
|
|
301
|
+
return layers.some(layer => layer.enabled && layer.items.length > 0);
|
|
308
302
|
}
|
|
309
303
|
|
|
310
304
|
// Animated RoundedBox wrapper
|
|
311
305
|
const AnimatedRoundedBox = animated(RoundedBox);
|
|
312
306
|
|
|
307
|
+
// Animated meshStandardMaterial for opacity transitions
|
|
308
|
+
const AnimatedMeshStandardMaterial = animated('meshStandardMaterial');
|
|
309
|
+
|
|
313
310
|
// ============================================================================
|
|
314
311
|
// Building Edges - Batched edge rendering for performance
|
|
315
312
|
// ============================================================================
|
|
@@ -317,10 +314,11 @@ const AnimatedRoundedBox = animated(RoundedBox);
|
|
|
317
314
|
interface BuildingEdgeData {
|
|
318
315
|
width: number;
|
|
319
316
|
depth: number;
|
|
320
|
-
|
|
317
|
+
fullHeight: number;
|
|
321
318
|
x: number;
|
|
322
319
|
z: number;
|
|
323
320
|
staggerDelayMs: number;
|
|
321
|
+
buildingIndex: number; // Index to look up height multiplier
|
|
324
322
|
}
|
|
325
323
|
|
|
326
324
|
interface BuildingEdgesProps {
|
|
@@ -329,9 +327,17 @@ interface BuildingEdgesProps {
|
|
|
329
327
|
minHeight: number;
|
|
330
328
|
baseOffset: number;
|
|
331
329
|
springDuration: number;
|
|
330
|
+
heightMultipliersRef: React.MutableRefObject<Float32Array | null>;
|
|
332
331
|
}
|
|
333
332
|
|
|
334
|
-
function BuildingEdges({
|
|
333
|
+
function BuildingEdges({
|
|
334
|
+
buildings,
|
|
335
|
+
growProgress,
|
|
336
|
+
minHeight,
|
|
337
|
+
baseOffset,
|
|
338
|
+
springDuration,
|
|
339
|
+
heightMultipliersRef,
|
|
340
|
+
}: BuildingEdgesProps) {
|
|
335
341
|
const meshRef = useRef<THREE.InstancedMesh>(null);
|
|
336
342
|
const startTimeRef = useRef<number | null>(null);
|
|
337
343
|
const tempObject = useMemo(() => new THREE.Object3D(), []);
|
|
@@ -341,16 +347,16 @@ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springD
|
|
|
341
347
|
|
|
342
348
|
// Pre-compute edge data
|
|
343
349
|
const edgeData = useMemo(() => {
|
|
344
|
-
return buildings.flatMap(
|
|
345
|
-
const { width, depth, x, z,
|
|
350
|
+
return buildings.flatMap(data => {
|
|
351
|
+
const { width, depth, x, z, fullHeight, staggerDelayMs, buildingIndex } = data;
|
|
346
352
|
const halfW = width / 2;
|
|
347
353
|
const halfD = depth / 2;
|
|
348
354
|
|
|
349
355
|
return [
|
|
350
|
-
{ x: x - halfW, z: z - halfD,
|
|
351
|
-
{ x: x + halfW, z: z - halfD,
|
|
352
|
-
{ x: x - halfW, z: z + halfD,
|
|
353
|
-
{ x: x + halfW, z: z + halfD,
|
|
356
|
+
{ x: x - halfW, z: z - halfD, fullHeight, staggerDelayMs, buildingIndex },
|
|
357
|
+
{ x: x + halfW, z: z - halfD, fullHeight, staggerDelayMs, buildingIndex },
|
|
358
|
+
{ x: x - halfW, z: z + halfD, fullHeight, staggerDelayMs, buildingIndex },
|
|
359
|
+
{ x: x + halfW, z: z + halfD, fullHeight, staggerDelayMs, buildingIndex },
|
|
354
360
|
];
|
|
355
361
|
});
|
|
356
362
|
}, [buildings]);
|
|
@@ -367,7 +373,10 @@ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springD
|
|
|
367
373
|
const animStartTime = startTimeRef.current ?? currentTime;
|
|
368
374
|
|
|
369
375
|
edgeData.forEach((edge, idx) => {
|
|
370
|
-
const { x, z,
|
|
376
|
+
const { x, z, fullHeight, staggerDelayMs, buildingIndex } = edge;
|
|
377
|
+
|
|
378
|
+
// Get height multiplier from shared ref (for collapse animation)
|
|
379
|
+
const heightMultiplier = heightMultipliersRef.current?.[buildingIndex] ?? 1;
|
|
371
380
|
|
|
372
381
|
// Calculate per-building animation progress
|
|
373
382
|
const elapsed = currentTime - animStartTime - staggerDelayMs;
|
|
@@ -381,7 +390,8 @@ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springD
|
|
|
381
390
|
animProgress = 0;
|
|
382
391
|
}
|
|
383
392
|
|
|
384
|
-
|
|
393
|
+
// Apply both grow animation and collapse multiplier
|
|
394
|
+
const height = animProgress * fullHeight * heightMultiplier + minHeight;
|
|
385
395
|
const yPosition = height / 2 + baseOffset;
|
|
386
396
|
|
|
387
397
|
tempObject.position.set(x, yPosition, z);
|
|
@@ -416,13 +426,18 @@ interface InstancedBuildingsProps {
|
|
|
416
426
|
hoveredIndex: number | null;
|
|
417
427
|
growProgress: number;
|
|
418
428
|
animationConfig: AnimationConfig;
|
|
419
|
-
highlightLayers: HighlightLayer[];
|
|
420
|
-
isolationMode: IsolationMode;
|
|
421
|
-
hasActiveHighlights: boolean;
|
|
422
|
-
dimOpacity: number;
|
|
423
429
|
heightScaling: HeightScaling;
|
|
424
430
|
linearScale: number;
|
|
425
431
|
staggerIndices: number[];
|
|
432
|
+
focusDirectory: string | null;
|
|
433
|
+
highlightLayers: HighlightLayer[];
|
|
434
|
+
isolationMode: IsolationMode;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Helper to check if a path is inside a directory
|
|
438
|
+
function isPathInDirectory(path: string, directory: string | null): boolean {
|
|
439
|
+
if (!directory) return true;
|
|
440
|
+
return path === directory || path.startsWith(directory + '/');
|
|
426
441
|
}
|
|
427
442
|
|
|
428
443
|
function InstancedBuildings({
|
|
@@ -433,91 +448,116 @@ function InstancedBuildings({
|
|
|
433
448
|
hoveredIndex,
|
|
434
449
|
growProgress,
|
|
435
450
|
animationConfig,
|
|
436
|
-
highlightLayers,
|
|
437
|
-
isolationMode,
|
|
438
|
-
hasActiveHighlights,
|
|
439
|
-
dimOpacity,
|
|
440
451
|
heightScaling,
|
|
441
452
|
linearScale,
|
|
442
453
|
staggerIndices,
|
|
454
|
+
focusDirectory,
|
|
455
|
+
highlightLayers,
|
|
456
|
+
isolationMode,
|
|
443
457
|
}: InstancedBuildingsProps) {
|
|
444
458
|
const meshRef = useRef<THREE.InstancedMesh>(null);
|
|
445
459
|
const startTimeRef = useRef<number | null>(null);
|
|
446
460
|
const tempObject = useMemo(() => new THREE.Object3D(), []);
|
|
447
461
|
const tempColor = useMemo(() => new THREE.Color(), []);
|
|
448
462
|
|
|
463
|
+
// Track animated height multipliers for each building (for collapse animation)
|
|
464
|
+
const heightMultipliersRef = useRef<Float32Array | null>(null);
|
|
465
|
+
const targetMultipliersRef = useRef<Float32Array | null>(null);
|
|
466
|
+
|
|
467
|
+
// Check if highlight layers have any active items
|
|
468
|
+
const hasActiveHighlightLayers = useMemo(() => {
|
|
469
|
+
return highlightLayers.some(layer => layer.enabled && layer.items.length > 0);
|
|
470
|
+
}, [highlightLayers]);
|
|
471
|
+
|
|
472
|
+
// Initialize height multiplier arrays
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
if (buildings.length > 0) {
|
|
475
|
+
if (
|
|
476
|
+
!heightMultipliersRef.current ||
|
|
477
|
+
heightMultipliersRef.current.length !== buildings.length
|
|
478
|
+
) {
|
|
479
|
+
heightMultipliersRef.current = new Float32Array(buildings.length).fill(1);
|
|
480
|
+
targetMultipliersRef.current = new Float32Array(buildings.length).fill(1);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}, [buildings.length]);
|
|
484
|
+
|
|
485
|
+
// Update target multipliers when focusDirectory or highlightLayers change
|
|
486
|
+
useEffect(() => {
|
|
487
|
+
if (!targetMultipliersRef.current) return;
|
|
488
|
+
|
|
489
|
+
buildings.forEach((building, index) => {
|
|
490
|
+
let shouldCollapse = false;
|
|
491
|
+
|
|
492
|
+
// Priority 1: focusDirectory - collapse buildings outside
|
|
493
|
+
if (focusDirectory) {
|
|
494
|
+
const isInFocus = isPathInDirectory(building.path, focusDirectory);
|
|
495
|
+
shouldCollapse = !isInFocus;
|
|
496
|
+
}
|
|
497
|
+
// Priority 2: highlightLayers with collapse isolation mode
|
|
498
|
+
else if (hasActiveHighlightLayers && isolationMode === 'collapse') {
|
|
499
|
+
const highlight = getHighlightForPath(building.path, highlightLayers);
|
|
500
|
+
shouldCollapse = highlight === null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
targetMultipliersRef.current![index] = shouldCollapse ? 0.05 : 1;
|
|
504
|
+
});
|
|
505
|
+
}, [focusDirectory, buildings, highlightLayers, isolationMode, hasActiveHighlightLayers]);
|
|
506
|
+
|
|
449
507
|
// Pre-compute building data
|
|
450
508
|
const buildingData = useMemo(() => {
|
|
451
509
|
return buildings.map((building, index) => {
|
|
452
510
|
const [width, , depth] = building.dimensions;
|
|
453
|
-
const
|
|
454
|
-
const
|
|
455
|
-
const shouldDim = hasActiveHighlights && !isHighlighted;
|
|
456
|
-
const shouldCollapse = shouldDim && isolationMode === 'collapse';
|
|
457
|
-
const shouldHide = shouldDim && isolationMode === 'hide';
|
|
458
|
-
|
|
459
|
-
const fullHeight = calculateBuildingHeight(
|
|
460
|
-
building,
|
|
461
|
-
heightScaling,
|
|
462
|
-
linearScale
|
|
463
|
-
);
|
|
464
|
-
const targetHeight = shouldCollapse ? 0.5 : fullHeight;
|
|
465
|
-
|
|
466
|
-
const baseColor = getColorForFile(building);
|
|
467
|
-
const color = isHighlighted ? highlight.color : baseColor;
|
|
511
|
+
const fullHeight = calculateBuildingHeight(building, heightScaling, linearScale);
|
|
512
|
+
const color = getColorForFile(building);
|
|
468
513
|
|
|
469
514
|
const x = building.position.x - centerOffset.x;
|
|
470
515
|
const z = building.position.z - centerOffset.z;
|
|
471
516
|
|
|
472
517
|
const staggerIndex = staggerIndices[index] ?? index;
|
|
473
|
-
const staggerDelayMs =
|
|
474
|
-
(animationConfig.staggerDelay || 15) * staggerIndex;
|
|
518
|
+
const staggerDelayMs = (animationConfig.staggerDelay || 15) * staggerIndex;
|
|
475
519
|
|
|
476
520
|
return {
|
|
477
521
|
building,
|
|
478
522
|
index,
|
|
479
523
|
width,
|
|
480
524
|
depth,
|
|
481
|
-
|
|
525
|
+
fullHeight,
|
|
482
526
|
color,
|
|
483
527
|
x,
|
|
484
528
|
z,
|
|
485
|
-
shouldHide,
|
|
486
|
-
shouldDim,
|
|
487
529
|
staggerDelayMs,
|
|
488
|
-
isHighlighted,
|
|
489
530
|
};
|
|
490
531
|
});
|
|
491
532
|
}, [
|
|
492
533
|
buildings,
|
|
493
534
|
centerOffset,
|
|
494
|
-
highlightLayers,
|
|
495
|
-
hasActiveHighlights,
|
|
496
|
-
isolationMode,
|
|
497
535
|
heightScaling,
|
|
498
536
|
linearScale,
|
|
499
537
|
staggerIndices,
|
|
500
538
|
animationConfig.staggerDelay,
|
|
501
539
|
]);
|
|
502
540
|
|
|
503
|
-
const visibleBuildings = useMemo(
|
|
504
|
-
() => buildingData.filter((b) => !b.shouldHide),
|
|
505
|
-
[buildingData]
|
|
506
|
-
);
|
|
507
|
-
|
|
508
541
|
const minHeight = 0.3;
|
|
509
542
|
const baseOffset = 0.2;
|
|
510
543
|
const tension = animationConfig.tension || 120;
|
|
511
544
|
const friction = animationConfig.friction || 14;
|
|
512
545
|
const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
|
|
513
546
|
|
|
547
|
+
// Initialize all buildings (only on first render or when building data changes)
|
|
548
|
+
// DO NOT include focusDirectory here - that would bypass the animation
|
|
549
|
+
const initializedRef = useRef(false);
|
|
550
|
+
|
|
514
551
|
useEffect(() => {
|
|
515
|
-
if (!meshRef.current) return;
|
|
552
|
+
if (!meshRef.current || buildingData.length === 0) return;
|
|
553
|
+
|
|
554
|
+
buildingData.forEach((data, instanceIndex) => {
|
|
555
|
+
const { width, depth, x, z, color, fullHeight } = data;
|
|
516
556
|
|
|
517
|
-
|
|
518
|
-
const
|
|
557
|
+
// Use the current animated multiplier, or default to 1 on first render
|
|
558
|
+
const multiplier = heightMultipliersRef.current?.[instanceIndex] ?? 1;
|
|
519
559
|
|
|
520
|
-
const height = growProgress *
|
|
560
|
+
const height = growProgress * fullHeight * multiplier + minHeight;
|
|
521
561
|
const yPosition = height / 2 + baseOffset;
|
|
522
562
|
|
|
523
563
|
tempObject.position.set(x, yPosition, z);
|
|
@@ -534,17 +574,14 @@ function InstancedBuildings({
|
|
|
534
574
|
if (meshRef.current.instanceColor) {
|
|
535
575
|
meshRef.current.instanceColor.needsUpdate = true;
|
|
536
576
|
}
|
|
537
|
-
}, [
|
|
538
|
-
visibleBuildings,
|
|
539
|
-
growProgress,
|
|
540
|
-
tempObject,
|
|
541
|
-
tempColor,
|
|
542
|
-
minHeight,
|
|
543
|
-
baseOffset,
|
|
544
|
-
]);
|
|
545
577
|
|
|
578
|
+
initializedRef.current = true;
|
|
579
|
+
}, [buildingData, growProgress, tempObject, tempColor, minHeight, baseOffset]);
|
|
580
|
+
|
|
581
|
+
// Animate buildings each frame
|
|
546
582
|
useFrame(({ clock }) => {
|
|
547
|
-
if (!meshRef.current) return;
|
|
583
|
+
if (!meshRef.current || buildingData.length === 0) return;
|
|
584
|
+
if (!heightMultipliersRef.current || !targetMultipliersRef.current) return;
|
|
548
585
|
|
|
549
586
|
if (startTimeRef.current === null && growProgress > 0) {
|
|
550
587
|
startTimeRef.current = clock.elapsedTime * 1000;
|
|
@@ -553,10 +590,20 @@ function InstancedBuildings({
|
|
|
553
590
|
const currentTime = clock.elapsedTime * 1000;
|
|
554
591
|
const animStartTime = startTimeRef.current ?? currentTime;
|
|
555
592
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
data;
|
|
593
|
+
// Animation speed for collapse/expand (lerp factor per frame)
|
|
594
|
+
const collapseSpeed = 0.08;
|
|
559
595
|
|
|
596
|
+
buildingData.forEach((data, instanceIndex) => {
|
|
597
|
+
const { width, depth, fullHeight, x, z, staggerDelayMs } = data;
|
|
598
|
+
|
|
599
|
+
// Animate height multiplier towards target
|
|
600
|
+
const currentMultiplier = heightMultipliersRef.current![instanceIndex];
|
|
601
|
+
const targetMultiplier = targetMultipliersRef.current![instanceIndex];
|
|
602
|
+
const newMultiplier =
|
|
603
|
+
currentMultiplier + (targetMultiplier - currentMultiplier) * collapseSpeed;
|
|
604
|
+
heightMultipliersRef.current![instanceIndex] = newMultiplier;
|
|
605
|
+
|
|
606
|
+
// Calculate grow animation progress
|
|
560
607
|
const elapsed = currentTime - animStartTime - staggerDelayMs;
|
|
561
608
|
let animProgress = growProgress;
|
|
562
609
|
|
|
@@ -568,7 +615,8 @@ function InstancedBuildings({
|
|
|
568
615
|
animProgress = 0;
|
|
569
616
|
}
|
|
570
617
|
|
|
571
|
-
|
|
618
|
+
// Apply both grow animation and collapse multiplier
|
|
619
|
+
const height = animProgress * fullHeight * newMultiplier + minHeight;
|
|
572
620
|
const yPosition = height / 2 + baseOffset;
|
|
573
621
|
|
|
574
622
|
const isHovered = hoveredIndex === data.index;
|
|
@@ -580,11 +628,15 @@ function InstancedBuildings({
|
|
|
580
628
|
|
|
581
629
|
meshRef.current!.setMatrixAt(instanceIndex, tempObject.matrix);
|
|
582
630
|
|
|
583
|
-
|
|
584
|
-
shouldDim && isolationMode === 'transparent' ? dimOpacity : 1;
|
|
631
|
+
// Desaturate collapsed buildings
|
|
585
632
|
tempColor.set(data.color);
|
|
586
|
-
if (
|
|
587
|
-
|
|
633
|
+
if (newMultiplier < 0.5) {
|
|
634
|
+
// Lerp towards gray based on collapse amount
|
|
635
|
+
const grayAmount = 1 - newMultiplier * 2; // 0 at multiplier=0.5, 1 at multiplier=0
|
|
636
|
+
const gray = 0.3;
|
|
637
|
+
tempColor.r = tempColor.r * (1 - grayAmount) + gray * grayAmount;
|
|
638
|
+
tempColor.g = tempColor.g * (1 - grayAmount) + gray * grayAmount;
|
|
639
|
+
tempColor.b = tempColor.b * (1 - grayAmount) + gray * grayAmount;
|
|
588
640
|
}
|
|
589
641
|
if (isHovered) {
|
|
590
642
|
tempColor.multiplyScalar(1.2);
|
|
@@ -601,15 +653,12 @@ function InstancedBuildings({
|
|
|
601
653
|
const handlePointerMove = useCallback(
|
|
602
654
|
(e: ThreeEvent<PointerEvent>) => {
|
|
603
655
|
e.stopPropagation();
|
|
604
|
-
if (
|
|
605
|
-
e.instanceId
|
|
606
|
-
e.instanceId < visibleBuildings.length
|
|
607
|
-
) {
|
|
608
|
-
const data = visibleBuildings[e.instanceId];
|
|
656
|
+
if (e.instanceId !== undefined && e.instanceId < buildingData.length) {
|
|
657
|
+
const data = buildingData[e.instanceId];
|
|
609
658
|
onHover?.(data.building);
|
|
610
659
|
}
|
|
611
660
|
},
|
|
612
|
-
[
|
|
661
|
+
[buildingData, onHover],
|
|
613
662
|
);
|
|
614
663
|
|
|
615
664
|
const handlePointerOut = useCallback(() => {
|
|
@@ -619,25 +668,22 @@ function InstancedBuildings({
|
|
|
619
668
|
const handleClick = useCallback(
|
|
620
669
|
(e: ThreeEvent<MouseEvent>) => {
|
|
621
670
|
e.stopPropagation();
|
|
622
|
-
if (
|
|
623
|
-
e.instanceId
|
|
624
|
-
e.instanceId < visibleBuildings.length
|
|
625
|
-
) {
|
|
626
|
-
const data = visibleBuildings[e.instanceId];
|
|
671
|
+
if (e.instanceId !== undefined && e.instanceId < buildingData.length) {
|
|
672
|
+
const data = buildingData[e.instanceId];
|
|
627
673
|
onClick?.(data.building);
|
|
628
674
|
}
|
|
629
675
|
},
|
|
630
|
-
[
|
|
676
|
+
[buildingData, onClick],
|
|
631
677
|
);
|
|
632
678
|
|
|
633
|
-
if (
|
|
679
|
+
if (buildingData.length === 0) return null;
|
|
634
680
|
|
|
635
681
|
return (
|
|
636
682
|
<group>
|
|
637
|
-
{/*
|
|
683
|
+
{/* All buildings - single mesh, original colors */}
|
|
638
684
|
<instancedMesh
|
|
639
685
|
ref={meshRef}
|
|
640
|
-
args={[undefined, undefined,
|
|
686
|
+
args={[undefined, undefined, buildingData.length]}
|
|
641
687
|
onPointerMove={handlePointerMove}
|
|
642
688
|
onPointerOut={handlePointerOut}
|
|
643
689
|
onClick={handleClick}
|
|
@@ -647,13 +693,22 @@ function InstancedBuildings({
|
|
|
647
693
|
<meshStandardMaterial metalness={0.1} roughness={0.35} />
|
|
648
694
|
</instancedMesh>
|
|
649
695
|
|
|
650
|
-
{/* Building edge outlines
|
|
696
|
+
{/* Building edge outlines */}
|
|
651
697
|
<BuildingEdges
|
|
652
|
-
buildings={
|
|
698
|
+
buildings={buildingData.map(d => ({
|
|
699
|
+
width: d.width,
|
|
700
|
+
depth: d.depth,
|
|
701
|
+
fullHeight: d.fullHeight,
|
|
702
|
+
x: d.x,
|
|
703
|
+
z: d.z,
|
|
704
|
+
staggerDelayMs: d.staggerDelayMs,
|
|
705
|
+
buildingIndex: d.index,
|
|
706
|
+
}))}
|
|
653
707
|
growProgress={growProgress}
|
|
654
708
|
minHeight={minHeight}
|
|
655
709
|
baseOffset={baseOffset}
|
|
656
710
|
springDuration={springDuration}
|
|
711
|
+
heightMultipliersRef={heightMultipliersRef}
|
|
657
712
|
/>
|
|
658
713
|
</group>
|
|
659
714
|
);
|
|
@@ -741,11 +796,7 @@ function AnimatedIcon({
|
|
|
741
796
|
});
|
|
742
797
|
|
|
743
798
|
return (
|
|
744
|
-
<sprite
|
|
745
|
-
ref={spriteRef}
|
|
746
|
-
position={[x, 0, z]}
|
|
747
|
-
scale={[iconSize, iconSize, 1]}
|
|
748
|
-
>
|
|
799
|
+
<sprite ref={spriteRef} position={[x, 0, z]} scale={[iconSize, iconSize, 1]}>
|
|
749
800
|
<spriteMaterial
|
|
750
801
|
ref={materialRef}
|
|
751
802
|
map={texture}
|
|
@@ -806,49 +857,61 @@ function BuildingIcons({
|
|
|
806
857
|
};
|
|
807
858
|
})
|
|
808
859
|
.filter(Boolean) as Array<{
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
}, [
|
|
860
|
+
building: CityBuilding;
|
|
861
|
+
config: FileConfigResult;
|
|
862
|
+
x: number;
|
|
863
|
+
z: number;
|
|
864
|
+
targetHeight: number;
|
|
865
|
+
shouldDim: boolean;
|
|
866
|
+
staggerDelayMs: number;
|
|
867
|
+
}>;
|
|
868
|
+
}, [
|
|
869
|
+
buildings,
|
|
870
|
+
centerOffset,
|
|
871
|
+
highlightLayers,
|
|
872
|
+
isolationMode,
|
|
873
|
+
hasActiveHighlights,
|
|
874
|
+
heightScaling,
|
|
875
|
+
linearScale,
|
|
876
|
+
staggerIndices,
|
|
877
|
+
staggerDelay,
|
|
878
|
+
]);
|
|
818
879
|
|
|
819
880
|
// Don't render if no progress yet
|
|
820
881
|
if (growProgress < 0.1) return null;
|
|
821
882
|
|
|
822
883
|
return (
|
|
823
884
|
<>
|
|
824
|
-
{buildingsWithIcons.map(
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
885
|
+
{buildingsWithIcons.map(
|
|
886
|
+
({ building, config, x, z, targetHeight, shouldDim, staggerDelayMs }) => {
|
|
887
|
+
const icon = config.icon!;
|
|
888
|
+
const texture = getIconTexture(icon.name, icon.color || '#ffffff');
|
|
889
|
+
if (!texture) return null;
|
|
890
|
+
|
|
891
|
+
// Icon size based on building dimensions
|
|
892
|
+
const [width] = building.dimensions;
|
|
893
|
+
const baseSize = Math.max(width * 0.8, 6);
|
|
894
|
+
const heightBoost = Math.min(targetHeight / 20, 3);
|
|
895
|
+
const iconSize = (baseSize + heightBoost) * (icon.size || 1);
|
|
896
|
+
|
|
897
|
+
const opacity = shouldDim && isolationMode === 'transparent' ? 0.3 : 1;
|
|
898
|
+
|
|
899
|
+
return (
|
|
900
|
+
<AnimatedIcon
|
|
901
|
+
key={building.path}
|
|
902
|
+
x={x}
|
|
903
|
+
z={z}
|
|
904
|
+
targetHeight={targetHeight}
|
|
905
|
+
iconSize={iconSize}
|
|
906
|
+
texture={texture}
|
|
907
|
+
opacity={opacity}
|
|
908
|
+
growProgress={growProgress}
|
|
909
|
+
staggerDelayMs={staggerDelayMs}
|
|
910
|
+
springDuration={springDuration}
|
|
911
|
+
/>
|
|
912
|
+
);
|
|
913
|
+
},
|
|
914
|
+
)}
|
|
852
915
|
</>
|
|
853
916
|
);
|
|
854
917
|
}
|
|
@@ -860,11 +923,7 @@ interface DistrictFloorProps {
|
|
|
860
923
|
opacity: number;
|
|
861
924
|
}
|
|
862
925
|
|
|
863
|
-
function DistrictFloor({
|
|
864
|
-
district,
|
|
865
|
-
centerOffset,
|
|
866
|
-
opacity,
|
|
867
|
-
}: DistrictFloorProps) {
|
|
926
|
+
function DistrictFloor({ district, centerOffset, opacity }: DistrictFloorProps) {
|
|
868
927
|
const { worldBounds } = district;
|
|
869
928
|
const width = worldBounds.maxX - worldBounds.minX;
|
|
870
929
|
const depth = worldBounds.maxZ - worldBounds.minZ;
|
|
@@ -878,15 +937,8 @@ function DistrictFloor({
|
|
|
878
937
|
|
|
879
938
|
return (
|
|
880
939
|
<group position={[centerX, 0, centerZ]}>
|
|
881
|
-
<lineSegments
|
|
882
|
-
|
|
883
|
-
position={[0, floorY, 0]}
|
|
884
|
-
renderOrder={-1}
|
|
885
|
-
>
|
|
886
|
-
<edgesGeometry
|
|
887
|
-
args={[new THREE.PlaneGeometry(width, depth)]}
|
|
888
|
-
attach="geometry"
|
|
889
|
-
/>
|
|
940
|
+
<lineSegments rotation={[-Math.PI / 2, 0, 0]} position={[0, floorY, 0]} renderOrder={-1}>
|
|
941
|
+
<edgesGeometry args={[new THREE.PlaneGeometry(width, depth)]} attach="geometry" />
|
|
890
942
|
<lineBasicMaterial color="#475569" depthWrite={false} />
|
|
891
943
|
</lineSegments>
|
|
892
944
|
|
|
@@ -909,9 +961,16 @@ function DistrictFloor({
|
|
|
909
961
|
}
|
|
910
962
|
|
|
911
963
|
// Camera controller
|
|
964
|
+
interface FocusTarget {
|
|
965
|
+
x: number;
|
|
966
|
+
z: number;
|
|
967
|
+
size: number; // Approximate size of the focused area
|
|
968
|
+
}
|
|
969
|
+
|
|
912
970
|
interface AnimatedCameraProps {
|
|
913
971
|
citySize: number;
|
|
914
972
|
isFlat: boolean;
|
|
973
|
+
focusTarget?: FocusTarget | null;
|
|
915
974
|
}
|
|
916
975
|
|
|
917
976
|
let cameraResetFn: (() => void) | null = null;
|
|
@@ -920,11 +979,59 @@ export function resetCamera() {
|
|
|
920
979
|
cameraResetFn?.();
|
|
921
980
|
}
|
|
922
981
|
|
|
923
|
-
function AnimatedCamera({ citySize, isFlat }: AnimatedCameraProps) {
|
|
982
|
+
function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps) {
|
|
924
983
|
const { camera } = useThree();
|
|
925
984
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
926
985
|
const controlsRef = useRef<any>(null);
|
|
927
986
|
|
|
987
|
+
// Animated camera position and target
|
|
988
|
+
const targetPos = useMemo(() => {
|
|
989
|
+
if (focusTarget) {
|
|
990
|
+
// Position camera to look at focus target
|
|
991
|
+
const distance = Math.max(focusTarget.size * 2, 50);
|
|
992
|
+
const height = Math.max(focusTarget.size * 1.5, 40);
|
|
993
|
+
return {
|
|
994
|
+
x: focusTarget.x,
|
|
995
|
+
y: height,
|
|
996
|
+
z: focusTarget.z + distance,
|
|
997
|
+
targetX: focusTarget.x,
|
|
998
|
+
targetY: 0,
|
|
999
|
+
targetZ: focusTarget.z,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
// Default: overview of entire city
|
|
1003
|
+
const targetHeight = isFlat ? citySize * 1.5 : citySize * 1.1;
|
|
1004
|
+
const targetZ = isFlat ? 0 : citySize * 1.3;
|
|
1005
|
+
return {
|
|
1006
|
+
x: 0,
|
|
1007
|
+
y: targetHeight,
|
|
1008
|
+
z: targetZ,
|
|
1009
|
+
targetX: 0,
|
|
1010
|
+
targetY: 0,
|
|
1011
|
+
targetZ: 0,
|
|
1012
|
+
};
|
|
1013
|
+
}, [focusTarget, isFlat, citySize]);
|
|
1014
|
+
|
|
1015
|
+
// Spring animation for camera movement
|
|
1016
|
+
const { camX, camY, camZ, lookX, lookY, lookZ } = useSpring({
|
|
1017
|
+
camX: targetPos.x,
|
|
1018
|
+
camY: targetPos.y,
|
|
1019
|
+
camZ: targetPos.z,
|
|
1020
|
+
lookX: targetPos.targetX,
|
|
1021
|
+
lookY: targetPos.targetY,
|
|
1022
|
+
lookZ: targetPos.targetZ,
|
|
1023
|
+
config: { tension: 60, friction: 20 },
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
// Update camera each frame based on spring values
|
|
1027
|
+
useFrame(() => {
|
|
1028
|
+
if (!controlsRef.current) return;
|
|
1029
|
+
|
|
1030
|
+
camera.position.set(camX.get(), camY.get(), camZ.get());
|
|
1031
|
+
controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
|
|
1032
|
+
controlsRef.current.update();
|
|
1033
|
+
});
|
|
1034
|
+
|
|
928
1035
|
const resetToInitial = useCallback(() => {
|
|
929
1036
|
const targetHeight = isFlat ? citySize * 1.5 : citySize * 1.1;
|
|
930
1037
|
const targetZ = isFlat ? 0 : citySize * 1.3;
|
|
@@ -939,8 +1046,10 @@ function AnimatedCamera({ citySize, isFlat }: AnimatedCameraProps) {
|
|
|
939
1046
|
}, [isFlat, citySize, camera]);
|
|
940
1047
|
|
|
941
1048
|
useEffect(() => {
|
|
942
|
-
|
|
943
|
-
|
|
1049
|
+
if (!focusTarget) {
|
|
1050
|
+
resetToInitial();
|
|
1051
|
+
}
|
|
1052
|
+
}, [resetToInitial, focusTarget]);
|
|
944
1053
|
|
|
945
1054
|
useEffect(() => {
|
|
946
1055
|
cameraResetFn = resetToInitial;
|
|
@@ -974,8 +1083,7 @@ function InfoPanel({ building }: InfoPanelProps) {
|
|
|
974
1083
|
|
|
975
1084
|
const fileName = building.path.split('/').pop();
|
|
976
1085
|
const dirPath = building.path.split('/').slice(0, -1).join('/');
|
|
977
|
-
const rawExt =
|
|
978
|
-
building.fileExtension || building.path.split('.').pop() || '';
|
|
1086
|
+
const rawExt = building.fileExtension || building.path.split('.').pop() || '';
|
|
979
1087
|
const ext = rawExt.replace(/^\./, '');
|
|
980
1088
|
const isCode = isCodeFile(ext);
|
|
981
1089
|
|
|
@@ -1010,9 +1118,7 @@ function InfoPanel({ building }: InfoPanelProps) {
|
|
|
1010
1118
|
{building.lineCount !== undefined && (
|
|
1011
1119
|
<span>{building.lineCount.toLocaleString()} lines</span>
|
|
1012
1120
|
)}
|
|
1013
|
-
{building.size !== undefined && (
|
|
1014
|
-
<span>{(building.size / 1024).toFixed(1)} KB</span>
|
|
1015
|
-
)}
|
|
1121
|
+
{building.size !== undefined && <span>{(building.size / 1024).toFixed(1)} KB</span>}
|
|
1016
1122
|
</div>
|
|
1017
1123
|
</div>
|
|
1018
1124
|
);
|
|
@@ -1025,11 +1131,7 @@ interface ControlsOverlayProps {
|
|
|
1025
1131
|
onResetCamera: () => void;
|
|
1026
1132
|
}
|
|
1027
1133
|
|
|
1028
|
-
function ControlsOverlay({
|
|
1029
|
-
isFlat,
|
|
1030
|
-
onToggle,
|
|
1031
|
-
onResetCamera,
|
|
1032
|
-
}: ControlsOverlayProps) {
|
|
1134
|
+
function ControlsOverlay({ isFlat, onToggle, onResetCamera }: ControlsOverlayProps) {
|
|
1033
1135
|
const buttonStyle = {
|
|
1034
1136
|
background: 'rgba(15, 23, 42, 0.9)',
|
|
1035
1137
|
border: '1px solid #334155',
|
|
@@ -1073,9 +1175,9 @@ interface CitySceneProps {
|
|
|
1073
1175
|
animationConfig: AnimationConfig;
|
|
1074
1176
|
highlightLayers: HighlightLayer[];
|
|
1075
1177
|
isolationMode: IsolationMode;
|
|
1076
|
-
dimOpacity: number;
|
|
1077
1178
|
heightScaling: HeightScaling;
|
|
1078
1179
|
linearScale: number;
|
|
1180
|
+
focusDirectory: string | null;
|
|
1079
1181
|
}
|
|
1080
1182
|
|
|
1081
1183
|
function CityScene({
|
|
@@ -1087,27 +1189,177 @@ function CityScene({
|
|
|
1087
1189
|
animationConfig,
|
|
1088
1190
|
highlightLayers,
|
|
1089
1191
|
isolationMode,
|
|
1090
|
-
dimOpacity,
|
|
1091
1192
|
heightScaling,
|
|
1092
1193
|
linearScale,
|
|
1194
|
+
focusDirectory,
|
|
1093
1195
|
}: CitySceneProps) {
|
|
1094
1196
|
const centerOffset = useMemo(
|
|
1095
1197
|
() => ({
|
|
1096
1198
|
x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
|
|
1097
1199
|
z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
|
|
1098
1200
|
}),
|
|
1099
|
-
[cityData.bounds]
|
|
1201
|
+
[cityData.bounds],
|
|
1100
1202
|
);
|
|
1101
1203
|
|
|
1102
1204
|
const citySize = Math.max(
|
|
1103
1205
|
cityData.bounds.maxX - cityData.bounds.minX,
|
|
1104
|
-
cityData.bounds.maxZ - cityData.bounds.minZ
|
|
1206
|
+
cityData.bounds.maxZ - cityData.bounds.minZ,
|
|
1105
1207
|
);
|
|
1106
1208
|
|
|
1107
|
-
const activeHighlights = useMemo(
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
)
|
|
1209
|
+
const activeHighlights = useMemo(() => hasActiveHighlights(highlightLayers), [highlightLayers]);
|
|
1210
|
+
|
|
1211
|
+
// Helper to check if a path is inside a directory
|
|
1212
|
+
const isPathInDirectory = useCallback((path: string, directory: string) => {
|
|
1213
|
+
if (!directory) return true;
|
|
1214
|
+
return path === directory || path.startsWith(directory + '/');
|
|
1215
|
+
}, []);
|
|
1216
|
+
|
|
1217
|
+
// Three-phase animation when switching directories:
|
|
1218
|
+
// Phase 1: Camera zooms out to overview
|
|
1219
|
+
// Phase 2: Buildings collapse/expand
|
|
1220
|
+
// Phase 3: Camera zooms into new directory
|
|
1221
|
+
//
|
|
1222
|
+
// We track two separate states:
|
|
1223
|
+
// - buildingFocusDirectory: controls which buildings are collapsed (passed to InstancedBuildings)
|
|
1224
|
+
// - cameraFocusDirectory: controls camera position (used for focusTarget calculation)
|
|
1225
|
+
const [buildingFocusDirectory, setBuildingFocusDirectory] = useState<string | null>(null);
|
|
1226
|
+
const [cameraFocusDirectory, setCameraFocusDirectory] = useState<string | null>(null);
|
|
1227
|
+
const prevFocusDirectoryRef = useRef<string | null>(null);
|
|
1228
|
+
const animationTimersRef = useRef<NodeJS.Timeout[]>([]);
|
|
1229
|
+
|
|
1230
|
+
useEffect(() => {
|
|
1231
|
+
// Clear any pending timers
|
|
1232
|
+
animationTimersRef.current.forEach(clearTimeout);
|
|
1233
|
+
animationTimersRef.current = [];
|
|
1234
|
+
|
|
1235
|
+
const prevFocus = prevFocusDirectoryRef.current;
|
|
1236
|
+
prevFocusDirectoryRef.current = focusDirectory;
|
|
1237
|
+
|
|
1238
|
+
// No change
|
|
1239
|
+
if (focusDirectory === prevFocus) return;
|
|
1240
|
+
|
|
1241
|
+
// Case 1: Going from overview to a directory (null -> dir)
|
|
1242
|
+
if (prevFocus === null && focusDirectory !== null) {
|
|
1243
|
+
// Phase 1: Collapse buildings immediately
|
|
1244
|
+
setBuildingFocusDirectory(focusDirectory);
|
|
1245
|
+
// Phase 2: After collapse settles, zoom camera in
|
|
1246
|
+
const timer = setTimeout(() => {
|
|
1247
|
+
setCameraFocusDirectory(focusDirectory);
|
|
1248
|
+
}, 600);
|
|
1249
|
+
animationTimersRef.current.push(timer);
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// Case 2: Going from a directory to overview (dir -> null)
|
|
1254
|
+
if (prevFocus !== null && focusDirectory === null) {
|
|
1255
|
+
// Phase 1: Zoom camera out first
|
|
1256
|
+
setCameraFocusDirectory(null);
|
|
1257
|
+
// Phase 2: After zoom-out settles, expand buildings
|
|
1258
|
+
const timer = setTimeout(() => {
|
|
1259
|
+
setBuildingFocusDirectory(null);
|
|
1260
|
+
}, 500);
|
|
1261
|
+
animationTimersRef.current.push(timer);
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Case 3: Switching between directories (dirA -> dirB)
|
|
1266
|
+
if (prevFocus !== null && focusDirectory !== null) {
|
|
1267
|
+
// Phase 1: Zoom camera out
|
|
1268
|
+
setCameraFocusDirectory(null);
|
|
1269
|
+
// Phase 2: After zoom-out, collapse/expand buildings
|
|
1270
|
+
const timer1 = setTimeout(() => {
|
|
1271
|
+
setBuildingFocusDirectory(focusDirectory);
|
|
1272
|
+
}, 500);
|
|
1273
|
+
// Phase 3: After collapse settles, zoom camera into new directory
|
|
1274
|
+
const timer2 = setTimeout(() => {
|
|
1275
|
+
setCameraFocusDirectory(focusDirectory);
|
|
1276
|
+
}, 1100); // 500ms zoom-out + 600ms collapse
|
|
1277
|
+
animationTimersRef.current.push(timer1, timer2);
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
}, [focusDirectory]);
|
|
1281
|
+
|
|
1282
|
+
// Cleanup timers on unmount
|
|
1283
|
+
useEffect(() => {
|
|
1284
|
+
return () => {
|
|
1285
|
+
animationTimersRef.current.forEach(clearTimeout);
|
|
1286
|
+
};
|
|
1287
|
+
}, []);
|
|
1288
|
+
|
|
1289
|
+
// Calculate focus target from cameraFocusDirectory (for camera)
|
|
1290
|
+
const focusTarget = useMemo((): FocusTarget | null => {
|
|
1291
|
+
// Use camera focus directory for camera movement
|
|
1292
|
+
if (cameraFocusDirectory) {
|
|
1293
|
+
const focusedBuildings = cityData.buildings.filter(building =>
|
|
1294
|
+
isPathInDirectory(building.path, cameraFocusDirectory),
|
|
1295
|
+
);
|
|
1296
|
+
|
|
1297
|
+
if (focusedBuildings.length === 0) return null;
|
|
1298
|
+
|
|
1299
|
+
let minX = Infinity,
|
|
1300
|
+
maxX = -Infinity;
|
|
1301
|
+
let minZ = Infinity,
|
|
1302
|
+
maxZ = -Infinity;
|
|
1303
|
+
|
|
1304
|
+
for (const building of focusedBuildings) {
|
|
1305
|
+
const x = building.position.x - centerOffset.x;
|
|
1306
|
+
const z = building.position.z - centerOffset.z;
|
|
1307
|
+
const [width, , depth] = building.dimensions;
|
|
1308
|
+
|
|
1309
|
+
minX = Math.min(minX, x - width / 2);
|
|
1310
|
+
maxX = Math.max(maxX, x + width / 2);
|
|
1311
|
+
minZ = Math.min(minZ, z - depth / 2);
|
|
1312
|
+
maxZ = Math.max(maxZ, z + depth / 2);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const centerX = (minX + maxX) / 2;
|
|
1316
|
+
const centerZ = (minZ + maxZ) / 2;
|
|
1317
|
+
const size = Math.max(maxX - minX, maxZ - minZ);
|
|
1318
|
+
|
|
1319
|
+
return { x: centerX, z: centerZ, size };
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// Priority 2: highlight layers (only if no focusDirectory is pending)
|
|
1323
|
+
// Don't focus on highlights if we're waiting for cameraFocusDirectory to catch up
|
|
1324
|
+
if (!activeHighlights || focusDirectory) return null;
|
|
1325
|
+
|
|
1326
|
+
const highlightedBuildings = cityData.buildings.filter(building => {
|
|
1327
|
+
const highlight = getHighlightForPath(building.path, highlightLayers);
|
|
1328
|
+
return highlight !== null;
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
if (highlightedBuildings.length === 0) return null;
|
|
1332
|
+
|
|
1333
|
+
let minX = Infinity,
|
|
1334
|
+
maxX = -Infinity;
|
|
1335
|
+
let minZ = Infinity,
|
|
1336
|
+
maxZ = -Infinity;
|
|
1337
|
+
|
|
1338
|
+
for (const building of highlightedBuildings) {
|
|
1339
|
+
const x = building.position.x - centerOffset.x;
|
|
1340
|
+
const z = building.position.z - centerOffset.z;
|
|
1341
|
+
const [width, , depth] = building.dimensions;
|
|
1342
|
+
|
|
1343
|
+
minX = Math.min(minX, x - width / 2);
|
|
1344
|
+
maxX = Math.max(maxX, x + width / 2);
|
|
1345
|
+
minZ = Math.min(minZ, z - depth / 2);
|
|
1346
|
+
maxZ = Math.max(maxZ, z + depth / 2);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
const centerX = (minX + maxX) / 2;
|
|
1350
|
+
const centerZ = (minZ + maxZ) / 2;
|
|
1351
|
+
const size = Math.max(maxX - minX, maxZ - minZ);
|
|
1352
|
+
|
|
1353
|
+
return { x: centerX, z: centerZ, size };
|
|
1354
|
+
}, [
|
|
1355
|
+
cameraFocusDirectory,
|
|
1356
|
+
focusDirectory,
|
|
1357
|
+
activeHighlights,
|
|
1358
|
+
cityData.buildings,
|
|
1359
|
+
highlightLayers,
|
|
1360
|
+
centerOffset,
|
|
1361
|
+
isPathInDirectory,
|
|
1362
|
+
]);
|
|
1111
1363
|
|
|
1112
1364
|
const staggerIndices = useMemo(() => {
|
|
1113
1365
|
const centerX = (cityData.bounds.minX + cityData.bounds.maxX) / 2;
|
|
@@ -1116,8 +1368,7 @@ function CityScene({
|
|
|
1116
1368
|
const withDistance = cityData.buildings.map((b, originalIndex) => ({
|
|
1117
1369
|
originalIndex,
|
|
1118
1370
|
distance: Math.sqrt(
|
|
1119
|
-
Math.pow(b.position.x - centerX, 2) +
|
|
1120
|
-
Math.pow(b.position.z - centerZ, 2)
|
|
1371
|
+
Math.pow(b.position.x - centerX, 2) + Math.pow(b.position.z - centerZ, 2),
|
|
1121
1372
|
),
|
|
1122
1373
|
}));
|
|
1123
1374
|
|
|
@@ -1133,7 +1384,7 @@ function CityScene({
|
|
|
1133
1384
|
|
|
1134
1385
|
const hoveredIndex = useMemo(() => {
|
|
1135
1386
|
if (!hoveredBuilding) return null;
|
|
1136
|
-
return cityData.buildings.findIndex(
|
|
1387
|
+
return cityData.buildings.findIndex(b => b.path === hoveredBuilding.path);
|
|
1137
1388
|
}, [hoveredBuilding, cityData.buildings]);
|
|
1138
1389
|
|
|
1139
1390
|
// Calculate spring duration for animation sync
|
|
@@ -1143,13 +1394,10 @@ function CityScene({
|
|
|
1143
1394
|
|
|
1144
1395
|
return (
|
|
1145
1396
|
<>
|
|
1146
|
-
<AnimatedCamera citySize={citySize} isFlat={growProgress === 0} />
|
|
1397
|
+
<AnimatedCamera citySize={citySize} isFlat={growProgress === 0} focusTarget={focusTarget} />
|
|
1147
1398
|
|
|
1148
1399
|
<ambientLight intensity={1.2} />
|
|
1149
|
-
<hemisphereLight
|
|
1150
|
-
args={['#ddeeff', '#667788', 0.8]}
|
|
1151
|
-
position={[0, citySize, 0]}
|
|
1152
|
-
/>
|
|
1400
|
+
<hemisphereLight args={['#ddeeff', '#667788', 0.8]} position={[0, citySize, 0]} />
|
|
1153
1401
|
<directionalLight
|
|
1154
1402
|
position={[citySize, citySize * 1.5, citySize * 0.5]}
|
|
1155
1403
|
intensity={2}
|
|
@@ -1160,12 +1408,9 @@ function CityScene({
|
|
|
1160
1408
|
position={[-citySize * 0.5, citySize * 0.8, -citySize * 0.5]}
|
|
1161
1409
|
intensity={1}
|
|
1162
1410
|
/>
|
|
1163
|
-
<directionalLight
|
|
1164
|
-
position={[citySize * 0.3, citySize, citySize]}
|
|
1165
|
-
intensity={0.6}
|
|
1166
|
-
/>
|
|
1411
|
+
<directionalLight position={[citySize * 0.3, citySize, citySize]} intensity={0.6} />
|
|
1167
1412
|
|
|
1168
|
-
{cityData.districts.map(
|
|
1413
|
+
{cityData.districts.map(district => (
|
|
1169
1414
|
<DistrictFloor
|
|
1170
1415
|
key={district.path}
|
|
1171
1416
|
district={district}
|
|
@@ -1182,13 +1427,12 @@ function CityScene({
|
|
|
1182
1427
|
hoveredIndex={hoveredIndex}
|
|
1183
1428
|
growProgress={growProgress}
|
|
1184
1429
|
animationConfig={animationConfig}
|
|
1185
|
-
highlightLayers={highlightLayers}
|
|
1186
|
-
isolationMode={isolationMode}
|
|
1187
|
-
hasActiveHighlights={activeHighlights}
|
|
1188
|
-
dimOpacity={dimOpacity}
|
|
1189
1430
|
heightScaling={heightScaling}
|
|
1190
1431
|
linearScale={linearScale}
|
|
1191
1432
|
staggerIndices={staggerIndices}
|
|
1433
|
+
focusDirectory={buildingFocusDirectory}
|
|
1434
|
+
highlightLayers={highlightLayers}
|
|
1435
|
+
isolationMode={isolationMode}
|
|
1192
1436
|
/>
|
|
1193
1437
|
|
|
1194
1438
|
<BuildingIcons
|
|
@@ -1249,6 +1493,14 @@ export interface FileCity3DProps {
|
|
|
1249
1493
|
heightScaling?: HeightScaling;
|
|
1250
1494
|
/** Scale factor for linear mode (height per line, default 0.05) */
|
|
1251
1495
|
linearScale?: number;
|
|
1496
|
+
/** Directory path to focus on - buildings outside will collapse */
|
|
1497
|
+
focusDirectory?: string | null;
|
|
1498
|
+
/** Callback when user clicks on a district to navigate */
|
|
1499
|
+
onDirectorySelect?: (directory: string | null) => void;
|
|
1500
|
+
/** Background color for the canvas container */
|
|
1501
|
+
backgroundColor?: string;
|
|
1502
|
+
/** Text color for secondary/placeholder text */
|
|
1503
|
+
textColor?: string;
|
|
1252
1504
|
}
|
|
1253
1505
|
|
|
1254
1506
|
/**
|
|
@@ -1276,20 +1528,17 @@ export function FileCity3D({
|
|
|
1276
1528
|
emptyMessage = 'No file tree data available',
|
|
1277
1529
|
heightScaling = 'logarithmic',
|
|
1278
1530
|
linearScale = 0.05,
|
|
1531
|
+
focusDirectory = null,
|
|
1532
|
+
onDirectorySelect,
|
|
1533
|
+
backgroundColor = '#0f172a',
|
|
1534
|
+
textColor = '#94a3b8',
|
|
1279
1535
|
}: FileCity3DProps) {
|
|
1280
|
-
const
|
|
1281
|
-
const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(
|
|
1282
|
-
null
|
|
1283
|
-
);
|
|
1536
|
+
const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(null);
|
|
1284
1537
|
const [internalIsGrown, setInternalIsGrown] = useState(false);
|
|
1285
1538
|
|
|
1286
|
-
const animationConfig = useMemo(
|
|
1287
|
-
() => ({ ...DEFAULT_ANIMATION, ...animation }),
|
|
1288
|
-
[animation]
|
|
1289
|
-
);
|
|
1539
|
+
const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
|
|
1290
1540
|
|
|
1291
|
-
const isGrown =
|
|
1292
|
-
externalIsGrown !== undefined ? externalIsGrown : internalIsGrown;
|
|
1541
|
+
const isGrown = externalIsGrown !== undefined ? externalIsGrown : internalIsGrown;
|
|
1293
1542
|
const setIsGrown = (value: boolean) => {
|
|
1294
1543
|
setInternalIsGrown(value);
|
|
1295
1544
|
onGrowChange?.(value);
|
|
@@ -1321,12 +1570,12 @@ export function FileCity3D({
|
|
|
1321
1570
|
width,
|
|
1322
1571
|
height,
|
|
1323
1572
|
position: 'relative',
|
|
1324
|
-
background:
|
|
1573
|
+
background: backgroundColor,
|
|
1325
1574
|
overflow: 'hidden',
|
|
1326
1575
|
display: 'flex',
|
|
1327
1576
|
alignItems: 'center',
|
|
1328
1577
|
justifyContent: 'center',
|
|
1329
|
-
color:
|
|
1578
|
+
color: textColor,
|
|
1330
1579
|
fontFamily: 'system-ui, sans-serif',
|
|
1331
1580
|
fontSize: 14,
|
|
1332
1581
|
...style,
|
|
@@ -1345,12 +1594,12 @@ export function FileCity3D({
|
|
|
1345
1594
|
width,
|
|
1346
1595
|
height,
|
|
1347
1596
|
position: 'relative',
|
|
1348
|
-
background:
|
|
1597
|
+
background: backgroundColor,
|
|
1349
1598
|
overflow: 'hidden',
|
|
1350
1599
|
display: 'flex',
|
|
1351
1600
|
alignItems: 'center',
|
|
1352
1601
|
justifyContent: 'center',
|
|
1353
|
-
color:
|
|
1602
|
+
color: textColor,
|
|
1354
1603
|
fontFamily: 'system-ui, sans-serif',
|
|
1355
1604
|
fontSize: 14,
|
|
1356
1605
|
...style,
|
|
@@ -1368,7 +1617,7 @@ export function FileCity3D({
|
|
|
1368
1617
|
width,
|
|
1369
1618
|
height,
|
|
1370
1619
|
position: 'relative',
|
|
1371
|
-
background:
|
|
1620
|
+
background: backgroundColor,
|
|
1372
1621
|
overflow: 'hidden',
|
|
1373
1622
|
...style,
|
|
1374
1623
|
}}
|
|
@@ -1392,18 +1641,14 @@ export function FileCity3D({
|
|
|
1392
1641
|
animationConfig={animationConfig}
|
|
1393
1642
|
highlightLayers={highlightLayers}
|
|
1394
1643
|
isolationMode={isolationMode}
|
|
1395
|
-
dimOpacity={dimOpacity}
|
|
1396
1644
|
heightScaling={heightScaling}
|
|
1397
1645
|
linearScale={linearScale}
|
|
1646
|
+
focusDirectory={focusDirectory}
|
|
1398
1647
|
/>
|
|
1399
1648
|
</Canvas>
|
|
1400
1649
|
<InfoPanel building={hoveredBuilding} />
|
|
1401
1650
|
{showControls && (
|
|
1402
|
-
<ControlsOverlay
|
|
1403
|
-
isFlat={!isGrown}
|
|
1404
|
-
onToggle={handleToggle}
|
|
1405
|
-
onResetCamera={resetCamera}
|
|
1406
|
-
/>
|
|
1651
|
+
<ControlsOverlay isFlat={!isGrown} onToggle={handleToggle} onResetCamera={resetCamera} />
|
|
1407
1652
|
)}
|
|
1408
1653
|
</div>
|
|
1409
1654
|
);
|