@principal-ai/file-city-react 0.5.15 → 0.5.17

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.
@@ -9,8 +9,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
9
9
  */
10
10
  import { useMemo, useRef, useState, useEffect, useCallback } from 'react';
11
11
  import { Canvas, useFrame, useThree } from '@react-three/fiber';
12
- import { animated, useSpring } from '@react-spring/three';
13
- import { OrbitControls, PerspectiveCamera, Text, RoundedBox } from '@react-three/drei';
12
+ import { useSpring } from '@react-spring/three';
13
+ import { OrbitControls, PerspectiveCamera, Text } from '@react-three/drei';
14
14
  import { getFileConfig } from '@principal-ai/file-city-builder';
15
15
  import * as THREE from 'three';
16
16
  const DEFAULT_ANIMATION = {
@@ -21,52 +21,6 @@ const DEFAULT_ANIMATION = {
21
21
  tension: 120,
22
22
  friction: 14,
23
23
  };
24
- // Code file extensions - height based on line count
25
- const CODE_EXTENSIONS = new Set([
26
- 'ts',
27
- 'tsx',
28
- 'js',
29
- 'jsx',
30
- 'mjs',
31
- 'cjs',
32
- 'py',
33
- 'pyw',
34
- 'rs',
35
- 'go',
36
- 'java',
37
- 'kt',
38
- 'scala',
39
- 'c',
40
- 'cpp',
41
- 'cc',
42
- 'cxx',
43
- 'h',
44
- 'hpp',
45
- 'cs',
46
- 'rb',
47
- 'php',
48
- 'swift',
49
- 'vue',
50
- 'svelte',
51
- 'lua',
52
- 'sh',
53
- 'bash',
54
- 'zsh',
55
- 'sql',
56
- 'r',
57
- 'dart',
58
- 'elm',
59
- 'ex',
60
- 'exs',
61
- 'clj',
62
- 'cljs',
63
- 'hs',
64
- 'ml',
65
- 'mli',
66
- ]);
67
- function isCodeFile(extension) {
68
- return CODE_EXTENSIONS.has(extension.toLowerCase());
69
- }
70
24
  /**
71
25
  * Calculate building height based on file metrics.
72
26
  * - logarithmic: Compresses large values (default, good for mixed codebases)
@@ -191,10 +145,6 @@ function getHighlightForPath(path, layers) {
191
145
  function hasActiveHighlights(layers) {
192
146
  return layers.some(layer => layer.enabled && layer.items.length > 0);
193
147
  }
194
- // Animated RoundedBox wrapper
195
- const AnimatedRoundedBox = animated(RoundedBox);
196
- // Animated meshStandardMaterial for opacity transitions
197
- const AnimatedMeshStandardMaterial = animated('meshStandardMaterial');
198
148
  function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springDuration, heightMultipliersRef, }) {
199
149
  const meshRef = useRef(null);
200
150
  const startTimeRef = useRef(null);
@@ -267,38 +217,65 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
267
217
  // Track animated height multipliers for each building (for collapse animation)
268
218
  const heightMultipliersRef = useRef(null);
269
219
  const targetMultipliersRef = useRef(null);
220
+ // Track dim state for buildings in focus but not highlighted (0 = dimmed, 1 = full)
221
+ const dimMultipliersRef = useRef(null);
222
+ const targetDimRef = useRef(null);
270
223
  // Check if highlight layers have any active items
271
224
  const hasActiveHighlightLayers = useMemo(() => {
272
225
  return highlightLayers.some(layer => layer.enabled && layer.items.length > 0);
273
226
  }, [highlightLayers]);
274
- // Initialize height multiplier arrays
227
+ // Initialize height and dim multiplier arrays
275
228
  useEffect(() => {
276
229
  if (buildings.length > 0) {
277
230
  if (!heightMultipliersRef.current ||
278
231
  heightMultipliersRef.current.length !== buildings.length) {
279
232
  heightMultipliersRef.current = new Float32Array(buildings.length).fill(1);
280
233
  targetMultipliersRef.current = new Float32Array(buildings.length).fill(1);
234
+ dimMultipliersRef.current = new Float32Array(buildings.length).fill(1);
235
+ targetDimRef.current = new Float32Array(buildings.length).fill(1);
281
236
  }
282
237
  }
283
238
  }, [buildings.length]);
284
239
  // Update target multipliers when focusDirectory or highlightLayers change
285
240
  useEffect(() => {
286
- if (!targetMultipliersRef.current)
241
+ if (!targetMultipliersRef.current || !targetDimRef.current)
287
242
  return;
288
243
  buildings.forEach((building, index) => {
289
244
  let shouldCollapse = false;
245
+ let shouldDim = false;
290
246
  const isInFocusDirectory = focusDirectory
291
247
  ? isPathInDirectory(building.path, focusDirectory)
292
248
  : true; // No focusDirectory means all are "in focus"
293
249
  const isHighlighted = hasActiveHighlightLayers
294
250
  ? getHighlightForPath(building.path, highlightLayers) !== null
295
251
  : true; // No highlights means all are "highlighted"
296
- // Collapse if outside BOTH focusDirectory AND highlightLayers
297
- // (only when collapse mode is active)
298
- if (focusDirectory || (hasActiveHighlightLayers && isolationMode === 'collapse')) {
299
- shouldCollapse = !isInFocusDirectory && !isHighlighted;
252
+ // Determine collapse and dim behavior based on what's active:
253
+ // - focusDirectory only: collapse if outside focus
254
+ // - highlightLayers only (with collapse mode): collapse if not highlighted
255
+ // - both: collapse if outside focus, dim if in focus but not highlighted
256
+ if (focusDirectory && hasActiveHighlightLayers && isolationMode === 'collapse') {
257
+ // Both active: collapse if outside focus, dim if in focus but not highlighted
258
+ shouldCollapse = !isInFocusDirectory;
259
+ shouldDim = isInFocusDirectory && !isHighlighted;
260
+ }
261
+ else if (focusDirectory) {
262
+ // Focus only: collapse if outside focus directory
263
+ shouldCollapse = !isInFocusDirectory;
264
+ }
265
+ else if (hasActiveHighlightLayers && isolationMode === 'collapse') {
266
+ // Highlight only with collapse: collapse if not highlighted
267
+ shouldCollapse = !isHighlighted;
268
+ }
269
+ // Height: 1.0 = full, 0.05 = flat (collapsed or dimmed)
270
+ if (shouldCollapse || shouldDim) {
271
+ targetMultipliersRef.current[index] = 0.05;
272
+ }
273
+ else {
274
+ targetMultipliersRef.current[index] = 1;
300
275
  }
301
- targetMultipliersRef.current[index] = shouldCollapse ? 0.05 : 1;
276
+ // Dim ref controls graying: 0 = gray out, 1 = keep color
277
+ // Collapsed buildings go gray, dimmed buildings keep their color
278
+ targetDimRef.current[index] = shouldCollapse ? 0 : 1;
302
279
  });
303
280
  }, [focusDirectory, buildings, highlightLayers, isolationMode, hasActiveHighlightLayers]);
304
281
  // Pre-compute building data
@@ -381,6 +358,11 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
381
358
  const targetMultiplier = targetMultipliersRef.current[instanceIndex];
382
359
  const newMultiplier = currentMultiplier + (targetMultiplier - currentMultiplier) * collapseSpeed;
383
360
  heightMultipliersRef.current[instanceIndex] = newMultiplier;
361
+ // Animate dim multiplier towards target
362
+ const currentDim = dimMultipliersRef.current[instanceIndex];
363
+ const targetDim = targetDimRef.current[instanceIndex];
364
+ const newDim = currentDim + (targetDim - currentDim) * collapseSpeed;
365
+ dimMultipliersRef.current[instanceIndex] = newDim;
384
366
  // Calculate grow animation progress
385
367
  const elapsed = currentTime - animStartTime - staggerDelayMs;
386
368
  let animProgress = growProgress;
@@ -402,11 +384,11 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
402
384
  tempObject.scale.set(width * scale, height, depth * scale);
403
385
  tempObject.updateMatrix();
404
386
  meshRef.current.setMatrixAt(instanceIndex, tempObject.matrix);
405
- // Desaturate collapsed buildings
387
+ // Apply color effects
406
388
  tempColor.set(data.color);
407
- if (newMultiplier < 0.5) {
408
- // Lerp towards gray based on collapse amount
409
- const grayAmount = 1 - newMultiplier * 2; // 0 at multiplier=0.5, 1 at multiplier=0
389
+ // Gray out collapsed buildings (newDim < 0.5 means should be gray)
390
+ if (newDim < 0.5) {
391
+ const grayAmount = 1 - newDim * 2; // 0 at dim=0.5, 1 at dim=0
410
392
  const gray = 0.3;
411
393
  tempColor.r = tempColor.r * (1 - grayAmount) + gray * grayAmount;
412
394
  tempColor.g = tempColor.g * (1 - grayAmount) + gray * grayAmount;
@@ -551,7 +533,7 @@ function BuildingIcons({ buildings, centerOffset, growProgress, heightScaling, l
551
533
  return (_jsx(AnimatedIcon, { x: x, z: z, targetHeight: targetHeight, iconSize: iconSize, texture: texture, opacity: opacity, growProgress: growProgress, staggerDelayMs: staggerDelayMs, springDuration: springDuration }, building.path));
552
534
  }) }));
553
535
  }
554
- function DistrictFloor({ district, centerOffset, opacity }) {
536
+ function DistrictFloor({ district, centerOffset, highlightColor }) {
555
537
  const { worldBounds } = district;
556
538
  const width = worldBounds.maxX - worldBounds.minX;
557
539
  const depth = worldBounds.maxZ - worldBounds.minZ;
@@ -560,20 +542,97 @@ function DistrictFloor({ district, centerOffset, opacity }) {
560
542
  const dirName = district.path.split('/').pop() || district.path;
561
543
  const pathDepth = district.path.split('/').length;
562
544
  const floorY = -5 - pathDepth * 0.1;
563
- return (_jsxs("group", { position: [centerX, 0, centerZ], children: [_jsxs("lineSegments", { rotation: [-Math.PI / 2, 0, 0], position: [0, floorY, 0], renderOrder: -1, children: [_jsx("edgesGeometry", { args: [new THREE.PlaneGeometry(width, depth)], attach: "geometry" }), _jsx("lineBasicMaterial", { color: "#475569", depthWrite: false })] }), district.label && (_jsx(Text, { position: [0, 1.5, depth / 2 + 2], rotation: [-Math.PI / 6, 0, 0], fontSize: Math.min(3, width / 6), color: "#cbd5e1", anchorX: "center", anchorY: "middle", outlineWidth: 0.1, outlineColor: "#0f172a", children: dirName }))] }));
545
+ const borderColor = highlightColor || '#475569';
546
+ const lineWidth = highlightColor ? 3 : 1;
547
+ const labelColor = highlightColor || '#cbd5e1';
548
+ return (_jsxs("group", { position: [centerX, 0, centerZ], children: [_jsxs("lineSegments", { rotation: [-Math.PI / 2, 0, 0], position: [0, floorY, 0], renderOrder: -1, children: [_jsx("edgesGeometry", { args: [new THREE.PlaneGeometry(width, depth)], attach: "geometry" }), _jsx("lineBasicMaterial", { color: borderColor, linewidth: lineWidth, depthWrite: false })] }), highlightColor && (_jsxs("mesh", { rotation: [-Math.PI / 2, 0, 0], position: [0, floorY - 0.1, 0], renderOrder: -2, children: [_jsx("planeGeometry", { args: [width, depth] }), _jsx("meshBasicMaterial", { color: highlightColor, transparent: true, opacity: 0.15, depthWrite: false })] })), district.label && (_jsx(Text, { position: [0, 1.5, depth / 2 + 2], rotation: [-Math.PI / 6, 0, 0], fontSize: Math.min(3, width / 6), color: labelColor, anchorX: "center", anchorY: "middle", outlineWidth: 0.1, outlineColor: "#0f172a", children: dirName }))] }));
564
549
  }
565
- let cameraResetFn = null;
550
+ let cameraApi = null;
566
551
  export function resetCamera() {
567
- cameraResetFn?.();
552
+ cameraApi?.reset();
553
+ }
554
+ export function moveCameraTo(x, z, size) {
555
+ cameraApi?.moveTo(x, z, size);
556
+ }
557
+ /**
558
+ * Set the camera's look-at target (center point for orbiting).
559
+ * Camera maintains its current distance and angles relative to the new target.
560
+ * @param x - Target X coordinate
561
+ * @param y - Target Y coordinate (usually 0 for ground level)
562
+ * @param z - Target Z coordinate
563
+ * @param options - Optional settings including duration in ms
564
+ */
565
+ export function setCameraTarget(x, y, z, options) {
566
+ cameraApi?.setTarget(x, y, z, options);
567
+ }
568
+ /**
569
+ * Get the current camera target (look-at point).
570
+ */
571
+ export function getCameraTarget() {
572
+ return cameraApi?.getCurrentTarget() ?? null;
573
+ }
574
+ /**
575
+ * Rotate the camera to view the city from a specific angle or cardinal direction.
576
+ * Uses the shortest path (e.g., 350° to 10° goes through 0°, not 180°).
577
+ * @param angleOrDirection - Angle in degrees (0 = south, 90 = west, 180 = north, 270 = east)
578
+ * or a cardinal direction string ('north', 'south', 'east', 'west')
579
+ * @param options - Optional settings including duration in ms
580
+ */
581
+ export function rotateCameraTo(angleOrDirection, options) {
582
+ cameraApi?.rotateTo(angleOrDirection, options);
583
+ }
584
+ /**
585
+ * Rotate the camera by a relative amount.
586
+ * @param degrees - Degrees to rotate. Positive = clockwise, negative = counter-clockwise.
587
+ * @param options - Optional settings including duration in ms
588
+ */
589
+ export function rotateCameraBy(degrees, options) {
590
+ cameraApi?.rotateBy(degrees, options);
591
+ }
592
+ /**
593
+ * Tilt the camera to a specific vertical angle or preset.
594
+ * @param angle - Angle in degrees (0 = top-down, 90 = level/horizontal)
595
+ * or a preset: 'top' (15°), 'high' (35°), 'low' (60°), 'level' (80°)
596
+ * @param options - Optional settings including duration in ms
597
+ */
598
+ export function tiltCameraTo(angle, options) {
599
+ cameraApi?.tiltTo(angle, options);
600
+ }
601
+ /**
602
+ * Tilt the camera by a relative amount.
603
+ * @param degrees - Degrees to tilt. Positive = tilt down (towards top-down), negative = tilt up (towards level).
604
+ * @param options - Optional settings including duration in ms
605
+ */
606
+ export function tiltCameraBy(degrees, options) {
607
+ cameraApi?.tiltBy(degrees, options);
568
608
  }
569
- function AnimatedCamera({ citySize, isFlat, focusTarget }) {
609
+ export function getCameraPosition() {
610
+ return cameraApi?.getCurrentPosition() ?? null;
611
+ }
612
+ /**
613
+ * Get the current camera angle in degrees (0-360).
614
+ * 0 = south, 90 = west, 180 = north, 270 = east
615
+ */
616
+ export function getCameraAngle() {
617
+ return cameraApi?.getCurrentAngle() ?? null;
618
+ }
619
+ /**
620
+ * Get the current camera tilt in degrees (0-90).
621
+ * 0 = top-down view, 90 = level/horizontal view
622
+ */
623
+ export function getCameraTilt() {
624
+ return cameraApi?.getCurrentTilt() ?? null;
625
+ }
626
+ function AnimatedCamera({ citySize, isFlat, focusTarget, maxBuildingHeight = 0 }) {
570
627
  const { camera } = useThree();
571
628
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
572
629
  const controlsRef = useRef(null);
573
630
  const isAnimatingRef = useRef(false);
631
+ const isOrbitingRef = useRef(false);
574
632
  const hasAppliedInitial = useRef(false);
575
633
  const frameCount = useRef(0);
576
634
  // Compute target camera position
635
+ // When flat, use a more top-down view; when grown, use an angled view
577
636
  const targetPos = useMemo(() => {
578
637
  if (focusTarget) {
579
638
  const distance = Math.max(focusTarget.size * 2, 50);
@@ -587,9 +646,15 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
587
646
  targetZ: focusTarget.z,
588
647
  };
589
648
  }
590
- // Default overview
591
- const targetHeight = citySize * 1.1;
592
- const targetZ = citySize * 1.3;
649
+ // Default overview - adjust angle based on flat/grown state
650
+ // Flat: directly overhead (90 degrees, looking straight down)
651
+ // Grown: angled view to see building heights (optionally based on max building height)
652
+ const baseHeight = citySize * 1.1;
653
+ const buildingAwareHeight = maxBuildingHeight > 0
654
+ ? Math.max(baseHeight, maxBuildingHeight * 2.5)
655
+ : baseHeight;
656
+ const targetHeight = isFlat ? citySize * 2.0 : buildingAwareHeight;
657
+ const targetZ = isFlat ? 0.001 : citySize * 1.3; // Near-zero for top-down (tiny offset to avoid gimbal lock)
593
658
  return {
594
659
  x: 0,
595
660
  y: targetHeight,
@@ -598,7 +663,7 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
598
663
  targetY: 0,
599
664
  targetZ: 0,
600
665
  };
601
- }, [focusTarget, citySize]);
666
+ }, [focusTarget, citySize, isFlat, maxBuildingHeight]);
602
667
  // Spring animation for camera movement
