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

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,17 +542,93 @@ 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();
568
553
  }
569
- function AnimatedCamera({ citySize, isFlat, focusTarget }) {
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);
608
+ }
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: _isFlat, focusTarget }) {
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
@@ -615,6 +673,33 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
615
673
  isAnimatingRef.current = false;
616
674
  },
617
675
  }));
676
+ // Separate spring for orbit angle animation (animates along horizontal arc)
677
+ const [{ orbitAngle }, orbitApi] = useSpring(() => ({
678
+ orbitAngle: 0,
679
+ config: { tension: 80, friction: 18 },
680
+ onStart: () => {
681
+ isOrbitingRef.current = true;
682
+ },
683
+ onRest: () => {
684
+ isOrbitingRef.current = false;
685
+ },
686
+ }));
687
+ // Separate spring for tilt angle animation (animates along vertical arc)
688
+ const isTiltingRef = useRef(false);
689
+ const [{ tiltAngle }, tiltApi] = useSpring(() => ({
690
+ tiltAngle: 0,
691
+ config: { tension: 80, friction: 18 },
692
+ onStart: () => {
693
+ isTiltingRef.current = true;
694
+ },
695
+ onRest: () => {
696
+ isTiltingRef.current = false;
697
+ },
698
+ }));
699
+ // Track orbit parameters during horizontal rotation
700
+ const orbitParamsRef = useRef(null);
701
+ // Track tilt parameters during vertical rotation
702
+ const tiltParamsRef = useRef(null);
618
703
  // When targetPos changes after initial, animate to new position
619
704
  useEffect(() => {
620
705
  // Skip the first render - we handle that directly in useFrame
@@ -657,8 +742,55 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
657
742
  hasAppliedInitial.current = true;
658
743
  return;
659
744
  }
660
- // Subsequent frames: only update during animations
661
- if (isAnimatingRef.current) {
745
+ // Handle orbit animation (horizontal rotation along arc)
746
+ if (isOrbitingRef.current && orbitParamsRef.current) {
747
+ const { centerX, centerZ, distance, height } = orbitParamsRef.current;
748
+ const currentAngle = orbitAngle.get();
749
+ const radians = (currentAngle * Math.PI) / 180;
750
+ const newX = centerX + Math.sin(radians) * distance;
751
+ const newZ = centerZ + Math.cos(radians) * distance;
752
+ camera.position.set(newX, height, newZ);
753
+ controlsRef.current.target.set(centerX, 0, centerZ);
754
+ controlsRef.current.update();
755
+ // Sync position spring to current orbit position
756
+ api.set({
757
+ camX: newX,
758
+ camY: height,
759
+ camZ: newZ,
760
+ lookX: centerX,
761
+ lookY: 0,
762
+ lookZ: centerZ,
763
+ });
764
+ }
765
+ // Handle tilt animation (vertical rotation along arc)
766
+ else if (isTiltingRef.current && tiltParamsRef.current) {
767
+ const { centerX, centerY, centerZ, distance, azimuthAngle } = tiltParamsRef.current;
768
+ const currentTilt = tiltAngle.get();
769
+ // Convert tilt angle to polar angle (0° tilt = looking down, 90° tilt = level)
770
+ // Clamp to avoid extreme angles
771
+ const clampedTilt = Math.max(5, Math.min(85, currentTilt));
772
+ const polarRadians = (clampedTilt * Math.PI) / 180;
773
+ const azimuthRadians = (azimuthAngle * Math.PI) / 180;
774
+ // Spherical to Cartesian conversion
775
+ // polarRadians: 0 = top, PI/2 = level
776
+ const newX = centerX + distance * Math.sin(polarRadians) * Math.sin(azimuthRadians);
777
+ const newY = centerY + distance * Math.cos(polarRadians);
778
+ const newZ = centerZ + distance * Math.sin(polarRadians) * Math.cos(azimuthRadians);
779
+ camera.position.set(newX, newY, newZ);
780
+ controlsRef.current.target.set(centerX, centerY, centerZ);
781
+ controlsRef.current.update();
782
+ // Sync position spring to current tilt position
783
+ api.set({
784
+ camX: newX,
785
+ camY: newY,
786
+ camZ: newZ,
787
+ lookX: centerX,
788
+ lookY: centerY,
789
+ lookZ: centerZ,
790
+ });
791
+ }
792
+ // Handle position animation
793
+ else if (isAnimatingRef.current) {
662
794
  camera.position.set(camX.get(), camY.get(), camZ.get());
663
795
  controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
664
796
  controlsRef.current.update();
@@ -676,12 +808,223 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
676
808
  lookZ: 0,
677
809
  });
678
810
  }, [citySize, api]);
