@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.
@@ -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
- targetHeight: number;
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, targetHeight, staggerDelayMs } = data;
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, targetHeight, staggerDelayMs },
351
- { x: x + halfW, z: z - halfD, targetHeight, staggerDelayMs },
352
- { x: x - halfW, z: z + halfD, targetHeight, staggerDelayMs },
353
- { x: x + halfW, z: z + halfD, targetHeight, staggerDelayMs },
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, targetHeight, staggerDelayMs } = edge;
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
- const height = animProgress * targetHeight + minHeight;
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 highlight = getHighlightForPath(building.path, highlightLayers);
454
- const isHighlighted = highlight !== null;
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
- targetHeight,
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
- visibleBuildings.forEach((data, instanceIndex) => {
518
- const { width, depth, x, z, color, targetHeight } = data;
543
+ buildingData.forEach((data, instanceIndex) => {
544
+ const { width, depth, x, z, color, fullHeight } = data;
519
545
 
520
- const height = growProgress * targetHeight + minHeight;
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
- visibleBuildings.forEach((data, instanceIndex) => {
557
- const { width, depth, targetHeight, x, z, staggerDelayMs, shouldDim } =
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
- const height = animProgress * targetHeight + minHeight;
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
- const opacity =
584
- shouldDim && isolationMode === 'transparent' ? dimOpacity : 1;
619
+ // Desaturate collapsed buildings
585
620
  tempColor.set(data.color);
586
- if (opacity < 1) {
587
- tempColor.multiplyScalar(opacity + 0.3);
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 !== undefined &&
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
- [visibleBuildings, onHover]
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 !== undefined &&
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
- [visibleBuildings, onClick]
664
+ [buildingData, onClick]
631
665
  );
632
666
 
633
- if (visibleBuildings.length === 0) return null;
667
+ if (buildingData.length === 0) return null;
634
668
 
635
669
  return (
636
670
  <group>
637
- {/* Main building meshes */}
671
+ {/* All buildings - single mesh, original colors */}
638
672
  <instancedMesh
639
673
  ref={meshRef}
640
- args={[undefined, undefined, visibleBuildings.length]}
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 - batched into single geometry for performance */}
684
+ {/* Building edge outlines */}
651
685
  <BuildingEdges
652
- buildings={visibleBuildings}
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
- resetToInitial();
943
- }, [resetToInitial]);
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} />