603
668
  const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => ({
604
669
  camX: targetPos.x,
@@ -615,6 +680,33 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
615
680
  isAnimatingRef.current = false;
616
681
  },
617
682
  }));
683
+ // Separate spring for orbit angle animation (animates along horizontal arc)
684
+ const [{ orbitAngle }, orbitApi] = useSpring(() => ({
685
+ orbitAngle: 0,
686
+ config: { tension: 80, friction: 18 },
687
+ onStart: () => {
688
+ isOrbitingRef.current = true;
689
+ },
690
+ onRest: () => {
691
+ isOrbitingRef.current = false;
692
+ },
693
+ }));
694
+ // Separate spring for tilt angle animation (animates along vertical arc)
695
+ const isTiltingRef = useRef(false);
696
+ const [{ tiltAngle }, tiltApi] = useSpring(() => ({
697
+ tiltAngle: 0,
698
+ config: { tension: 80, friction: 18 },
699
+ onStart: () => {
700
+ isTiltingRef.current = true;
701
+ },
702
+ onRest: () => {
703
+ isTiltingRef.current = false;
704
+ },
705
+ }));
706
+ // Track orbit parameters during horizontal rotation
707
+ const orbitParamsRef = useRef(null);
708
+ // Track tilt parameters during vertical rotation
709
+ const tiltParamsRef = useRef(null);
618
710
  // When targetPos changes after initial, animate to new position