811
+ const moveTo = useCallback((x, z, size) => {
812
+ const effectiveSize = size ?? citySize * 0.3;
813
+ const distance = Math.max(effectiveSize * 2, 50);
814
+ const height = Math.max(effectiveSize * 1.5, 40);
815
+ api.start({
816
+ camX: x,
817
+ camY: height,
818
+ camZ: z + distance,
819
+ lookX: x,
820
+ lookY: 0,
821
+ lookZ: z,
822
+ });
823
+ }, [citySize, api]);
824
+ // Set camera target (look-at point), maintaining current distance and angles
825
+ const setTarget = useCallback((x, y, z, options) => {
826
+ // Get current offset from target
827
+ const currentTargetX = controlsRef.current?.target.x ?? 0;
828
+ const currentTargetY = controlsRef.current?.target.y ?? 0;
829
+ const currentTargetZ = controlsRef.current?.target.z ?? 0;
830
+ const offsetX = camera.position.x - currentTargetX;
831
+ const offsetY = camera.position.y - currentTargetY;
832
+ const offsetZ = camera.position.z - currentTargetZ;
833
+ // New camera position maintains same offset from new target
834
+ const newCamX = x + offsetX;
835
+ const newCamY = y + offsetY;
836
+ const newCamZ = z + offsetZ;
837
+ // Build animation config
838
+ const animConfig = options?.duration
839
+ ? { duration: options.duration, easing: (t) => t }
840
+ : { tension: 60, friction: 20 };
841
+ api.start({
842
+ camX: newCamX,
843
+ camY: newCamY,
844
+ camZ: newCamZ,
845
+ lookX: x,
846
+ lookY: y,
847
+ lookZ: z,
848
+ config: animConfig,
849
+ });
850
+ }, [camera, api]);
851
+ // Convert cardinal direction to angle in degrees
852
+ const directionToAngle = (dir) => {
853
+ switch (dir) {
854
+ case 'north': return 180;
855
+ case 'south': return 0;
856
+ case 'east': return 270;
857
+ case 'west': return 90;
858
+ }
859
+ };
860
+ // Get current angle (helper)
861
+ const computeCurrentAngle = useCallback(() => {
862
+ const targetX = controlsRef.current?.target.x ?? 0;
863
+ const targetZ = controlsRef.current?.target.z ?? 0;
864
+ const dx = camera.position.x - targetX;
865
+ const dz = camera.position.z - targetZ;
866
+ let angle = Math.atan2(dx, dz) * (180 / Math.PI);
867
+ if (angle < 0)
868
+ angle += 360;
869
+ return angle;
870
+ }, [camera]);
871
+ // Rotate to absolute angle using shortest path
872
+ const rotateTo = useCallback((angleOrDirection, options) => {
873
+ const targetAngle = typeof angleOrDirection === 'number'
874
+ ? angleOrDirection
875
+ : directionToAngle(angleOrDirection);
876
+ // Get current state
877
+ const centerX = controlsRef.current?.target.x ?? 0;
878
+ const centerZ = controlsRef.current?.target.z ?? 0;
879
+ const currentAngle = computeCurrentAngle();
880
+ const distance = Math.sqrt(Math.pow(camera.position.x - centerX, 2) +
881
+ Math.pow(camera.position.z - centerZ, 2));
882
+ const height = camera.position.y;
883
+ // Store orbit parameters
884
+ orbitParamsRef.current = { centerX, centerZ, distance, height };
885
+ // Calculate shortest path
886
+ let delta = targetAngle - currentAngle;
887
+ // Normalize to -180 to 180
888
+ while (delta > 180)
889
+ delta -= 360;
890
+ while (delta < -180)
891
+ delta += 360;
892
+ // Build animation config
893
+ const animConfig = options?.duration
894
+ ? { duration: options.duration }
895
+ : { tension: 80, friction: 18 };
896
+ // Animate from current angle to target using shortest path
897
+ orbitApi.set({ orbitAngle: currentAngle });
898
+ orbitApi.start({ orbitAngle: currentAngle + delta, config: animConfig });
899
+ }, [camera, computeCurrentAngle, orbitApi]);
900
+ // Rotate by relative degrees (positive = clockwise, negative = counter-clockwise)
901
+ const rotateBy = useCallback((degrees, options) => {
902
+ // Get current state
903
+ const centerX = controlsRef.current?.target.x ?? 0;
904
+ const centerZ = controlsRef.current?.target.z ?? 0;
905
+ const currentAngle = computeCurrentAngle();
906
+ const distance = Math.sqrt(Math.pow(camera.position.x - centerX, 2) +
907
+ Math.pow(camera.position.z - centerZ, 2));
908
+ const height = camera.position.y;
909
+ // Store orbit parameters
910
+ orbitParamsRef.current = { centerX, centerZ, distance, height };
911
+ // Build animation config
912
+ const animConfig = options?.duration
913
+ ? { duration: options.duration }
914
+ : { tension: 80, friction: 18 };
915
+ // Animate from current angle by the specified degrees
916
+ orbitApi.set({ orbitAngle: currentAngle });
917
+ orbitApi.start({ orbitAngle: currentAngle + degrees, config: animConfig });
918
+ }, [camera, computeCurrentAngle, orbitApi]);
919
+ // Convert tilt preset to angle
920
+ const tiltPresetToAngle = (preset) => {
921
+ switch (preset) {
922
+ case 'top': return 15; // Near top-down
923
+ case 'high': return 35; // High angle
924
+ case 'low': return 60; // Low angle
925
+ case 'level': return 80; // Near horizontal
926
+ }
927
+ };
928
+ // Compute current tilt angle (polar angle in degrees)
929
+ const computeCurrentTilt = useCallback(() => {
930
+ const centerX = controlsRef.current?.target.x ?? 0;
931
+ const centerY = controlsRef.current?.target.y ?? 0;
932
+ const centerZ = controlsRef.current?.target.z ?? 0;
933
+ const dx = camera.position.x - centerX;
934
+ const dy = camera.position.y - centerY;
935
+ const dz = camera.position.z - centerZ;
936
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
937
+ if (distance === 0)
938
+ return 45; // Default
939
+ // Polar angle: arccos(dy / distance)
940
+ const polarRadians = Math.acos(dy / distance);
941
+ return (polarRadians * 180) / Math.PI;
942
+ }, [camera]);
943
+ // Tilt to absolute angle or preset
944
+ const tiltTo = useCallback((angleOrPreset, options) => {
945
+ const targetTilt = typeof angleOrPreset === 'number'
946
+ ? angleOrPreset
947
+ : tiltPresetToAngle(angleOrPreset);
948
+ // Get current state
949
+ const centerX = controlsRef.current?.target.x ?? 0;
950
+ const centerY = controlsRef.current?.target.y ?? 0;
951
+ const centerZ = controlsRef.current?.target.z ?? 0;
952
+ const dx = camera.position.x - centerX;
953
+ const dy = camera.position.y - centerY;
954
+ const dz = camera.position.z - centerZ;
955
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
956
+ const currentTilt = computeCurrentTilt();
957
+ const azimuthAngle = computeCurrentAngle();
958
+ // Store tilt parameters
959
+ tiltParamsRef.current = { centerX, centerY, centerZ, distance, azimuthAngle };
960
+ // Build animation config
961
+ const animConfig = options?.duration
962
+ ? { duration: options.duration }
963
+ : { tension: 80, friction: 18 };
964
+ // Animate from current tilt to target
965
+ tiltApi.set({ tiltAngle: currentTilt });
966
+ tiltApi.start({ tiltAngle: targetTilt, config: animConfig });
967
+ }, [camera, computeCurrentTilt, computeCurrentAngle, tiltApi]);
968
+ // Tilt by relative degrees (positive = down towards top-down, negative = up towards level)
969
+ const tiltBy = useCallback((degrees, options) => {
970
+ // Get current state
971
+ const centerX = controlsRef.current?.target.x ?? 0;
972
+ const centerY = controlsRef.current?.target.y ?? 0;
973
+ const centerZ = controlsRef.current?.target.z ?? 0;
974
+ const dx = camera.position.x - centerX;
975
+ const dy = camera.position.y - centerY;
976
+ const dz = camera.position.z - centerZ;
977
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
978
+ const currentTilt = computeCurrentTilt();
979
+ const azimuthAngle = computeCurrentAngle();
980
+ // Store tilt parameters
981
+ tiltParamsRef.current = { centerX, centerY, centerZ, distance, azimuthAngle };
982
+ // Build animation config
983
+ const animConfig = options?.duration
984
+ ? { duration: options.duration }
985
+ : { tension: 80, friction: 18 };
986
+ // Animate from current tilt by the specified degrees
987
+ tiltApi.set({ tiltAngle: currentTilt });
988
+ tiltApi.start({ tiltAngle: currentTilt + degrees, config: animConfig });
989
+ }, [camera, computeCurrentTilt, computeCurrentAngle, tiltApi]);
990
+ const getCurrentPosition = useCallback(() => {
991
+ return {
992
+ x: camera.position.x,
993
+ y: camera.position.y,
994
+ z: camera.position.z,
995
+ };
996
+ }, [camera]);
997
+ const getCurrentTarget = useCallback(() => {
998
+ return {
999
+ x: controlsRef.current?.target.x ?? 0,
1000
+ y: controlsRef.current?.target.y ?? 0,
1001
+ z: controlsRef.current?.target.z ?? 0,
1002
+ };
1003
+ }, []);
1004
+ const getCurrentAngle = useCallback(() => {
1005
+ return computeCurrentAngle();
1006
+ }, [computeCurrentAngle]);
1007
+ const getCurrentTilt = useCallback(() => {
1008
+ return computeCurrentTilt();
1009
+ }, [computeCurrentTilt]);
679
1010
  useEffect(() => {
680
- cameraResetFn = resetToInitial;
1011
+ cameraApi = {
1012
+ reset: resetToInitial,
1013
+ moveTo,
1014
+ setTarget,
1015
+ rotateTo,
1016
+ rotateBy,
1017
+ tiltTo,
1018
+ tiltBy,
1019
+ getCurrentPosition,
1020
+ getCurrentTarget,
1021
+ getCurrentAngle,
1022
+ getCurrentTilt,
1023
+ };
681
1024
  return () => {
682
- cameraResetFn = null;
1025
+ cameraApi = null;
683
1026
  };
684
- }, [resetToInitial]);
1027
+ }, [resetToInitial, moveTo, setTarget, rotateTo, rotateBy, tiltTo, tiltBy, getCurrentPosition, getCurrentTarget, getCurrentAngle, getCurrentTilt]);
685
1028
  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
