@principal-ai/file-city-react 0.5.8 → 0.5.9
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 +5 -1
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +283 -70
- package/package.json +1 -1
- package/src/components/FileCity3D/FileCity3D.tsx +348 -103
- package/src/stories/FileCity3D.stories.tsx +359 -1
|
@@ -310,6 +310,9 @@ function hasActiveHighlights(layers: HighlightLayer[]): boolean {
|
|
|
310
310
|
// Animated RoundedBox wrapper
|
|
311
311
|
const AnimatedRoundedBox = animated(RoundedBox);
|
|
312
312
|
|
|
313
|
+
// Animated meshStandardMaterial for opacity transitions
|
|
314
|
+
const AnimatedMeshStandardMaterial = animated('meshStandardMaterial');
|
|
315
|
+
|
|
313
316
|
// ============================================================================
|
|
314
317
|
// Building Edges - Batched edge rendering for performance
|
|
315
318
|
// ============================================================================
|
|
@@ -317,10 +320,11 @@ const AnimatedRoundedBox = animated(RoundedBox);
|
|
|
317
320
|
interface BuildingEdgeData {
|
|
318
321
|
width: number;
|
|
319
322
|
depth: number;
|
|
320
|
-
|
|
323
|
+
fullHeight: number;
|
|
321
324
|
x: number;
|
|
322
325
|
z: number;
|
|
323
326
|
staggerDelayMs: number;
|
|
327
|
+
buildingIndex: number; // Index to look up height multiplier
|
|
324
328
|
}
|
|
325
329
|
|
|
326
330
|
interface BuildingEdgesProps {
|
|
@@ -329,9 +333,10 @@ interface BuildingEdgesProps {
|
|
|
329
333
|
minHeight: number;
|
|
330
334
|
baseOffset: number;
|
|
331
335
|
springDuration: number;
|
|
336
|
+
heightMultipliersRef: React.MutableRefObject<Float32Array | null>;
|
|
332
337
|
}
|
|
333
338
|
|
|
334
|
-
function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springDuration }: BuildingEdgesProps) {
|
|
339
|
+
function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springDuration, heightMultipliersRef }: BuildingEdgesProps) {
|
|
335
340
|
const meshRef = useRef<THREE.InstancedMesh>(null);
|
|
336
341
|
const startTimeRef = useRef<number | null>(null);
|
|
337
342
|
const tempObject = useMemo(() => new THREE.Object3D(), []);
|
|
@@ -342,15 +347,15 @@ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springD
|
|
|
342
347
|
// Pre-compute edge data
|
|
343
348
|
const edgeData = useMemo(() => {
|
|
344
349
|
return buildings.flatMap((data) => {
|
|
345
|
-
const { width, depth, x, z,
|
|
350
|
+
const { width, depth, x, z, fullHeight, staggerDelayMs, buildingIndex } = data;
|
|
346
351
|
const halfW = width / 2;
|
|
347
352
|
const halfD = depth / 2;
|
|
348
353
|
|
|
349
354
|
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,
|
|
355
|
+
{ x: x - halfW, z: z - halfD, fullHeight, staggerDelayMs, buildingIndex },
|
|
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 },
|
|
354
359
|
];
|
|
355
360
|
});
|
|
356
361
|
}, [buildings]);
|
|
@@ -367,7 +372,10 @@ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springD
|
|
|
367
372
|
const animStartTime = startTimeRef.current ?? currentTime;
|
|
368
373
|
|
|
369
374
|
edgeData.forEach((edge, idx) => {
|
|
370
|
-
const { x, z,
|
|
375
|
+
const { x, z, fullHeight, staggerDelayMs, buildingIndex } = edge;
|
|
376
|
+
|
|
377
|
+
// Get height multiplier from shared ref (for collapse animation)
|
|
378
|
+
const heightMultiplier = heightMultipliersRef.current?.[buildingIndex] ?? 1;
|
|
371
379
|
|
|
372
380
|
// Calculate per-building animation progress
|
|
373
381
|
const elapsed = currentTime - animStartTime - staggerDelayMs;
|
|
@@ -381,7 +389,8 @@ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springD
|
|
|
381
389
|
animProgress = 0;
|
|
382
390
|
}
|
|
383
391
|
|
|
384
|
-
|
|
392
|
+
// Apply both grow animation and collapse multiplier
|
|
393
|
+
const height = animProgress * fullHeight * heightMultiplier + minHeight;
|
|
385
394
|
const yPosition = height / 2 + baseOffset;
|
|
386
395
|
|
|
387
396
|
tempObject.position.set(x, yPosition, z);
|
|
@@ -416,13 +425,18 @@ interface InstancedBuildingsProps {
|
|
|
416
425
|
hoveredIndex: number | null;
|
|
417
426
|
growProgress: number;
|
|
418
427
|
animationConfig: AnimationConfig;
|
|
419
|
-
highlightLayers: HighlightLayer[];
|
|
420
|
-
isolationMode: IsolationMode;
|
|
421
|
-
hasActiveHighlights: boolean;
|
|
422
|
-
dimOpacity: number;
|
|
423
428
|
heightScaling: HeightScaling;
|
|
424
429
|
linearScale: number;
|
|
425
430
|
staggerIndices: number[];
|
|
431
|
+
focusDirectory: string | null;
|
|
432
|
+
highlightLayers: HighlightLayer[];
|
|
433
|
+
isolationMode: IsolationMode;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Helper to check if a path is inside a directory
|
|
437
|
+
function isPathInDirectory(path: string, directory: string | null): boolean {
|
|
438
|
+
if (!directory) return true;
|
|
439
|
+
return path === directory || path.startsWith(directory + '/');
|
|
426
440
|
}
|
|
427
441
|
|
|
428
442
|
function InstancedBuildings({
|
|
@@ -433,77 +447,85 @@ function InstancedBuildings({
|
|
|
433
447
|
hoveredIndex,
|
|
434
448
|
growProgress,
|
|
435
449
|
animationConfig,
|
|
436
|
-
highlightLayers,
|
|
437
|
-
isolationMode,
|
|
438
|
-
hasActiveHighlights,
|
|
439
|
-
dimOpacity,
|
|
440
450
|
heightScaling,
|
|
441
451
|
linearScale,
|
|
442
452
|
staggerIndices,
|
|
453
|
+
focusDirectory,
|
|
454
|
+
highlightLayers,
|
|
455
|
+
isolationMode,
|
|
443
456
|
}: InstancedBuildingsProps) {
|
|
444
457
|
const meshRef = useRef<THREE.InstancedMesh>(null);
|
|
445
458
|
const startTimeRef = useRef<number | null>(null);
|
|
446
459
|
const tempObject = useMemo(() => new THREE.Object3D(), []);
|
|
447
460
|
const tempColor = useMemo(() => new THREE.Color(), []);
|
|
448
461
|
|
|
462
|
+
// Track animated height multipliers for each building (for collapse animation)
|
|
463
|
+
const heightMultipliersRef = useRef<Float32Array | null>(null);
|
|
464
|
+
const targetMultipliersRef = useRef<Float32Array | null>(null);
|
|
465
|
+
|
|
466
|
+
// Check if highlight layers have any active items
|
|
467
|
+
const hasActiveHighlightLayers = useMemo(() => {
|
|
468
|
+
return highlightLayers.some(layer => layer.enabled && layer.items.length > 0);
|
|
469
|
+
}, [highlightLayers]);
|
|
470
|
+
|
|
471
|
+
// Initialize height multiplier arrays
|
|
472
|
+
useEffect(() => {
|
|
473
|
+
if (buildings.length > 0) {
|
|
474
|
+
if (!heightMultipliersRef.current || heightMultipliersRef.current.length !== buildings.length) {
|
|
475
|
+
heightMultipliersRef.current = new Float32Array(buildings.length).fill(1);
|
|
476
|
+
targetMultipliersRef.current = new Float32Array(buildings.length).fill(1);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}, [buildings.length]);
|
|
480
|
+
|
|
481
|
+
// Update target multipliers when focusDirectory or highlightLayers change
|
|
482
|
+
useEffect(() => {
|
|
483
|
+
if (!targetMultipliersRef.current) return;
|
|
484
|
+
|
|
485
|
+
buildings.forEach((building, index) => {
|
|
486
|
+
let shouldCollapse = false;
|
|
487
|
+
|
|
488
|
+
// Priority 1: focusDirectory - collapse buildings outside
|
|
489
|
+
if (focusDirectory) {
|
|
490
|
+
const isInFocus = isPathInDirectory(building.path, focusDirectory);
|
|
491
|
+
shouldCollapse = !isInFocus;
|
|
492
|
+
}
|
|
493
|
+
// Priority 2: highlightLayers with collapse isolation mode
|
|
494
|
+
else if (hasActiveHighlightLayers && isolationMode === 'collapse') {
|
|
495
|
+
const highlight = getHighlightForPath(building.path, highlightLayers);
|
|
496
|
+
shouldCollapse = highlight === null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
targetMultipliersRef.current![index] = shouldCollapse ? 0.05 : 1;
|
|
500
|
+
});
|
|
501
|
+
}, [focusDirectory, buildings, highlightLayers, isolationMode, hasActiveHighlightLayers]);
|
|
502
|
+
|
|
449
503
|
// Pre-compute building data
|
|
450
504
|
const buildingData = useMemo(() => {
|
|
451
505
|
return buildings.map((building, index) => {
|
|
452
506
|
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;
|
|
507
|
+
const fullHeight = calculateBuildingHeight(building, heightScaling, linearScale);
|
|
508
|
+
const color = getColorForFile(building);
|
|
468
509
|
|
|
469
510
|
const x = building.position.x - centerOffset.x;
|
|
470
511
|
const z = building.position.z - centerOffset.z;
|
|
471
512
|
|
|
472
513
|
const staggerIndex = staggerIndices[index] ?? index;
|
|
473
|
-
const staggerDelayMs =
|
|
474
|
-
(animationConfig.staggerDelay || 15) * staggerIndex;
|
|
514
|
+
const staggerDelayMs = (animationConfig.staggerDelay || 15) * staggerIndex;
|
|
475
515
|
|
|
476
516
|
return {
|
|
477
517
|
building,
|
|
478
518
|
index,
|
|
479
519
|
width,
|
|
480
520
|
depth,
|
|
481
|
-
|
|
521
|
+
fullHeight,
|
|
482
522
|
color,
|
|
483
523
|
x,
|
|
484
524
|
z,
|
|
485
|
-
shouldHide,
|
|
486
|
-
shouldDim,
|
|
487
525
|
staggerDelayMs,
|
|
488
|
-
isHighlighted,
|
|
489
526
|
};
|
|
490
527
|
});
|
|
491
|
-
}, [
|
|
492
|
-
buildings,
|
|
493
|
-
centerOffset,
|
|
494
|
-
highlightLayers,
|
|
495
|
-
hasActiveHighlights,
|
|
496
|
-
isolationMode,
|
|
497
|
-
heightScaling,
|
|
498
|
-
linearScale,
|
|
499
|
-
staggerIndices,
|
|
500
|
-
animationConfig.staggerDelay,
|
|
501
|
-
]);
|
|
502
|
-
|
|
503
|
-
const visibleBuildings = useMemo(
|
|
504
|
-
() => buildingData.filter((b) => !b.shouldHide),
|
|
505
|
-
[buildingData]
|
|
506
|
-
);
|
|
528
|
+
}, [buildings, centerOffset, heightScaling, linearScale, staggerIndices, animationConfig.staggerDelay]);
|
|
507
529
|
|
|
508
530
|
const minHeight = 0.3;
|
|
509
531
|
const baseOffset = 0.2;
|
|
@@ -511,13 +533,20 @@ function InstancedBuildings({
|
|
|
511
533
|
const friction = animationConfig.friction || 14;
|
|
512
534
|
const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
|
|
513
535
|
|
|
536
|
+
// Initialize all buildings (only on first render or when building data changes)
|
|
537
|
+
// DO NOT include focusDirectory here - that would bypass the animation
|
|
538
|
+
const initializedRef = useRef(false);
|
|
539
|
+
|
|
514
540
|
useEffect(() => {
|
|
515
|
-
if (!meshRef.current) return;
|
|
541
|
+
if (!meshRef.current || buildingData.length === 0) return;
|
|
516
542
|
|
|
517
|
-
|
|
518
|
-
const { width, depth, x, z, color,
|
|
543
|
+
buildingData.forEach((data, instanceIndex) => {
|
|
544
|
+
const { width, depth, x, z, color, fullHeight } = data;
|
|
519
545
|
|
|
520
|
-
|
|
546
|
+
// Use the current animated multiplier, or default to 1 on first render
|
|
547
|
+
const multiplier = heightMultipliersRef.current?.[instanceIndex] ?? 1;
|
|
548
|
+
|
|
549
|
+
const height = growProgress * fullHeight * multiplier + minHeight;
|
|
521
550
|
const yPosition = height / 2 + baseOffset;
|
|
522
551
|
|
|
523
552
|
tempObject.position.set(x, yPosition, z);
|
|
@@ -534,17 +563,14 @@ function InstancedBuildings({
|
|
|
534
563
|
if (meshRef.current.instanceColor) {
|
|
535
564
|
meshRef.current.instanceColor.needsUpdate = true;
|
|
536
565
|
}
|
|
537
|
-
}, [
|
|
538
|
-
visibleBuildings,
|
|
539
|
-
growProgress,
|
|
540
|
-
tempObject,
|
|
541
|
-
tempColor,
|
|
542
|
-
minHeight,
|
|
543
|
-
baseOffset,
|
|
544
|
-
]);
|
|
545
566
|
|
|
567
|
+
initializedRef.current = true;
|
|
568
|
+
}, [buildingData, growProgress, tempObject, tempColor, minHeight, baseOffset]);
|
|
569
|
+
|
|
570
|
+
// Animate buildings each frame
|
|
546
571
|
useFrame(({ clock }) => {
|
|
547
|
-
if (!meshRef.current) return;
|
|
572
|
+
if (!meshRef.current || buildingData.length === 0) return;
|
|
573
|
+
if (!heightMultipliersRef.current || !targetMultipliersRef.current) return;
|
|
548
574
|
|
|
549
575
|
if (startTimeRef.current === null && growProgress > 0) {
|
|
550
576
|
startTimeRef.current = clock.elapsedTime * 1000;
|
|
@@ -553,10 +579,19 @@ function InstancedBuildings({
|
|
|
553
579
|
const currentTime = clock.elapsedTime * 1000;
|
|
554
580
|
const animStartTime = startTimeRef.current ?? currentTime;
|
|
555
581
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
data;
|
|
582
|
+
// Animation speed for collapse/expand (lerp factor per frame)
|
|
583
|
+
const collapseSpeed = 0.08;
|
|
559
584
|
|
|
585
|
+
buildingData.forEach((data, instanceIndex) => {
|
|
586
|
+
const { width, depth, fullHeight, x, z, staggerDelayMs } = data;
|
|
587
|
+
|
|
588
|
+
// Animate height multiplier towards target
|
|
589
|
+
const currentMultiplier = heightMultipliersRef.current![instanceIndex];
|
|
590
|
+
const targetMultiplier = targetMultipliersRef.current![instanceIndex];
|
|
591
|
+
const newMultiplier = currentMultiplier + (targetMultiplier - currentMultiplier) * collapseSpeed;
|
|
592
|
+
heightMultipliersRef.current![instanceIndex] = newMultiplier;
|
|
593
|
+
|
|
594
|
+
// Calculate grow animation progress
|
|
560
595
|
const elapsed = currentTime - animStartTime - staggerDelayMs;
|
|
561
596
|
let animProgress = growProgress;
|
|
562
597
|
|
|
@@ -568,7 +603,8 @@ function InstancedBuildings({
|
|
|
568
603
|
animProgress = 0;
|
|
569
604
|
}
|
|
570
605
|
|
|
571
|
-
|
|
606
|
+
// Apply both grow animation and collapse multiplier
|
|
607
|
+
const height = animProgress * fullHeight * newMultiplier + minHeight;
|
|
572
608
|
const yPosition = height / 2 + baseOffset;
|
|
573
609
|
|
|
574
610
|
const isHovered = hoveredIndex === data.index;
|
|
@@ -580,11 +616,15 @@ function InstancedBuildings({
|
|
|
580
616
|
|
|
581
617
|
meshRef.current!.setMatrixAt(instanceIndex, tempObject.matrix);
|
|
582
618
|
|
|
583
|
-
|
|
584
|
-
shouldDim && isolationMode === 'transparent' ? dimOpacity : 1;
|
|
619
|
+
// Desaturate collapsed buildings
|
|
585
620
|
tempColor.set(data.color);
|
|
586
|
-
if (
|
|
587
|
-
|
|
621
|
+
if (newMultiplier < 0.5) {
|
|
622
|
+
// Lerp towards gray based on collapse amount
|
|
623
|
+
const grayAmount = 1 - newMultiplier * 2; // 0 at multiplier=0.5, 1 at multiplier=0
|
|
624
|
+
const gray = 0.3;
|
|
625
|
+
tempColor.r = tempColor.r * (1 - grayAmount) + gray * grayAmount;
|
|
626
|
+
tempColor.g = tempColor.g * (1 - grayAmount) + gray * grayAmount;
|
|
627
|
+
tempColor.b = tempColor.b * (1 - grayAmount) + gray * grayAmount;
|
|
588
628
|
}
|
|
589
629
|
if (isHovered) {
|
|
590
630
|
tempColor.multiplyScalar(1.2);
|
|
@@ -601,15 +641,12 @@ function InstancedBuildings({
|
|
|
601
641
|
const handlePointerMove = useCallback(
|
|
602
642
|
(e: ThreeEvent<PointerEvent>) => {
|
|
603
643
|
e.stopPropagation();
|
|
604
|
-
if (
|
|
605
|
-
e.instanceId
|
|
606
|
-
e.instanceId < visibleBuildings.length
|
|
607
|
-
) {
|
|
608
|
-
const data = visibleBuildings[e.instanceId];
|
|
644
|
+
if (e.instanceId !== undefined && e.instanceId < buildingData.length) {
|
|
645
|
+
const data = buildingData[e.instanceId];
|
|
609
646
|
onHover?.(data.building);
|
|
610
647
|
}
|
|
611
648
|
},
|
|
612
|
-
[
|
|
649
|
+
[buildingData, onHover]
|
|
613
650
|
);
|
|
614
651
|
|
|
615
652
|
const handlePointerOut = useCallback(() => {
|
|
@@ -619,25 +656,22 @@ function InstancedBuildings({
|
|
|
619
656
|
const handleClick = useCallback(
|
|
620
657
|
(e: ThreeEvent<MouseEvent>) => {
|
|
621
658
|
e.stopPropagation();
|
|
622
|
-
if (
|
|
623
|
-
e.instanceId
|
|
624
|
-
e.instanceId < visibleBuildings.length
|
|
625
|
-
) {
|
|
626
|
-
const data = visibleBuildings[e.instanceId];
|
|
659
|
+
if (e.instanceId !== undefined && e.instanceId < buildingData.length) {
|
|
660
|
+
const data = buildingData[e.instanceId];
|
|
627
661
|
onClick?.(data.building);
|
|
628
662
|
}
|
|
629
663
|
},
|
|
630
|
-
[
|
|
664
|
+
[buildingData, onClick]
|
|
631
665
|
);
|
|
632
666
|
|
|
633
|
-
if (
|
|
667
|
+
if (buildingData.length === 0) return null;
|
|
634
668
|
|
|
635
669
|
return (
|
|
636
670
|
<group>
|
|
637
|
-
{/*
|
|
671
|
+
{/* All buildings - single mesh, original colors */}
|
|
638
672
|
<instancedMesh
|
|
639
673
|
ref={meshRef}
|
|
640
|
-
args={[undefined, undefined,
|
|
674
|
+
args={[undefined, undefined, buildingData.length]}
|
|
641
675
|
onPointerMove={handlePointerMove}
|
|
642
676
|
onPointerOut={handlePointerOut}
|
|
643
677
|
onClick={handleClick}
|
|
@@ -647,13 +681,22 @@ function InstancedBuildings({
|
|
|
647
681
|
<meshStandardMaterial metalness={0.1} roughness={0.35} />
|
|
648
682
|
</instancedMesh>
|
|
649
683
|
|
|
650
|
-
{/* Building edge outlines
|
|
684
|
+
{/* Building edge outlines */}
|
|
651
685
|
<BuildingEdges
|
|
652
|
-
buildings={
|
|
686
|
+
buildings={buildingData.map(d => ({
|
|
687
|
+
width: d.width,
|
|
688
|
+
depth: d.depth,
|
|
689
|
+
fullHeight: d.fullHeight,
|
|
690
|
+
x: d.x,
|
|
691
|
+
z: d.z,
|
|
692
|
+
staggerDelayMs: d.staggerDelayMs,
|
|
693
|
+
buildingIndex: d.index,
|
|
694
|
+
}))}
|
|
653
695
|
growProgress={growProgress}
|
|
654
696
|
minHeight={minHeight}
|
|
655
697
|
baseOffset={baseOffset}
|
|
656
698
|
springDuration={springDuration}
|
|
699
|
+
heightMultipliersRef={heightMultipliersRef}
|
|
657
700
|
/>
|
|
658
701
|
</group>
|
|
659
702
|
);
|
|
@@ -909,9 +952,16 @@ function DistrictFloor({
|
|
|
909
952
|
}
|
|
910
953
|
|
|
911
954
|
// Camera controller
|
|
955
|
+
interface FocusTarget {
|
|
956
|
+
x: number;
|
|
957
|
+
z: number;
|
|
958
|
+
size: number; // Approximate size of the focused area
|
|
959
|
+
}
|
|
960
|
+
|
|
912
961
|
interface AnimatedCameraProps {
|
|
913
962
|
citySize: number;
|
|
914
963
|
isFlat: boolean;
|
|
964
|
+
focusTarget?: FocusTarget | null;
|
|
915
965
|
}
|
|
916
966
|
|
|
917
967
|
let cameraResetFn: (() => void) | null = null;
|
|
@@ -920,11 +970,59 @@ export function resetCamera() {
|
|
|
920
970
|
cameraResetFn?.();
|
|
921
971
|
}
|
|
922
972
|
|
|
923
|
-
function AnimatedCamera({ citySize, isFlat }: AnimatedCameraProps) {
|
|
973
|
+
function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps) {
|
|
924
974
|
const { camera } = useThree();
|
|
925
975
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
926
976
|
const controlsRef = useRef<any>(null);
|
|
927
977
|
|
|
978
|
+
// Animated camera position and target
|
|
979
|
+
const targetPos = useMemo(() => {
|
|
980
|
+
if (focusTarget) {
|
|
981
|
+
// Position camera to look at focus target
|
|
982
|
+
const distance = Math.max(focusTarget.size * 2, 50);
|
|
983
|
+
const height = Math.max(focusTarget.size * 1.5, 40);
|
|
984
|
+
return {
|
|
985
|
+
x: focusTarget.x,
|
|
986
|
+
y: height,
|
|
987
|
+
z: focusTarget.z + distance,
|
|
988
|
+
targetX: focusTarget.x,
|
|
989
|
+
targetY: 0,
|
|
990
|
+
targetZ: focusTarget.z,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
// Default: overview of entire city
|
|
994
|
+
const targetHeight = isFlat ? citySize * 1.5 : citySize * 1.1;
|
|
995
|
+
const targetZ = isFlat ? 0 : citySize * 1.3;
|
|
996
|
+
return {
|
|
997
|
+
x: 0,
|
|
998
|
+
y: targetHeight,
|
|
999
|
+
z: targetZ,
|
|
1000
|
+
targetX: 0,
|
|
1001
|
+
targetY: 0,
|
|
1002
|
+
targetZ: 0,
|
|
1003
|
+
};
|
|
1004
|
+
}, [focusTarget, isFlat, citySize]);
|
|
1005
|
+
|
|
1006
|
+
// Spring animation for camera movement
|
|
1007
|
+
const { camX, camY, camZ, lookX, lookY, lookZ } = useSpring({
|
|
1008
|
+
camX: targetPos.x,
|
|
1009
|
+
camY: targetPos.y,
|
|
1010
|
+
camZ: targetPos.z,
|
|
1011
|
+
lookX: targetPos.targetX,
|
|
1012
|
+
lookY: targetPos.targetY,
|
|
1013
|
+
lookZ: targetPos.targetZ,
|
|
1014
|
+
config: { tension: 60, friction: 20 },
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
// Update camera each frame based on spring values
|
|
1018
|
+
useFrame(() => {
|
|
1019
|
+
if (!controlsRef.current) return;
|
|
1020
|
+
|
|
1021
|
+
camera.position.set(camX.get(), camY.get(), camZ.get());
|
|
1022
|
+
controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
|
|
1023
|
+
controlsRef.current.update();
|
|
1024
|
+
});
|
|
1025
|
+
|
|
928
1026
|
const resetToInitial = useCallback(() => {
|
|
929
1027
|
const targetHeight = isFlat ? citySize * 1.5 : citySize * 1.1;
|
|
930
1028
|
const targetZ = isFlat ? 0 : citySize * 1.3;
|
|
@@ -939,8 +1037,10 @@ function AnimatedCamera({ citySize, isFlat }: AnimatedCameraProps) {
|
|
|
939
1037
|
}, [isFlat, citySize, camera]);
|
|
940
1038
|
|
|
941
1039
|
useEffect(() => {
|
|
942
|
-
|
|
943
|
-
|
|
1040
|
+
if (!focusTarget) {
|
|
1041
|
+
resetToInitial();
|
|
1042
|
+
}
|
|
1043
|
+
}, [resetToInitial, focusTarget]);
|
|
944
1044
|
|
|
945
1045
|
useEffect(() => {
|
|
946
1046
|
cameraResetFn = resetToInitial;
|
|
@@ -1073,9 +1173,9 @@ interface CitySceneProps {
|
|
|
1073
1173
|
animationConfig: AnimationConfig;
|
|
1074
1174
|
highlightLayers: HighlightLayer[];
|
|
1075
1175
|
isolationMode: IsolationMode;
|
|
1076
|
-
dimOpacity: number;
|
|
1077
1176
|
heightScaling: HeightScaling;
|
|
1078
1177
|
linearScale: number;
|
|
1178
|
+
focusDirectory: string | null;
|
|
1079
1179
|
}
|
|
1080
1180
|
|
|
1081
1181
|
function CityScene({
|
|
@@ -1087,9 +1187,9 @@ function CityScene({
|
|
|
1087
1187
|
animationConfig,
|
|
1088
1188
|
highlightLayers,
|
|
1089
1189
|
isolationMode,
|
|
1090
|
-
dimOpacity,
|
|
1091
1190
|
heightScaling,
|
|
1092
1191
|
linearScale,
|
|
1192
|
+
focusDirectory,
|
|
1093
1193
|
}: CitySceneProps) {
|
|
1094
1194
|
const centerOffset = useMemo(
|
|
1095
1195
|
() => ({
|
|
@@ -1109,6 +1209,146 @@ function CityScene({
|
|
|
1109
1209
|
[highlightLayers]
|
|
1110
1210
|
);
|
|
1111
1211
|
|
|
1212
|
+
// Helper to check if a path is inside a directory
|
|
1213
|
+
const isPathInDirectory = useCallback((path: string, directory: string) => {
|
|
1214
|
+
if (!directory) return true;
|
|
1215
|
+
return path === directory || path.startsWith(directory + '/');
|
|
1216
|
+
}, []);
|
|
1217
|
+
|
|
1218
|
+
// Three-phase animation when switching directories:
|
|
1219
|
+
// Phase 1: Camera zooms out to overview
|
|
1220
|
+
// Phase 2: Buildings collapse/expand
|
|
1221
|
+
// Phase 3: Camera zooms into new directory
|
|
1222
|
+
//
|
|
1223
|
+
// We track two separate states:
|
|
1224
|
+
// - buildingFocusDirectory: controls which buildings are collapsed (passed to InstancedBuildings)
|
|
1225
|
+
// - cameraFocusDirectory: controls camera position (used for focusTarget calculation)
|
|
1226
|
+
const [buildingFocusDirectory, setBuildingFocusDirectory] = useState<string | null>(null);
|
|
1227
|
+
const [cameraFocusDirectory, setCameraFocusDirectory] = useState<string | null>(null);
|
|
1228
|
+
const prevFocusDirectoryRef = useRef<string | null>(null);
|
|
1229
|
+
const animationTimersRef = useRef<NodeJS.Timeout[]>([]);
|
|
1230
|
+
|
|
1231
|
+
useEffect(() => {
|
|
1232
|
+
// Clear any pending timers
|
|
1233
|
+
animationTimersRef.current.forEach(clearTimeout);
|
|
1234
|
+
animationTimersRef.current = [];
|
|
1235
|
+
|
|
1236
|
+
const prevFocus = prevFocusDirectoryRef.current;
|
|
1237
|
+
prevFocusDirectoryRef.current = focusDirectory;
|
|
1238
|
+
|
|
1239
|
+
// No change
|
|
1240
|
+
if (focusDirectory === prevFocus) return;
|
|
1241
|
+
|
|
1242
|
+
// Case 1: Going from overview to a directory (null -> dir)
|
|
1243
|
+
if (prevFocus === null && focusDirectory !== null) {
|
|
1244
|
+
// Phase 1: Collapse buildings immediately
|
|
1245
|
+
setBuildingFocusDirectory(focusDirectory);
|
|
1246
|
+
// Phase 2: After collapse settles, zoom camera in
|
|
1247
|
+
const timer = setTimeout(() => {
|
|
1248
|
+
setCameraFocusDirectory(focusDirectory);
|
|
1249
|
+
}, 600);
|
|
1250
|
+
animationTimersRef.current.push(timer);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Case 2: Going from a directory to overview (dir -> null)
|
|
1255
|
+
if (prevFocus !== null && focusDirectory === null) {
|
|
1256
|
+
// Phase 1: Zoom camera out first
|
|
1257
|
+
setCameraFocusDirectory(null);
|
|
1258
|
+
// Phase 2: After zoom-out settles, expand buildings
|
|
1259
|
+
const timer = setTimeout(() => {
|
|
1260
|
+
setBuildingFocusDirectory(null);
|
|
1261
|
+
}, 500);
|
|
1262
|
+
animationTimersRef.current.push(timer);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Case 3: Switching between directories (dirA -> dirB)
|
|
1267
|
+
if (prevFocus !== null && focusDirectory !== null) {
|
|
1268
|
+
// Phase 1: Zoom camera out
|
|
1269
|
+
setCameraFocusDirectory(null);
|
|
1270
|
+
// Phase 2: After zoom-out, collapse/expand buildings
|
|
1271
|
+
const timer1 = setTimeout(() => {
|
|
1272
|
+
setBuildingFocusDirectory(focusDirectory);
|
|
1273
|
+
}, 500);
|
|
1274
|
+
// Phase 3: After collapse settles, zoom camera into new directory
|
|
1275
|
+
const timer2 = setTimeout(() => {
|
|
1276
|
+
setCameraFocusDirectory(focusDirectory);
|
|
1277
|
+
}, 1100); // 500ms zoom-out + 600ms collapse
|
|
1278
|
+
animationTimersRef.current.push(timer1, timer2);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
}, [focusDirectory]);
|
|
1282
|
+
|
|
1283
|
+
// Cleanup timers on unmount
|
|
1284
|
+
useEffect(() => {
|
|
1285
|
+
return () => {
|
|
1286
|
+
animationTimersRef.current.forEach(clearTimeout);
|
|
1287
|
+
};
|
|
1288
|
+
}, []);
|
|
1289
|
+
|
|
1290
|
+
// Calculate focus target from cameraFocusDirectory (for camera)
|
|
1291
|
+
const focusTarget = useMemo((): FocusTarget | null => {
|
|
1292
|
+
// Use camera focus directory for camera movement
|
|
1293
|
+
if (cameraFocusDirectory) {
|
|
1294
|
+
const focusedBuildings = cityData.buildings.filter((building) =>
|
|
1295
|
+
isPathInDirectory(building.path, cameraFocusDirectory)
|
|
1296
|
+
);
|
|
1297
|
+
|
|
1298
|
+
if (focusedBuildings.length === 0) return null;
|
|
1299
|
+
|
|
1300
|
+
let minX = Infinity, maxX = -Infinity;
|
|
1301
|
+
let minZ = Infinity, maxZ = -Infinity;
|
|
1302
|
+
|
|
1303
|
+
for (const building of focusedBuildings) {
|
|
1304
|
+
const x = building.position.x - centerOffset.x;
|
|
1305
|
+
const z = building.position.z - centerOffset.z;
|
|
1306
|
+
const [width, , depth] = building.dimensions;
|
|
1307
|
+
|
|
1308
|
+
minX = Math.min(minX, x - width / 2);
|
|
1309
|
+
maxX = Math.max(maxX, x + width / 2);
|
|
1310
|
+
minZ = Math.min(minZ, z - depth / 2);
|
|
1311
|
+
maxZ = Math.max(maxZ, z + depth / 2);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const centerX = (minX + maxX) / 2;
|
|
1315
|
+
const centerZ = (minZ + maxZ) / 2;
|
|
1316
|
+
const size = Math.max(maxX - minX, maxZ - minZ);
|
|
1317
|
+
|
|
1318
|
+
return { x: centerX, z: centerZ, size };
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Priority 2: highlight layers
|
|
1322
|
+
if (!activeHighlights) return null;
|
|
1323
|
+
|
|
1324
|
+
const highlightedBuildings = cityData.buildings.filter((building) => {
|
|
1325
|
+
const highlight = getHighlightForPath(building.path, highlightLayers);
|
|
1326
|
+
return highlight !== null;
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
if (highlightedBuildings.length === 0) return null;
|
|
1330
|
+
|
|
1331
|
+
let minX = Infinity, maxX = -Infinity;
|
|
1332
|
+
let minZ = Infinity, maxZ = -Infinity;
|
|
1333
|
+
|
|
1334
|
+
for (const building of highlightedBuildings) {
|
|
1335
|
+
const x = building.position.x - centerOffset.x;
|
|
1336
|
+
const z = building.position.z - centerOffset.z;
|
|
1337
|
+
const [width, , depth] = building.dimensions;
|
|
1338
|
+
|
|
1339
|
+
minX = Math.min(minX, x - width / 2);
|
|
1340
|
+
maxX = Math.max(maxX, x + width / 2);
|
|
1341
|
+
minZ = Math.min(minZ, z - depth / 2);
|
|
1342
|
+
maxZ = Math.max(maxZ, z + depth / 2);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const centerX = (minX + maxX) / 2;
|
|
1346
|
+
const centerZ = (minZ + maxZ) / 2;
|
|
1347
|
+
const size = Math.max(maxX - minX, maxZ - minZ);
|
|
1348
|
+
|
|
1349
|
+
return { x: centerX, z: centerZ, size };
|
|
1350
|
+
}, [cameraFocusDirectory, activeHighlights, cityData.buildings, highlightLayers, centerOffset, isPathInDirectory]);
|
|
1351
|
+
|
|
1112
1352
|
const staggerIndices = useMemo(() => {
|
|
1113
1353
|
const centerX = (cityData.bounds.minX + cityData.bounds.maxX) / 2;
|
|
1114
1354
|
const centerZ = (cityData.bounds.minZ + cityData.bounds.maxZ) / 2;
|
|
@@ -1143,7 +1383,7 @@ function CityScene({
|
|
|
1143
1383
|
|
|
1144
1384
|
return (
|
|
1145
1385
|
<>
|
|
1146
|
-
<AnimatedCamera citySize={citySize} isFlat={growProgress === 0} />
|
|
1386
|
+
<AnimatedCamera citySize={citySize} isFlat={growProgress === 0} focusTarget={focusTarget} />
|
|
1147
1387
|
|
|
1148
1388
|
<ambientLight intensity={1.2} />
|
|
1149
1389
|
<hemisphereLight
|
|
@@ -1182,13 +1422,12 @@ function CityScene({
|
|
|
1182
1422
|
hoveredIndex={hoveredIndex}
|
|
1183
1423
|
growProgress={growProgress}
|
|
1184
1424
|
animationConfig={animationConfig}
|
|
1185
|
-
highlightLayers={highlightLayers}
|
|
1186
|
-
isolationMode={isolationMode}
|
|
1187
|
-
hasActiveHighlights={activeHighlights}
|
|
1188
|
-
dimOpacity={dimOpacity}
|
|
1189
1425
|
heightScaling={heightScaling}
|
|
1190
1426
|
linearScale={linearScale}
|
|
1191
1427
|
staggerIndices={staggerIndices}
|
|
1428
|
+
focusDirectory={buildingFocusDirectory}
|
|
1429
|
+
highlightLayers={highlightLayers}
|
|
1430
|
+
isolationMode={isolationMode}
|
|
1192
1431
|
/>
|
|
1193
1432
|
|
|
1194
1433
|
<BuildingIcons
|
|
@@ -1249,6 +1488,10 @@ export interface FileCity3DProps {
|
|
|
1249
1488
|
heightScaling?: HeightScaling;
|
|
1250
1489
|
/** Scale factor for linear mode (height per line, default 0.05) */
|
|
1251
1490
|
linearScale?: number;
|
|
1491
|
+
/** Directory path to focus on - buildings outside will collapse */
|
|
1492
|
+
focusDirectory?: string | null;
|
|
1493
|
+
/** Callback when user clicks on a district to navigate */
|
|
1494
|
+
onDirectorySelect?: (directory: string | null) => void;
|
|
1252
1495
|
}
|
|
1253
1496
|
|
|
1254
1497
|
/**
|
|
@@ -1276,6 +1519,8 @@ export function FileCity3D({
|
|
|
1276
1519
|
emptyMessage = 'No file tree data available',
|
|
1277
1520
|
heightScaling = 'logarithmic',
|
|
1278
1521
|
linearScale = 0.05,
|
|
1522
|
+
focusDirectory = null,
|
|
1523
|
+
onDirectorySelect,
|
|
1279
1524
|
}: FileCity3DProps) {
|
|
1280
1525
|
const { theme } = useTheme();
|
|
1281
1526
|
const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(
|
|
@@ -1392,9 +1637,9 @@ export function FileCity3D({
|
|
|
1392
1637
|
animationConfig={animationConfig}
|
|
1393
1638
|
highlightLayers={highlightLayers}
|
|
1394
1639
|
isolationMode={isolationMode}
|
|
1395
|
-
dimOpacity={dimOpacity}
|
|
1396
1640
|
heightScaling={heightScaling}
|
|
1397
1641
|
linearScale={linearScale}
|
|
1642
|
+
focusDirectory={focusDirectory}
|
|
1398
1643
|
/>
|
|
1399
1644
|
</Canvas>
|
|
1400
1645
|
<InfoPanel building={hoveredBuilding} />
|