619
711
  useEffect(() => {
620
712
  // Skip the first render - we handle that directly in useFrame
@@ -657,8 +749,55 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
657
749
  hasAppliedInitial.current = true;
658
750
  return;
659
751
  }
660
- // Subsequent frames: only update during animations
661
- if (isAnimatingRef.current) {
752
+ // Handle orbit animation (horizontal rotation along arc)
753
+ if (isOrbitingRef.current && orbitParamsRef.current) {
754
+ const { centerX, centerZ, distance, height } = orbitParamsRef.current;
755
+ const currentAngle = orbitAngle.get();
756
+ const radians = (currentAngle * Math.PI) / 180;
757
+ const newX = centerX + Math.sin(radians) * distance;
758
+ const newZ = centerZ + Math.cos(radians) * distance;
759
+ camera.position.set(newX, height, newZ);
760
+ controlsRef.current.target.set(centerX, 0, centerZ);
761
+ controlsRef.current.update();
762
+ // Sync position spring to current orbit position
763
+ api.set({
764
+ camX: newX,
765
+ camY: height,
766
+ camZ: newZ,
767
+ lookX: centerX,
768
+ lookY: 0,
769
+ lookZ: centerZ,
770
+ });
771
+ }
772
+ // Handle tilt animation (vertical rotation along arc)
773
+ else if (isTiltingRef.current && tiltParamsRef.current) {
774
+ const { centerX, centerY, centerZ, distance, azimuthAngle } = tiltParamsRef.current;
775
+ const currentTilt = tiltAngle.get();
776
+ // Convert tilt angle to polar angle (0° tilt = looking down, 90° tilt = level)
777
+ // Clamp to avoid extreme angles
778
+ const clampedTilt = Math.max(5, Math.min(85, currentTilt));
779
+ const polarRadians = (clampedTilt * Math.PI) / 180;
780
+ const azimuthRadians = (azimuthAngle * Math.PI) / 180;
781
+ // Spherical to Cartesian conversion
782
+ // polarRadians: 0 = top, PI/2 = level
783
+ const newX = centerX + distance * Math.sin(polarRadians) * Math.sin(azimuthRadians);
784
+ const newY = centerY + distance * Math.cos(polarRadians);
785
+ const newZ = centerZ + distance * Math.sin(polarRadians) * Math.cos(azimuthRadians);
786
+ camera.position.set(newX, newY, newZ);
787
+ controlsRef.current.target.set(centerX, centerY, centerZ);
788
+ controlsRef.current.update();
789
+ // Sync position spring to current tilt position
790
+ api.set({
791
+ camX: newX,
792
+ camY: newY,
793
+ camZ: newZ,
794
+ lookX: centerX,
795
+ lookY: centerY,
796
+ lookZ: centerZ,
797
+ });
798
+ }
799
+ // Handle position animation
800
+ else if (isAnimatingRef.current) {
662
801
  camera.position.set(camX.get(), camY.get(), camZ.get());
663
802
  controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
664
803
  controlsRef.current.update();
@@ -676,12 +815,223 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
676
815
  lookZ: 0,
677
816
  });
678
817
  }, [citySize, api]);