1029
  }
687
1030
  function InfoPanel({ building }) {
@@ -689,9 +1032,6 @@ function InfoPanel({ building }) {
689
1032
  return null;
690
1033
  const fileName = building.path.split('/').pop();
691
1034
  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
1035
  return (_jsxs("div", { style: {
696
1036
  position: 'absolute',
697
1037
  bottom: 16,
@@ -734,7 +1074,7 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera }) {
734
1074
  gap: 8,
735
1075
  }, 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
1076
  }
737
- function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, }) {
1077
+ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, focusColor, }) {
738
1078
  const centerOffset = useMemo(() => ({
739
1079
  x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
740
1080
  z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
@@ -752,10 +1092,12 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
752
1092
  // Phase 2: Buildings collapse/expand
753
1093
  // Phase 3: Camera zooms into new directory
754
1094
  //
755
- // We track two separate states:
1095
+ // We track three separate states for smooth transitions:
756
1096
  // - buildingFocusDirectory: controls which buildings are collapsed (passed to InstancedBuildings)
1097
+ // - buildingFocusColor: the color for the focused district (synced with buildingFocusDirectory)
757
1098
  // - cameraFocusDirectory: controls camera position (used for focusTarget calculation)
758
1099
  const [buildingFocusDirectory, setBuildingFocusDirectory] = useState(null);
1100
+ const [buildingFocusColor, setBuildingFocusColor] = useState(null);
759
1101
  const [cameraFocusDirectory, setCameraFocusDirectory] = useState(null);
760
1102
  const prevFocusDirectoryRef = useRef(null);
761
1103
  const animationTimersRef = useRef([]);
@@ -770,33 +1112,56 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
770
1112
  return;
771
1113
  // Case 1: Going from overview to a directory (null -> dir)
772
1114
  if (prevFocus === null && focusDirectory !== null) {
773
- // Phase 1: Collapse buildings immediately
1115
+ // Check if camera is already focused on this area via highlight layers
1116
+ const highlightMatchesFocus = highlightLayers.some(layer => layer.enabled && layer.items.some(item => item.type === 'directory' && (item.path === focusDirectory ||
1117
+ focusDirectory.startsWith(item.path + '/'))));
1118
+ // Phase 1: Collapse buildings immediately with the new color
774
1119
  setBuildingFocusDirectory(focusDirectory);
775
- // Phase 2: After collapse settles, zoom camera in
776
- const timer = setTimeout(() => {
1120
+ setBuildingFocusColor(focusColor ?? null);
1121
+ if (highlightMatchesFocus) {
1122
+ // Camera is already there, set immediately
777
1123
  setCameraFocusDirectory(focusDirectory);
778
- }, 600);
779
- animationTimersRef.current.push(timer);
1124
+ }
1125
+ else {
1126
+ // Phase 2: After collapse settles, zoom camera in
1127
+ const timer = setTimeout(() => {
1128
+ setCameraFocusDirectory(focusDirectory);
1129
+ }, 600);
1130
+ animationTimersRef.current.push(timer);
1131
+ }
780
1132
  return;
781
1133
  }
782
1134
  // Case 2: Going from a directory to overview (dir -> null)
783
1135
  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(() => {
1136
+ // Check if highlight layers will keep camera focused on same area
1137
+ const highlightMatchesPrevFocus = highlightLayers.some(layer => layer.enabled && layer.items.some(item => item.type === 'directory' && (item.path === prevFocus ||
1138
+ prevFocus.startsWith(item.path + '/'))));
1139
+ if (highlightMatchesPrevFocus) {
1140
+ // Camera will stay focused via highlights, just clear focus state
1141
+ setCameraFocusDirectory(null);
788
1142
  setBuildingFocusDirectory(null);
789
- }, 500);
790
- animationTimersRef.current.push(timer);
1143
+ setBuildingFocusColor(null);
1144
+ }
1145
+ else {
1146
+ // Phase 1: Zoom camera out first
1147
+ setCameraFocusDirectory(null);
1148
+ // Phase 2: After zoom-out settles, expand buildings and clear color
1149
+ const timer = setTimeout(() => {
1150
+ setBuildingFocusDirectory(null);
1151
+ setBuildingFocusColor(null);
1152
+ }, 500);
1153
+ animationTimersRef.current.push(timer);
1154
+ }
791
1155
  return;
792
1156
  }
793
1157
  // Case 3: Switching between directories (dirA -> dirB)
794
1158
  if (prevFocus !== null && focusDirectory !== null) {
795
1159
  // Phase 1: Zoom camera out
796
1160
  setCameraFocusDirectory(null);
797
- // Phase 2: After zoom-out, collapse/expand buildings
1161
+ // Phase 2: After zoom-out, collapse/expand buildings with new color
798
1162
  const timer1 = setTimeout(() => {
799
1163
  setBuildingFocusDirectory(focusDirectory);
1164
+ setBuildingFocusColor(focusColor ?? null);
800
1165
  }, 500);
801
1166
  // Phase 3: After collapse settles, zoom camera into new directory
802
1167
  const timer2 = setTimeout(() => {
@@ -897,7 +1262,30 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
897
1262
  const tension = animationConfig.tension || 120;
898
1263
  const friction = animationConfig.friction || 14;
899
1264
  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 })] }));