818
+ const moveTo = useCallback((x, z, size) => {
819
+ const effectiveSize = size ?? citySize * 0.3;
820
+ const distance = Math.max(effectiveSize * 2, 50);
821
+ const height = Math.max(effectiveSize * 1.5, 40);
822
+ api.start({
823
+ camX: x,
824
+ camY: height,
825
+ camZ: z + distance,
826
+ lookX: x,
827
+ lookY: 0,
828
+ lookZ: z,
829
+ });
830
+ }, [citySize, api]);
831
+ // Set camera target (look-at point), maintaining current distance and angles
832
+ const setTarget = useCallback((x, y, z, options) => {
833
+ // Get current offset from target
834
+ const currentTargetX = controlsRef.current?.target.x ?? 0;
835
+ const currentTargetY = controlsRef.current?.target.y ?? 0;
836
+ const currentTargetZ = controlsRef.current?.target.z ?? 0;
837
+ const offsetX = camera.position.x - currentTargetX;
838
+ const offsetY = camera.position.y - currentTargetY;
839
+ const offsetZ = camera.position.z - currentTargetZ;
840
+ // New camera position maintains same offset from new target
841
+ const newCamX = x + offsetX;
842
+ const newCamY = y + offsetY;
843
+ const newCamZ = z + offsetZ;
844
+ // Build animation config
845
+ const animConfig = options?.duration
846
+ ? { duration: options.duration, easing: (t) => t }
847
+ : { tension: 60, friction: 20 };
848
+ api.start({
849
+ camX: newCamX,
850
+ camY: newCamY,
851
+ camZ: newCamZ,
852
+ lookX: x,
853
+ lookY: y,
854
+ lookZ: z,
855
+ config: animConfig,
856
+ });
857
+ }, [camera, api]);
858
+ // Convert cardinal direction to angle in degrees
859
+ const directionToAngle = (dir) => {
860
+ switch (dir) {
861
+ case 'north': return 180;
862
+ case 'south': return 0;
863
+ case 'east': return 270;
864
+ case 'west': return 90;
865
+ }
866
+ };
867
+ // Get current angle (helper)
868
+ const computeCurrentAngle = useCallback(() => {
869
+ const targetX = controlsRef.current?.target.x ?? 0;
870
+ const targetZ = controlsRef.current?.target.z ?? 0;
871
+ const dx = camera.position.x - targetX;
872
+ const dz = camera.position.z - targetZ;
873
+ let angle = Math.atan2(dx, dz) * (180 / Math.PI);
874
+ if (angle < 0)
875
+ angle += 360;
876
+ return angle;
877
+ }, [camera]);
878
+ // Rotate to absolute angle using shortest path
879
+ const rotateTo = useCallback((angleOrDirection, options) => {
880
+ const targetAngle = typeof angleOrDirection === 'number'
881
+ ? angleOrDirection
882
+ : directionToAngle(angleOrDirection);
883
+ // Get current state
884
+ const centerX = controlsRef.current?.target.x ?? 0;
885
+ const centerZ = controlsRef.current?.target.z ?? 0;
886
+ const currentAngle = computeCurrentAngle();
887
+ const distance = Math.sqrt(Math.pow(camera.position.x - centerX, 2) +
888
+ Math.pow(camera.position.z - centerZ, 2));
889
+ const height = camera.position.y;
890
+ // Store orbit parameters
891
+ orbitParamsRef.current = { centerX, centerZ, distance, height };
892
+ // Calculate shortest path
893
+ let delta = targetAngle - currentAngle;
894
+ // Normalize to -180 to 180
895
+ while (delta > 180)
896
+ delta -= 360;
897
+ while (delta < -180)
898
+ delta += 360;
899
+ // Build animation config
900
+ const animConfig = options?.duration
901
+ ? { duration: options.duration }
902
+ : { tension: 80, friction: 18 };
903
+ // Animate from current angle to target using shortest path
904
+ orbitApi.set({ orbitAngle: currentAngle });
905
+ orbitApi.start({ orbitAngle: currentAngle + delta, config: animConfig });
906
+ }, [camera, computeCurrentAngle, orbitApi]);
907
+ // Rotate by relative degrees (positive = clockwise, negative = counter-clockwise)
908
+ const rotateBy = useCallback((degrees, options) => {
909
+ // Get current state
910
+ const centerX = controlsRef.current?.target.x ?? 0;
911
+ const centerZ = controlsRef.current?.target.z ?? 0;
912
+ const currentAngle = computeCurrentAngle();
913
+ const distance = Math.sqrt(Math.pow(camera.position.x - centerX, 2) +
914
+ Math.pow(camera.position.z - centerZ, 2));
915
+ const height = camera.position.y;
916
+ // Store orbit parameters
917
+ orbitParamsRef.current = { centerX, centerZ, distance, height };
918
+ // Build animation config
919
+ const animConfig = options?.duration
920
+ ? { duration: options.duration }
921
+ : { tension: 80, friction: 18 };
922
+ // Animate from current angle by the specified degrees
923
+ orbitApi.set({ orbitAngle: currentAngle });
924
+ orbitApi.start({ orbitAngle: currentAngle + degrees, config: animConfig });
925
+ }, [camera, computeCurrentAngle, orbitApi]);
926
+ // Convert tilt preset to angle
927
+ const tiltPresetToAngle = (preset) => {
928
+ switch (preset) {
929
+ case 'top': return 15; // Near top-down
930
+ case 'high': return 35; // High angle
931
+ case 'low': return 60; // Low angle
932
+ case 'level': return 80; // Near horizontal
933
+ }
934
+ };
935
+ // Compute current tilt angle (polar angle in degrees)
936
+ const computeCurrentTilt = useCallback(() => {
937
+ const centerX = controlsRef.current?.target.x ?? 0;
938
+ const centerY = controlsRef.current?.target.y ?? 0;
939
+ const centerZ = controlsRef.current?.target.z ?? 0;
940
+ const dx = camera.position.x - centerX;
941
+ const dy = camera.position.y - centerY;
942
+ const dz = camera.position.z - centerZ;
943
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
944
+ if (distance === 0)
945
+ return 45; // Default
946
+ // Polar angle: arccos(dy / distance)
947
+ const polarRadians = Math.acos(dy / distance);
948
+ return (polarRadians * 180) / Math.PI;
949
+ }, [camera]);
950
+ // Tilt to absolute angle or preset
951
+ const tiltTo = useCallback((angleOrPreset, options) => {
952
+ const targetTilt = typeof angleOrPreset === 'number'
953
+ ? angleOrPreset
954
+ : tiltPresetToAngle(angleOrPreset);
955
+ // Get current state
956
+ const centerX = controlsRef.current?.target.x ?? 0;
957
+ const centerY = controlsRef.current?.target.y ?? 0;
958
+ const centerZ = controlsRef.current?.target.z ?? 0;
959
+ const dx = camera.position.x - centerX;
960
+ const dy = camera.position.y - centerY;
961
+ const dz = camera.position.z - centerZ;
962
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
963
+ const currentTilt = computeCurrentTilt();
964
+ const azimuthAngle = computeCurrentAngle();
965
+ // Store tilt parameters
966
+ tiltParamsRef.current = { centerX, centerY, centerZ, distance, azimuthAngle };
967
+ // Build animation config
968
+ const animConfig = options?.duration
969
+ ? { duration: options.duration }
970
+ : { tension: 80, friction: 18 };
971
+ // Animate from current tilt to target
972
+ tiltApi.set({ tiltAngle: currentTilt });
973
+ tiltApi.start({ tiltAngle: targetTilt, config: animConfig });
974
+ }, [camera, computeCurrentTilt, computeCurrentAngle, tiltApi]);
975
+ // Tilt by relative degrees (positive = down towards top-down, negative = up towards level)
976
+ const tiltBy = useCallback((degrees, options) => {
977
+ // Get current state
978
+ const centerX = controlsRef.current?.target.x ?? 0;
979
+ const centerY = controlsRef.current?.target.y ?? 0;
980
+ const centerZ = controlsRef.current?.target.z ?? 0;
981
+ const dx = camera.position.x - centerX;
982
+ const dy = camera.position.y - centerY;
983
+ const dz = camera.position.z - centerZ;
984
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
985
+ const currentTilt = computeCurrentTilt();
986
+ const azimuthAngle = computeCurrentAngle();
987
+ // Store tilt parameters
988
+ tiltParamsRef.current = { centerX, centerY, centerZ, distance, azimuthAngle };
989
+ // Build animation config
990
+ const animConfig = options?.duration
991
+ ? { duration: options.duration }
992
+ : { tension: 80, friction: 18 };
993
+ // Animate from current tilt by the specified degrees
994
+ tiltApi.set({ tiltAngle: currentTilt });
995
+ tiltApi.start({ tiltAngle: currentTilt + degrees, config: animConfig });
996
+ }, [camera, computeCurrentTilt, computeCurrentAngle, tiltApi]);
997
+ const getCurrentPosition = useCallback(() => {
998
+ return {
999
+ x: camera.position.x,
1000
+ y: camera.position.y,
1001
+ z: camera.position.z,
1002
+ };
1003
+ }, [camera]);
1004
+ const getCurrentTarget = useCallback(() => {
1005
+ return {
1006
+ x: controlsRef.current?.target.x ?? 0,
1007
+ y: controlsRef.current?.target.y ?? 0,
1008
+ z: controlsRef.current?.target.z ?? 0,
1009
+ };
1010
+ }, []);
1011
+ const getCurrentAngle = useCallback(() => {
1012
+ return computeCurrentAngle();
1013
+ }, [computeCurrentAngle]);
1014
+ const getCurrentTilt = useCallback(() => {
1015
+ return computeCurrentTilt();
1016
+ }, [computeCurrentTilt]);
679
1017
  useEffect(() => {
680
- cameraResetFn = resetToInitial;
1018
+ cameraApi = {
1019
+ reset: resetToInitial,
1020
+ moveTo,
1021
+ setTarget,
1022
+ rotateTo,
1023
+ rotateBy,
1024
+ tiltTo,
1025
+ tiltBy,
1026
+ getCurrentPosition,
1027
+ getCurrentTarget,
1028
+ getCurrentAngle,
1029
+ getCurrentTilt,
1030
+ };
681
1031
  return () => {
682
- cameraResetFn = null;
1032
+ cameraApi = null;
683
1033
  };
684
- }, [resetToInitial]);
1034
+ }, [resetToInitial, moveTo, setTarget, rotateTo, rotateBy, tiltTo, tiltBy, getCurrentPosition, getCurrentTarget, getCurrentAngle, getCurrentTilt]);
685
1035
  return (_jsxs(_Fragment, { children: [_jsx(PerspectiveCamera, { makeDefault: true, fov: 50, near: 1, far: citySize * 10 }), _jsx(OrbitControls, { ref: controlsRef, enableDamping: true, dampingFactor: 0.05, minDistance: 10, maxDistance: citySize * 3, maxPolarAngle: Math.PI / 2.1 })] }));
686
1036
  }
687
1037
  function InfoPanel({ building }) {
@@ -689,9 +1039,6 @@ function InfoPanel({ building }) {
689
1039
  return null;
690
1040
  const fileName = building.path.split('/').pop();
691
1041
  const dirPath = building.path.split('/').slice(0, -1).join('/');
692
- const rawExt = building.fileExtension || building.path.split('.').pop() || '';
693
- const ext = rawExt.replace(/^\./, '');
694
- const isCode = isCodeFile(ext);
695
1042
  return (_jsxs("div", { style: {
696
1043
  position: 'absolute',
697
1044
  bottom: 16,
@@ -734,12 +1081,18 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera }) {
734
1081
  gap: 8,
735
1082
  }, children: [_jsx("button", { onClick: onResetCamera, style: buttonStyle, children: "Reset View" }), _jsx("button", { onClick: onToggle, style: buttonStyle, children: isFlat ? 'Grow to 3D' : 'Flatten to 2D' })] }));
736
1083
  }
737
- function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, }) {
1084
+ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, focusColor, adaptCameraToBuildings = false, }) {
738
1085
  const centerOffset = useMemo(() => ({
739
1086
  x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
740
1087
  z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
741
1088
  }), [cityData.bounds]);
742
1089
  const citySize = Math.max(cityData.bounds.maxX - cityData.bounds.minX, cityData.bounds.maxZ - cityData.bounds.minZ);
1090
+ // Calculate max building height for camera positioning (when adaptCameraToBuildings is true)
1091
+ const maxBuildingHeight = useMemo(() => {
1092
+ if (!adaptCameraToBuildings)
1093
+ return 0;
1094
+ return Math.max(...cityData.buildings.map(b => b.dimensions[1]), 0);
1095
+ }, [adaptCameraToBuildings, cityData.buildings]);
743
1096
  const activeHighlights = useMemo(() => hasActiveHighlights(highlightLayers), [highlightLayers]);
744
1097
  // Helper to check if a path is inside a directory
745
1098
  const isPathInDirectory = useCallback((path, directory) => {
@@ -752,10 +1105,12 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
752
1105
  // Phase 2: Buildings collapse/expand
753
1106
  // Phase 3: Camera zooms into new directory
754
1107
  //
755
- // We track two separate states:
1108
+ // We track three separate states for smooth transitions:
756
1109
  // - buildingFocusDirectory: controls which buildings are collapsed (passed to InstancedBuildings)
1110
+ // - buildingFocusColor: the color for the focused district (synced with buildingFocusDirectory)
757
1111
  // - cameraFocusDirectory: controls camera position (used for focusTarget calculation)
758
1112
  const [buildingFocusDirectory, setBuildingFocusDirectory] = useState(null);
1113
+ const [buildingFocusColor, setBuildingFocusColor] = useState(null);
759
1114
  const [cameraFocusDirectory, setCameraFocusDirectory] = useState(null);
760
1115
  const prevFocusDirectoryRef = useRef(null);
761
1116
  const animationTimersRef = useRef([]);
@@ -770,33 +1125,56 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
770
1125
  return;
771
1126
  // Case 1: Going from overview to a directory (null -> dir)
772
1127
  if (prevFocus === null && focusDirectory !== null) {
773
- // Phase 1: Collapse buildings immediately
1128
+ // Check if camera is already focused on this area via highlight layers
1129
+ const highlightMatchesFocus = highlightLayers.some(layer => layer.enabled && layer.items.some(item => item.type === 'directory' && (item.path === focusDirectory ||
1130
+ focusDirectory.startsWith(item.path + '/'))));
1131
+ // Phase 1: Collapse buildings immediately with the new color
774
1132
  setBuildingFocusDirectory(focusDirectory);
775
- // Phase 2: After collapse settles, zoom camera in
776
- const timer = setTimeout(() => {
1133
+ setBuildingFocusColor(focusColor ?? null);
1134
+ if (highlightMatchesFocus) {
1135
+ // Camera is already there, set immediately
777
1136
  setCameraFocusDirectory(focusDirectory);
778
- }, 600);
779
- animationTimersRef.current.push(timer);
1137
+ }
1138
+ else {
1139
+ // Phase 2: After collapse settles, zoom camera in
1140
+ const timer = setTimeout(() => {
1141
+ setCameraFocusDirectory(focusDirectory);
1142
+ }, 600);
1143
+ animationTimersRef.current.push(timer);
1144
+ }
780
1145
  return;
781
1146
  }
782
1147
  // Case 2: Going from a directory to overview (dir -> null)
783
1148
  if (prevFocus !== null && focusDirectory === null) {
784
- // Phase 1: Zoom camera out first
785
- setCameraFocusDirectory(null);
786
- // Phase 2: After zoom-out settles, expand buildings
787
- const timer = setTimeout(() => {
1149
+ // Check if highlight layers will keep camera focused on same area
1150
+ const highlightMatchesPrevFocus = highlightLayers.some(layer => layer.enabled && layer.items.some(item => item.type === 'directory' && (item.path === prevFocus ||
1151
+ prevFocus.startsWith(item.path + '/'))));
1152
+ if (highlightMatchesPrevFocus) {
1153
+ // Camera will stay focused via highlights, just clear focus state
1154
+ setCameraFocusDirectory(null);
788
1155
  setBuildingFocusDirectory(null);
789
- }, 500);
790
- animationTimersRef.current.push(timer);
1156
+ setBuildingFocusColor(null);
1157
+ }
1158
+ else {
1159
+ // Phase 1: Zoom camera out first
1160
+ setCameraFocusDirectory(null);
1161
+ // Phase 2: After zoom-out settles, expand buildings and clear color
1162
+ const timer = setTimeout(() => {
1163
+ setBuildingFocusDirectory(null);
1164
+ setBuildingFocusColor(null);
1165
+ }, 500);
1166
+ animationTimersRef.current.push(timer);
1167
+ }
791
1168
  return;
792
1169
  }
793
1170
  // Case 3: Switching between directories (dirA -> dirB)
794
1171
  if (prevFocus !== null && focusDirectory !== null) {
795
1172
  // Phase 1: Zoom camera out
796
1173
  setCameraFocusDirectory(null);
797
- // Phase 2: After zoom-out, collapse/expand buildings
1174
+ // Phase 2: After zoom-out, collapse/expand buildings with new color
798
1175
  const timer1 = setTimeout(() => {
799
1176
  setBuildingFocusDirectory(focusDirectory);
1177
+ setBuildingFocusColor(focusColor ?? null);
800
1178
  }, 500);
801
1179
  // Phase 3: After collapse settles, zoom camera into new directory
802
1180
  const timer2 = setTimeout(() => {
@@ -897,7 +1275,30 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
897
1275
  const tension = animationConfig.tension || 120;
898
1276
  const friction = animationConfig.friction || 14;
899
1277
  const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
900
- return (_jsxs(_Fragment, { children: [_jsx(AnimatedCamera, { citySize: citySize, isFlat: growProgress === 0, focusTarget: focusTarget }), _jsx("ambientLight", { intensity: 1.2 }), _jsx("hemisphereLight", { args: ['#ddeeff', '#667788', 0.8], position: [0, citySize, 0] }), _jsx("directionalLight", { position: [citySize, citySize * 1.5, citySize * 0.5], intensity: 2, castShadow: true, "shadow-mapSize": [2048, 2048] }), _jsx("directionalLight", { position: [-citySize * 0.5, citySize * 0.8, -citySize * 0.5], intensity: 1 }), _jsx("directionalLight", { position: [citySize * 0.3, citySize, citySize], intensity: 0.6 }), cityData.districts.map(district => (_jsx(DistrictFloor, { district: district, centerOffset: centerOffset, opacity: 1 }, district.path))), _jsx(InstancedBuildings, { buildings: cityData.buildings, centerOffset: centerOffset, onHover: onBuildingHover, onClick: onBuildingClick, hoveredIndex: hoveredIndex, selectedIndex: selectedIndex, growProgress: growProgress, animationConfig: animationConfig, heightScaling: heightScaling, linearScale: linearScale, staggerIndices: staggerIndices, focusDirectory: buildingFocusDirectory, highlightLayers: highlightLayers, isolationMode: isolationMode }), _jsx(BuildingIcons, { buildings: cityData.buildings, centerOffset: centerOffset, growProgress: growProgress, heightScaling: heightScaling, linearScale: linearScale, highlightLayers: highlightLayers, isolationMode: isolationMode, hasActiveHighlights: activeHighlights, staggerIndices: staggerIndices, springDuration: springDuration, staggerDelay: animationConfig.staggerDelay || 15 })] }));
1278
+ return (_jsxs(_Fragment, { children: [_jsx(AnimatedCamera, { citySize: citySize, isFlat: growProgress === 0, focusTarget: focusTarget, maxBuildingHeight: maxBuildingHeight }), _jsx("ambientLight", { intensity: 1.2 }), _jsx("hemisphereLight", { args: ['#ddeeff', '#667788', 0.8], position: [0, citySize, 0] }), _jsx("directionalLight", { position: [citySize, citySize * 1.5, citySize * 0.5], intensity: 2, castShadow: true, "shadow-mapSize": [2048, 2048] }), _jsx("directionalLight", { position: [-citySize * 0.5, citySize * 0.8, -citySize * 0.5], intensity: 1 }), _jsx("directionalLight", { position: [citySize * 0.3, citySize, citySize], intensity: 0.6 }), cityData.districts.map(district => {
1279
+ // Check if district matches focusDirectory
1280
+ const isFocused = buildingFocusDirectory
1281
+ ? district.path === buildingFocusDirectory
1282
+ : false;
1283
+ // Check if district matches any highlight layer
1284
+ let highlightLayerColor = null;
1285
+ for (const layer of highlightLayers) {
1286
+ if (!layer.enabled)
1287
+ continue;
1288
+ for (const item of layer.items) {
1289
+ if (item.type === 'directory' && item.path === district.path) {
1290
+ highlightLayerColor = layer.color;
1291
+ break;
1292
+ }
1293
+ }
1294
+ if (highlightLayerColor)
1295
+ break;
1296
+ }
1297
+ // Use buildingFocusColor (synced with animation) instead of focusColor prop
1298
+ // Focus color takes priority, then highlight layer color
1299
+ const districtColor = (isFocused && buildingFocusColor) ? buildingFocusColor : highlightLayerColor;
1300
+ return (_jsx(DistrictFloor, { district: district, centerOffset: centerOffset, opacity: 1, highlightColor: districtColor }, district.path));
1301
+ }), _jsx(InstancedBuildings, { buildings: cityData.buildings, centerOffset: centerOffset, onHover: onBuildingHover, onClick: onBuildingClick, hoveredIndex: hoveredIndex, selectedIndex: selectedIndex, growProgress: growProgress, animationConfig: animationConfig, heightScaling: heightScaling, linearScale: linearScale, staggerIndices: staggerIndices, focusDirectory: buildingFocusDirectory, highlightLayers: highlightLayers, isolationMode: isolationMode }), _jsx(BuildingIcons, { buildings: cityData.buildings, centerOffset: centerOffset, growProgress: growProgress, heightScaling: heightScaling, linearScale: linearScale, highlightLayers: highlightLayers, isolationMode: isolationMode, hasActiveHighlights: activeHighlights, staggerIndices: staggerIndices, springDuration: springDuration, staggerDelay: animationConfig.staggerDelay || 15 })] }));
901
1302
  }
902
1303
  /**
903
1304
  * FileCity3D - 3D visualization of codebase structure
@@ -905,7 +1306,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
905
1306
  * Renders CityData as an interactive 3D city where buildings represent files
906
1307
  * and their height corresponds to line count or file size.
907
1308
  */
908
- export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls = true, highlightLayers = [], isolationMode = 'transparent', dimOpacity = 0.15, isLoading = false, loadingMessage = 'Loading file city...', emptyMessage = 'No file tree data available', heightScaling = 'logarithmic', linearScale = 0.05, focusDirectory = null, onDirectorySelect, backgroundColor = '#0f172a', textColor = '#94a3b8', selectedBuilding = null, }) {
1309
+ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls = true, highlightLayers = [], isolationMode = 'transparent', dimOpacity: _dimOpacity = 0.15, isLoading = false, loadingMessage = 'Loading file city...', emptyMessage = 'No file tree data available', heightScaling = 'logarithmic', linearScale = 0.05, focusDirectory = null, focusColor = null, onDirectorySelect: _onDirectorySelect, backgroundColor = '#0f172a', textColor = '#94a3b8', selectedBuilding = null, adaptCameraToBuildings = false, }) {
909
1310
  const [hoveredBuilding, setHoveredBuilding] = useState(null);
910
1311
  const [internalIsGrown, setInternalIsGrown] = useState(false);
911
1312
  const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
@@ -924,6 +1325,7 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
924
1325
  else if (!animationConfig.startFlat) {
925
1326
  setIsGrown(true);
926
1327
  }
1328
+ return undefined;
927
1329
  // eslint-disable-next-line react-hooks/exhaustive-deps
928
1330
  }, [animationConfig.startFlat, animationConfig.autoStartDelay]);
929
1331
  const growProgress = isGrown ? 1 : 0;
@@ -975,6 +1377,6 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
975
1377
  left: 0,
976
1378
  width: '100%',
977
1379
  height: '100%',
978
- }, children: _jsx(CityScene, { cityData: cityData, onBuildingHover: setHoveredBuilding, onBuildingClick: onBuildingClick, hoveredBuilding: hoveredBuilding, selectedBuilding: selectedBuilding, growProgress: growProgress, animationConfig: animationConfig, highlightLayers: highlightLayers, isolationMode: isolationMode, heightScaling: heightScaling, linearScale: linearScale, focusDirectory: focusDirectory }) }), _jsx(InfoPanel, { building: selectedBuilding || hoveredBuilding }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
1380
+ }, children: _jsx(CityScene, { cityData: cityData, onBuildingHover: setHoveredBuilding, onBuildingClick: onBuildingClick, hoveredBuilding: hoveredBuilding, selectedBuilding: selectedBuilding, growProgress: growProgress, animationConfig: animationConfig, highlightLayers: highlightLayers, isolationMode: isolationMode, heightScaling: heightScaling, linearScale: linearScale, focusDirectory: focusDirectory, focusColor: focusColor, adaptCameraToBuildings: adaptCameraToBuildings }) }), _jsx(InfoPanel, { building: selectedBuilding || hoveredBuilding }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
979
1381
  }
980
1382
  export default FileCity3D;