1265
+ 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 => {
1266
+ // Check if district matches focusDirectory
1267
+ const isFocused = buildingFocusDirectory
1268
+ ? district.path === buildingFocusDirectory
1269
+ : false;
1270
+ // Check if district matches any highlight layer
1271
+ let highlightLayerColor = null;
1272
+ for (const layer of highlightLayers) {
1273
+ if (!layer.enabled)
1274
+ continue;
1275
+ for (const item of layer.items) {
1276
+ if (item.type === 'directory' && item.path === district.path) {
1277
+ highlightLayerColor = layer.color;
1278
+ break;
1279
+ }
1280
+ }
1281
+ if (highlightLayerColor)
1282
+ break;
1283
+ }
1284
+ // Use buildingFocusColor (synced with animation) instead of focusColor prop
1285
+ // Focus color takes priority, then highlight layer color
1286
+ const districtColor = (isFocused && buildingFocusColor) ? buildingFocusColor : highlightLayerColor;
1287
+ return (_jsx(DistrictFloor, { district: district, centerOffset: centerOffset, opacity: 1, highlightColor: districtColor }, district.path));
1288
+ }), _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
1289
  }
902
1290
  /**
903
1291
  * FileCity3D - 3D visualization of codebase structure
@@ -905,7 +1293,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
905
1293
  * Renders CityData as an interactive 3D city where buildings represent files
906
1294
  * and their height corresponds to line count or file size.
907
1295
  */
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, }) {
1296
+ 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, }) {
909
1297
  const [hoveredBuilding, setHoveredBuilding] = useState(null);
910
1298
  const [internalIsGrown, setInternalIsGrown] = useState(false);
911
1299
  const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
@@ -924,6 +1312,7 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
924
1312
  else if (!animationConfig.startFlat) {
925
1313
  setIsGrown(true);
926
1314
  }
1315
+ return undefined;
927
1316
  // eslint-disable-next-line react-hooks/exhaustive-deps
928
1317
  }, [animationConfig.startFlat, animationConfig.autoStartDelay]);
929
1318
  const growProgress = isGrown ? 1 : 0;
@@ -975,6 +1364,6 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
975
1364
  left: 0,
976
1365
  width: '100%',
977
1366
  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 }))] }));
1367
+ }, 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 }) }), _jsx(InfoPanel, { building: selectedBuilding || hoveredBuilding }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
979
1368
  }
980
1369
  export default FileCity3D;