@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.
@@ -10,8 +10,8 @@
10
10
  import React, { useMemo, useRef, useState, useEffect, useCallback } from 'react';
11
11
  import { Canvas, useFrame, ThreeEvent, useThree } from '@react-three/fiber';
12
12
 
13
- import { animated, useSpring, config } from '@react-spring/three';
14
- import { OrbitControls, PerspectiveCamera, Text, RoundedBox } from '@react-three/drei';
13
+ import { useSpring } from '@react-spring/three';
14
+ import { OrbitControls, PerspectiveCamera, Text } from '@react-three/drei';
15
15
  import { getFileConfig } from '@principal-ai/file-city-builder';
16
16
  import type {
17
17
  CityData,
@@ -92,54 +92,6 @@ const DEFAULT_ANIMATION: AnimationConfig = {
92
92
  friction: 14,
93
93
  };
94
94
 
95
- // Code file extensions - height based on line count
96
- const CODE_EXTENSIONS = new Set([
97
- 'ts',
98
- 'tsx',
99
- 'js',
100
- 'jsx',
101
- 'mjs',
102
- 'cjs',
103
- 'py',
104
- 'pyw',
105
- 'rs',
106
- 'go',
107
- 'java',
108
- 'kt',
109
- 'scala',
110
- 'c',
111
- 'cpp',
112
- 'cc',
113
- 'cxx',
114
- 'h',
115
- 'hpp',
116
- 'cs',
117
- 'rb',
118
- 'php',
119
- 'swift',
120
- 'vue',
121
- 'svelte',
122
- 'lua',
123
- 'sh',
124
- 'bash',
125
- 'zsh',
126
- 'sql',
127
- 'r',
128
- 'dart',
129
- 'elm',
130
- 'ex',
131
- 'exs',
132
- 'clj',
133
- 'cljs',
134
- 'hs',
135
- 'ml',
136
- 'mli',
137
- ]);
138
-
139
- function isCodeFile(extension: string): boolean {
140
- return CODE_EXTENSIONS.has(extension.toLowerCase());
141
- }
142
-
143
95
  /**
144
96
  * Calculate building height based on file metrics.
145
97
  * - logarithmic: Compresses large values (default, good for mixed codebases)
@@ -301,12 +253,6 @@ function hasActiveHighlights(layers: HighlightLayer[]): boolean {
301
253
  return layers.some(layer => layer.enabled && layer.items.length > 0);
302
254
  }
303
255
 
304
- // Animated RoundedBox wrapper
305
- const AnimatedRoundedBox = animated(RoundedBox);
306
-
307
- // Animated meshStandardMaterial for opacity transitions
308
- const AnimatedMeshStandardMaterial = animated('meshStandardMaterial');
309
-
310
256
  // ============================================================================
311
257
  // Building Edges - Batched edge rendering for performance
312
258
  // ============================================================================
@@ -465,13 +411,16 @@ function InstancedBuildings({
465
411
  // Track animated height multipliers for each building (for collapse animation)
466
412
  const heightMultipliersRef = useRef<Float32Array | null>(null);
467
413
  const targetMultipliersRef = useRef<Float32Array | null>(null);
414
+ // Track dim state for buildings in focus but not highlighted (0 = dimmed, 1 = full)
415
+ const dimMultipliersRef = useRef<Float32Array | null>(null);
416
+ const targetDimRef = useRef<Float32Array | null>(null);
468
417
 
469
418
  // Check if highlight layers have any active items
470
419
  const hasActiveHighlightLayers = useMemo(() => {
471
420
  return highlightLayers.some(layer => layer.enabled && layer.items.length > 0);
472
421
  }, [highlightLayers]);
473
422
 
474
- // Initialize height multiplier arrays
423
+ // Initialize height and dim multiplier arrays
475
424
  useEffect(() => {
476
425
  if (buildings.length > 0) {
477
426
  if (
@@ -480,16 +429,19 @@ function InstancedBuildings({
480
429
  ) {
481
430
  heightMultipliersRef.current = new Float32Array(buildings.length).fill(1);
482
431
  targetMultipliersRef.current = new Float32Array(buildings.length).fill(1);
432
+ dimMultipliersRef.current = new Float32Array(buildings.length).fill(1);
433
+ targetDimRef.current = new Float32Array(buildings.length).fill(1);
483
434
  }
484
435
  }
485
436
  }, [buildings.length]);
486
437
 
487
438
  // Update target multipliers when focusDirectory or highlightLayers change
488
439
  useEffect(() => {
489
- if (!targetMultipliersRef.current) return;
440
+ if (!targetMultipliersRef.current || !targetDimRef.current) return;
490
441
 
491
442
  buildings.forEach((building, index) => {
492
443
  let shouldCollapse = false;
444
+ let shouldDim = false;
493
445
 
494
446
  const isInFocusDirectory = focusDirectory
495
447
  ? isPathInDirectory(building.path, focusDirectory)
@@ -499,13 +451,31 @@ function InstancedBuildings({
499
451
  ? getHighlightForPath(building.path, highlightLayers) !== null
500
452
  : true; // No highlights means all are "highlighted"
501
453
 
502
- // Collapse if outside BOTH focusDirectory AND highlightLayers
503
- // (only when collapse mode is active)
504
- if (focusDirectory || (hasActiveHighlightLayers && isolationMode === 'collapse')) {
505
- shouldCollapse = !isInFocusDirectory && !isHighlighted;
454
+ // Determine collapse and dim behavior based on what's active:
455
+ // - focusDirectory only: collapse if outside focus
456
+ // - highlightLayers only (with collapse mode): collapse if not highlighted
457
+ // - both: collapse if outside focus, dim if in focus but not highlighted
458
+ if (focusDirectory && hasActiveHighlightLayers && isolationMode === 'collapse') {
459
+ // Both active: collapse if outside focus, dim if in focus but not highlighted
460
+ shouldCollapse = !isInFocusDirectory;
461
+ shouldDim = isInFocusDirectory && !isHighlighted;
462
+ } else if (focusDirectory) {
463
+ // Focus only: collapse if outside focus directory
464
+ shouldCollapse = !isInFocusDirectory;
465
+ } else if (hasActiveHighlightLayers && isolationMode === 'collapse') {
466
+ // Highlight only with collapse: collapse if not highlighted
467
+ shouldCollapse = !isHighlighted;
506
468
  }
507
469
 
508
- targetMultipliersRef.current![index] = shouldCollapse ? 0.05 : 1;
470
+ // Height: 1.0 = full, 0.05 = flat (collapsed or dimmed)
471
+ if (shouldCollapse || shouldDim) {
472
+ targetMultipliersRef.current![index] = 0.05;
473
+ } else {
474
+ targetMultipliersRef.current![index] = 1;
475
+ }
476
+ // Dim ref controls graying: 0 = gray out, 1 = keep color
477
+ // Collapsed buildings go gray, dimmed buildings keep their color
478
+ targetDimRef.current![index] = shouldCollapse ? 0 : 1;
509
479
  });
510
480
  }, [focusDirectory, buildings, highlightLayers, isolationMode, hasActiveHighlightLayers]);
511
481
 
@@ -608,6 +578,12 @@ function InstancedBuildings({
608
578
  currentMultiplier + (targetMultiplier - currentMultiplier) * collapseSpeed;
609
579
  heightMultipliersRef.current![instanceIndex] = newMultiplier;
610
580
 
581
+ // Animate dim multiplier towards target
582
+ const currentDim = dimMultipliersRef.current![instanceIndex];
583
+ const targetDim = targetDimRef.current![instanceIndex];
584
+ const newDim = currentDim + (targetDim - currentDim) * collapseSpeed;
585
+ dimMultipliersRef.current![instanceIndex] = newDim;
586
+
611
587
  // Calculate grow animation progress
612
588
  const elapsed = currentTime - animStartTime - staggerDelayMs;
613
589
  let animProgress = growProgress;
@@ -634,16 +610,18 @@ function InstancedBuildings({
634
610
 
635
611
  meshRef.current!.setMatrixAt(instanceIndex, tempObject.matrix);
636
612
 
637
- // Desaturate collapsed buildings
613
+ // Apply color effects
638
614
  tempColor.set(data.color);
639
- if (newMultiplier < 0.5) {
640
- // Lerp towards gray based on collapse amount
641
- const grayAmount = 1 - newMultiplier * 2; // 0 at multiplier=0.5, 1 at multiplier=0
615
+
616
+ // Gray out collapsed buildings (newDim < 0.5 means should be gray)
617
+ if (newDim < 0.5) {
618
+ const grayAmount = 1 - newDim * 2; // 0 at dim=0.5, 1 at dim=0
642
619
  const gray = 0.3;
643
620
  tempColor.r = tempColor.r * (1 - grayAmount) + gray * grayAmount;
644
621
  tempColor.g = tempColor.g * (1 - grayAmount) + gray * grayAmount;
645
622
  tempColor.b = tempColor.b * (1 - grayAmount) + gray * grayAmount;
646
623
  }
624
+
647
625
  if (isSelected) {
648
626
  tempColor.multiplyScalar(1.4);
649
627
  } else if (isHovered) {
@@ -937,9 +915,10 @@ interface DistrictFloorProps {
937
915
  district: CityDistrict;
938
916
  centerOffset: { x: number; z: number };
939
917
  opacity: number;
918
+ highlightColor?: string | null;
940
919
  }
941
920
 
942
- function DistrictFloor({ district, centerOffset, opacity }: DistrictFloorProps) {
921
+ function DistrictFloor({ district, centerOffset, highlightColor }: DistrictFloorProps) {
943
922
  const { worldBounds } = district;
944
923
  const width = worldBounds.maxX - worldBounds.minX;
945
924
  const depth = worldBounds.maxZ - worldBounds.minZ;
@@ -951,19 +930,32 @@ function DistrictFloor({ district, centerOffset, opacity }: DistrictFloorProps)
951
930
  const pathDepth = district.path.split('/').length;
952
931
  const floorY = -5 - pathDepth * 0.1;
953
932
 
933
+ const borderColor = highlightColor || '#475569';
934
+ const lineWidth = highlightColor ? 3 : 1;
935
+ const labelColor = highlightColor || '#cbd5e1';
936
+
954
937
  return (
955
938
  <group position={[centerX, 0, centerZ]}>
939
+ {/* Border outline */}
956
940
  <lineSegments rotation={[-Math.PI / 2, 0, 0]} position={[0, floorY, 0]} renderOrder={-1}>
957
941
  <edgesGeometry args={[new THREE.PlaneGeometry(width, depth)]} attach="geometry" />
958
- <lineBasicMaterial color="#475569" depthWrite={false} />
942
+ <lineBasicMaterial color={borderColor} linewidth={lineWidth} depthWrite={false} />
959
943
  </lineSegments>
960
944
 
945
+ {/* Highlighted floor fill when focused */}
946
+ {highlightColor && (
947
+ <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, floorY - 0.1, 0]} renderOrder={-2}>
948
+ <planeGeometry args={[width, depth]} />
949
+ <meshBasicMaterial color={highlightColor} transparent opacity={0.15} depthWrite={false} />
950
+ </mesh>
951
+ )}
952
+
961
953
  {district.label && (
962
954
  <Text
963
955
  position={[0, 1.5, depth / 2 + 2]}
964
956
  rotation={[-Math.PI / 6, 0, 0]}
965
957
  fontSize={Math.min(3, width / 6)}
966
- color="#cbd5e1"
958
+ color={labelColor}
967
959
  anchorX="center"
968
960
  anchorY="middle"
969
961
  outlineWidth={0.1}
@@ -987,23 +979,135 @@ interface AnimatedCameraProps {
987
979
  citySize: number;
988
980
  isFlat: boolean;
989
981
  focusTarget?: FocusTarget | null;
982
+ maxBuildingHeight?: number;
983
+ }
984
+
985
+ // Camera rotation options
986
+ export interface RotateOptions {
987
+ /** Animation duration in milliseconds. Default uses spring physics (~800ms feel). */
988
+ duration?: number;
989
+ }
990
+
991
+ // Camera control API - populated by AnimatedCamera
992
+ interface CameraApi {
993
+ reset: () => void;
994
+ moveTo: (x: number, z: number, size?: number) => void;
995
+ setTarget: (x: number, y: number, z: number, options?: RotateOptions) => void;
996
+ rotateTo: (angleOrDirection: number | 'north' | 'south' | 'east' | 'west', options?: RotateOptions) => void;
997
+ rotateBy: (degrees: number, options?: RotateOptions) => void;
998
+ tiltTo: (angle: number | 'top' | 'level' | 'high' | 'low', options?: RotateOptions) => void;
999
+ tiltBy: (degrees: number, options?: RotateOptions) => void;
1000
+ getCurrentPosition: () => { x: number; y: number; z: number } | null;
1001
+ getCurrentTarget: () => { x: number; y: number; z: number } | null;
1002
+ getCurrentAngle: () => number | null;
1003
+ getCurrentTilt: () => number | null;
990
1004
  }
991
1005
 
992
- let cameraResetFn: (() => void) | null = null;
1006
+ let cameraApi: CameraApi | null = null;
993
1007
 
994
1008
  export function resetCamera() {
995
- cameraResetFn?.();
1009
+ cameraApi?.reset();
996
1010
  }
997
1011
 
998
- function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps) {
1012
+ export function moveCameraTo(x: number, z: number, size?: number) {
1013
+ cameraApi?.moveTo(x, z, size);
1014
+ }
1015
+
1016
+ /**
1017
+ * Set the camera's look-at target (center point for orbiting).
1018
+ * Camera maintains its current distance and angles relative to the new target.
1019
+ * @param x - Target X coordinate
1020
+ * @param y - Target Y coordinate (usually 0 for ground level)
1021
+ * @param z - Target Z coordinate
1022
+ * @param options - Optional settings including duration in ms
1023
+ */
1024
+ export function setCameraTarget(x: number, y: number, z: number, options?: RotateOptions) {
1025
+ cameraApi?.setTarget(x, y, z, options);
1026
+ }
1027
+
1028
+ /**
1029
+ * Get the current camera target (look-at point).
1030
+ */
1031
+ export function getCameraTarget() {
1032
+ return cameraApi?.getCurrentTarget() ?? null;
1033
+ }
1034
+
1035
+ /**
1036
+ * Rotate the camera to view the city from a specific angle or cardinal direction.
1037
+ * Uses the shortest path (e.g., 350° to 10° goes through 0°, not 180°).
1038
+ * @param angleOrDirection - Angle in degrees (0 = south, 90 = west, 180 = north, 270 = east)
1039
+ * or a cardinal direction string ('north', 'south', 'east', 'west')
1040
+ * @param options - Optional settings including duration in ms
1041
+ */
1042
+ export function rotateCameraTo(
1043
+ angleOrDirection: number | 'north' | 'south' | 'east' | 'west',
1044
+ options?: RotateOptions
1045
+ ) {
1046
+ cameraApi?.rotateTo(angleOrDirection, options);
1047
+ }
1048
+
1049
+ /**
1050
+ * Rotate the camera by a relative amount.
1051
+ * @param degrees - Degrees to rotate. Positive = clockwise, negative = counter-clockwise.
1052
+ * @param options - Optional settings including duration in ms
1053
+ */
1054
+ export function rotateCameraBy(degrees: number, options?: RotateOptions) {
1055
+ cameraApi?.rotateBy(degrees, options);
1056
+ }
1057
+
1058
+ /**
1059
+ * Tilt the camera to a specific vertical angle or preset.
1060
+ * @param angle - Angle in degrees (0 = top-down, 90 = level/horizontal)
1061
+ * or a preset: 'top' (15°), 'high' (35°), 'low' (60°), 'level' (80°)
1062
+ * @param options - Optional settings including duration in ms
1063
+ */
1064
+ export function tiltCameraTo(
1065
+ angle: number | 'top' | 'level' | 'high' | 'low',
1066
+ options?: RotateOptions
1067
+ ) {
1068
+ cameraApi?.tiltTo(angle, options);
1069
+ }
1070
+
1071
+ /**
1072
+ * Tilt the camera by a relative amount.
1073
+ * @param degrees - Degrees to tilt. Positive = tilt down (towards top-down), negative = tilt up (towards level).
1074
+ * @param options - Optional settings including duration in ms
1075
+ */
1076
+ export function tiltCameraBy(degrees: number, options?: RotateOptions) {
1077
+ cameraApi?.tiltBy(degrees, options);
1078
+ }
1079
+
1080
+ export function getCameraPosition() {
1081
+ return cameraApi?.getCurrentPosition() ?? null;
1082
+ }
1083
+
1084
+ /**
1085
+ * Get the current camera angle in degrees (0-360).
1086
+ * 0 = south, 90 = west, 180 = north, 270 = east
1087
+ */
1088
+ export function getCameraAngle() {
1089
+ return cameraApi?.getCurrentAngle() ?? null;
1090
+ }
1091
+
1092
+ /**
1093
+ * Get the current camera tilt in degrees (0-90).
1094
+ * 0 = top-down view, 90 = level/horizontal view
1095
+ */
1096
+ export function getCameraTilt() {
1097
+ return cameraApi?.getCurrentTilt() ?? null;
1098
+ }
1099
+
1100
+ function AnimatedCamera({ citySize, isFlat, focusTarget, maxBuildingHeight = 0 }: AnimatedCameraProps) {
999
1101
  const { camera } = useThree();
1000
1102
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1001
1103
  const controlsRef = useRef<any>(null);
1002
1104
  const isAnimatingRef = useRef(false);
1105
+ const isOrbitingRef = useRef(false);
1003
1106
  const hasAppliedInitial = useRef(false);
1004
1107
  const frameCount = useRef(0);
1005
1108
 
1006
1109
  // Compute target camera position
1110
+ // When flat, use a more top-down view; when grown, use an angled view
1007
1111
  const targetPos = useMemo(() => {
1008
1112
  if (focusTarget) {
1009
1113
  const distance = Math.max(focusTarget.size * 2, 50);
@@ -1017,9 +1121,15 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps)
1017
1121
  targetZ: focusTarget.z,
1018
1122
  };
1019
1123
  }
1020
- // Default overview
1021
- const targetHeight = citySize * 1.1;
1022
- const targetZ = citySize * 1.3;
1124
+ // Default overview - adjust angle based on flat/grown state
1125
+ // Flat: directly overhead (90 degrees, looking straight down)
1126
+ // Grown: angled view to see building heights (optionally based on max building height)
1127
+ const baseHeight = citySize * 1.1;
1128
+ const buildingAwareHeight = maxBuildingHeight > 0
1129
+ ? Math.max(baseHeight, maxBuildingHeight * 2.5)
1130
+ : baseHeight;
1131
+ const targetHeight = isFlat ? citySize * 2.0 : buildingAwareHeight;
1132
+ const targetZ = isFlat ? 0.001 : citySize * 1.3; // Near-zero for top-down (tiny offset to avoid gimbal lock)
1023
1133
  return {
1024
1134
  x: 0,
1025
1135
  y: targetHeight,
@@ -1028,7 +1138,7 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps)
1028
1138
  targetY: 0,
1029
1139
  targetZ: 0,
1030
1140
  };
1031
- }, [focusTarget, citySize]);
1141
+ }, [focusTarget, citySize, isFlat, maxBuildingHeight]);
1032
1142
 
1033
1143
  // Spring animation for camera movement
1034
1144
  const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => ({
@@ -1047,6 +1157,48 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps)
1047
1157
  },
1048
1158
  }));
1049
1159
 
1160
+ // Separate spring for orbit angle animation (animates along horizontal arc)
1161
+ const [{ orbitAngle }, orbitApi] = useSpring(() => ({
1162
+ orbitAngle: 0,
1163
+ config: { tension: 80, friction: 18 },
1164
+ onStart: () => {
1165
+ isOrbitingRef.current = true;
1166
+ },
1167
+ onRest: () => {
1168
+ isOrbitingRef.current = false;
1169
+ },
1170
+ }));
1171
+
1172
+ // Separate spring for tilt angle animation (animates along vertical arc)
1173
+ const isTiltingRef = useRef(false);
1174
+ const [{ tiltAngle }, tiltApi] = useSpring(() => ({
1175
+ tiltAngle: 0,
1176
+ config: { tension: 80, friction: 18 },
1177
+ onStart: () => {
1178
+ isTiltingRef.current = true;
1179
+ },
1180
+ onRest: () => {
1181
+ isTiltingRef.current = false;
1182
+ },
1183
+ }));
1184
+
1185
+ // Track orbit parameters during horizontal rotation
1186
+ const orbitParamsRef = useRef<{
1187
+ centerX: number;
1188
+ centerZ: number;
1189
+ distance: number;
1190
+ height: number;
1191
+ } | null>(null);
1192
+
1193
+ // Track tilt parameters during vertical rotation
1194
+ const tiltParamsRef = useRef<{
1195
+ centerX: number;
1196
+ centerY: number;
1197
+ centerZ: number;
1198
+ distance: number;
1199
+ azimuthAngle: number; // horizontal angle to maintain
1200
+ } | null>(null);
1201
+
1050
1202
  // When targetPos changes after initial, animate to new position
1051
1203
  useEffect(() => {
1052
1204
  // Skip the first render - we handle that directly in useFrame
@@ -1093,8 +1245,62 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps)
1093
1245
  return;
1094
1246
  }
1095
1247
 
1096
- // Subsequent frames: only update during animations
1097
- if (isAnimatingRef.current) {
1248
+ // Handle orbit animation (horizontal rotation along arc)
1249
+ if (isOrbitingRef.current && orbitParamsRef.current) {
1250
+ const { centerX, centerZ, distance, height } = orbitParamsRef.current;
1251
+ const currentAngle = orbitAngle.get();
1252
+ const radians = (currentAngle * Math.PI) / 180;
1253
+
1254
+ const newX = centerX + Math.sin(radians) * distance;
1255
+ const newZ = centerZ + Math.cos(radians) * distance;
1256
+
1257
+ camera.position.set(newX, height, newZ);
1258
+ controlsRef.current.target.set(centerX, 0, centerZ);
1259
+ controlsRef.current.update();
1260
+
1261
+ // Sync position spring to current orbit position
1262
+ api.set({
1263
+ camX: newX,
1264
+ camY: height,
1265
+ camZ: newZ,
1266
+ lookX: centerX,
1267
+ lookY: 0,
1268
+ lookZ: centerZ,
1269
+ });
1270
+ }
1271
+ // Handle tilt animation (vertical rotation along arc)
1272
+ else if (isTiltingRef.current && tiltParamsRef.current) {
1273
+ const { centerX, centerY, centerZ, distance, azimuthAngle } = tiltParamsRef.current;
1274
+ const currentTilt = tiltAngle.get();
1275
+
1276
+ // Convert tilt angle to polar angle (0° tilt = looking down, 90° tilt = level)
1277
+ // Clamp to avoid extreme angles
1278
+ const clampedTilt = Math.max(5, Math.min(85, currentTilt));
1279
+ const polarRadians = (clampedTilt * Math.PI) / 180;
1280
+ const azimuthRadians = (azimuthAngle * Math.PI) / 180;
1281
+
1282
+ // Spherical to Cartesian conversion
1283
+ // polarRadians: 0 = top, PI/2 = level
1284
+ const newX = centerX + distance * Math.sin(polarRadians) * Math.sin(azimuthRadians);
1285
+ const newY = centerY + distance * Math.cos(polarRadians);
1286
+ const newZ = centerZ + distance * Math.sin(polarRadians) * Math.cos(azimuthRadians);
1287
+
1288
+ camera.position.set(newX, newY, newZ);
1289
+ controlsRef.current.target.set(centerX, centerY, centerZ);
1290
+ controlsRef.current.update();
1291
+
1292
+ // Sync position spring to current tilt position
1293
+ api.set({
1294
+ camX: newX,
1295
+ camY: newY,
1296
+ camZ: newZ,
1297
+ lookX: centerX,
1298
+ lookY: centerY,
1299
+ lookZ: centerZ,
1300
+ });
1301
+ }
1302
+ // Handle position animation
1303
+ else if (isAnimatingRef.current) {
1098
1304
  camera.position.set(camX.get(), camY.get(), camZ.get());
1099
1305
  controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
1100
1306
  controlsRef.current.update();
@@ -1115,12 +1321,268 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps)
1115
1321
  });
1116
1322
  }, [citySize, api]);
1117
1323
 
1324
+ const moveTo = useCallback((x: number, z: number, size?: number) => {
1325
+ const effectiveSize = size ?? citySize * 0.3;
1326
+ const distance = Math.max(effectiveSize * 2, 50);
1327
+ const height = Math.max(effectiveSize * 1.5, 40);
1328
+
1329
+ api.start({
1330
+ camX: x,
1331
+ camY: height,
1332
+ camZ: z + distance,
1333
+ lookX: x,
1334
+ lookY: 0,
1335
+ lookZ: z,
1336
+ });
1337
+ }, [citySize, api]);
1338
+
1339
+ // Set camera target (look-at point), maintaining current distance and angles
1340
+ const setTarget = useCallback((x: number, y: number, z: number, options?: RotateOptions) => {
1341
+ // Get current offset from target
1342
+ const currentTargetX = controlsRef.current?.target.x ?? 0;
1343
+ const currentTargetY = controlsRef.current?.target.y ?? 0;
1344
+ const currentTargetZ = controlsRef.current?.target.z ?? 0;
1345
+
1346
+ const offsetX = camera.position.x - currentTargetX;
1347
+ const offsetY = camera.position.y - currentTargetY;
1348
+ const offsetZ = camera.position.z - currentTargetZ;
1349
+
1350
+ // New camera position maintains same offset from new target
1351
+ const newCamX = x + offsetX;
1352
+ const newCamY = y + offsetY;
1353
+ const newCamZ = z + offsetZ;
1354
+
1355
+ // Build animation config
1356
+ const animConfig = options?.duration
1357
+ ? { duration: options.duration, easing: (t: number) => t }
1358
+ : { tension: 60, friction: 20 };
1359
+
1360
+ api.start({
1361
+ camX: newCamX,
1362
+ camY: newCamY,
1363
+ camZ: newCamZ,
1364
+ lookX: x,
1365
+ lookY: y,
1366
+ lookZ: z,
1367
+ config: animConfig,
1368
+ });
1369
+ }, [camera, api]);
1370
+
1371
+ // Convert cardinal direction to angle in degrees
1372
+ const directionToAngle = (dir: 'north' | 'south' | 'east' | 'west'): number => {
1373
+ switch (dir) {
1374
+ case 'north': return 180;
1375
+ case 'south': return 0;
1376
+ case 'east': return 270;
1377
+ case 'west': return 90;
1378
+ }
1379
+ };
1380
+
1381
+ // Get current angle (helper)
1382
+ const computeCurrentAngle = useCallback(() => {
1383
+ const targetX = controlsRef.current?.target.x ?? 0;
1384
+ const targetZ = controlsRef.current?.target.z ?? 0;
1385
+ const dx = camera.position.x - targetX;
1386
+ const dz = camera.position.z - targetZ;
1387
+ let angle = Math.atan2(dx, dz) * (180 / Math.PI);
1388
+ if (angle < 0) angle += 360;
1389
+ return angle;
1390
+ }, [camera]);
1391
+
1392
+ // Rotate to absolute angle using shortest path
1393
+ const rotateTo = useCallback((
1394
+ angleOrDirection: number | 'north' | 'south' | 'east' | 'west',
1395
+ options?: RotateOptions
1396
+ ) => {
1397
+ const targetAngle = typeof angleOrDirection === 'number'
1398
+ ? angleOrDirection
1399
+ : directionToAngle(angleOrDirection);
1400
+
1401
+ // Get current state
1402
+ const centerX = controlsRef.current?.target.x ?? 0;
1403
+ const centerZ = controlsRef.current?.target.z ?? 0;
1404
+ const currentAngle = computeCurrentAngle();
1405
+ const distance = Math.sqrt(
1406
+ Math.pow(camera.position.x - centerX, 2) +
1407
+ Math.pow(camera.position.z - centerZ, 2)
1408
+ );
1409
+ const height = camera.position.y;
1410
+
1411
+ // Store orbit parameters
1412
+ orbitParamsRef.current = { centerX, centerZ, distance, height };
1413
+
1414
+ // Calculate shortest path
1415
+ let delta = targetAngle - currentAngle;
1416
+ // Normalize to -180 to 180
1417
+ while (delta > 180) delta -= 360;
1418
+ while (delta < -180) delta += 360;
1419
+
1420
+ // Build animation config
1421
+ const animConfig = options?.duration
1422
+ ? { duration: options.duration }
1423
+ : { tension: 80, friction: 18 };
1424
+
1425
+ // Animate from current angle to target using shortest path
1426
+ orbitApi.set({ orbitAngle: currentAngle });
1427
+ orbitApi.start({ orbitAngle: currentAngle + delta, config: animConfig });
1428
+ }, [camera, computeCurrentAngle, orbitApi]);
1429
+
1430
+ // Rotate by relative degrees (positive = clockwise, negative = counter-clockwise)
1431
+ const rotateBy = useCallback((degrees: number, options?: RotateOptions) => {
1432
+ // Get current state
1433
+ const centerX = controlsRef.current?.target.x ?? 0;
1434
+ const centerZ = controlsRef.current?.target.z ?? 0;
1435
+ const currentAngle = computeCurrentAngle();
1436
+ const distance = Math.sqrt(
1437
+ Math.pow(camera.position.x - centerX, 2) +
1438
+ Math.pow(camera.position.z - centerZ, 2)
1439
+ );
1440
+ const height = camera.position.y;
1441
+
1442
+ // Store orbit parameters
1443
+ orbitParamsRef.current = { centerX, centerZ, distance, height };
1444
+
1445
+ // Build animation config
1446
+ const animConfig = options?.duration
1447
+ ? { duration: options.duration }
1448
+ : { tension: 80, friction: 18 };
1449
+
1450
+ // Animate from current angle by the specified degrees
1451
+ orbitApi.set({ orbitAngle: currentAngle });
1452
+ orbitApi.start({ orbitAngle: currentAngle + degrees, config: animConfig });
1453
+ }, [camera, computeCurrentAngle, orbitApi]);
1454
+
1455
+ // Convert tilt preset to angle
1456
+ const tiltPresetToAngle = (preset: 'top' | 'level' | 'high' | 'low'): number => {
1457
+ switch (preset) {
1458
+ case 'top': return 15; // Near top-down
1459
+ case 'high': return 35; // High angle
1460
+ case 'low': return 60; // Low angle
1461
+ case 'level': return 80; // Near horizontal
1462
+ }
1463
+ };
1464
+
1465
+ // Compute current tilt angle (polar angle in degrees)
1466
+ const computeCurrentTilt = useCallback(() => {
1467
+ const centerX = controlsRef.current?.target.x ?? 0;
1468
+ const centerY = controlsRef.current?.target.y ?? 0;
1469
+ const centerZ = controlsRef.current?.target.z ?? 0;
1470
+
1471
+ const dx = camera.position.x - centerX;
1472
+ const dy = camera.position.y - centerY;
1473
+ const dz = camera.position.z - centerZ;
1474
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
1475
+
1476
+ if (distance === 0) return 45; // Default
1477
+
1478
+ // Polar angle: arccos(dy / distance)
1479
+ const polarRadians = Math.acos(dy / distance);
1480
+ return (polarRadians * 180) / Math.PI;
1481
+ }, [camera]);
1482
+
1483
+ // Tilt to absolute angle or preset
1484
+ const tiltTo = useCallback((
1485
+ angleOrPreset: number | 'top' | 'level' | 'high' | 'low',
1486
+ options?: RotateOptions
1487
+ ) => {
1488
+ const targetTilt = typeof angleOrPreset === 'number'
1489
+ ? angleOrPreset
1490
+ : tiltPresetToAngle(angleOrPreset);
1491
+
1492
+ // Get current state
1493
+ const centerX = controlsRef.current?.target.x ?? 0;
1494
+ const centerY = controlsRef.current?.target.y ?? 0;
1495
+ const centerZ = controlsRef.current?.target.z ?? 0;
1496
+
1497
+ const dx = camera.position.x - centerX;
1498
+ const dy = camera.position.y - centerY;
1499
+ const dz = camera.position.z - centerZ;
1500
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
1501
+ const currentTilt = computeCurrentTilt();
1502
+ const azimuthAngle = computeCurrentAngle();
1503
+
1504
+ // Store tilt parameters
1505
+ tiltParamsRef.current = { centerX, centerY, centerZ, distance, azimuthAngle };
1506
+
1507
+ // Build animation config
1508
+ const animConfig = options?.duration
1509
+ ? { duration: options.duration }
1510
+ : { tension: 80, friction: 18 };
1511
+
1512
+ // Animate from current tilt to target
1513
+ tiltApi.set({ tiltAngle: currentTilt });
1514
+ tiltApi.start({ tiltAngle: targetTilt, config: animConfig });
1515
+ }, [camera, computeCurrentTilt, computeCurrentAngle, tiltApi]);
1516
+
1517
+ // Tilt by relative degrees (positive = down towards top-down, negative = up towards level)
1518
+ const tiltBy = useCallback((degrees: number, options?: RotateOptions) => {
1519
+ // Get current state
1520
+ const centerX = controlsRef.current?.target.x ?? 0;
1521
+ const centerY = controlsRef.current?.target.y ?? 0;
1522
+ const centerZ = controlsRef.current?.target.z ?? 0;
1523
+
1524
+ const dx = camera.position.x - centerX;
1525
+ const dy = camera.position.y - centerY;
1526
+ const dz = camera.position.z - centerZ;
1527
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
1528
+ const currentTilt = computeCurrentTilt();
1529
+ const azimuthAngle = computeCurrentAngle();
1530
+
1531
+ // Store tilt parameters
1532
+ tiltParamsRef.current = { centerX, centerY, centerZ, distance, azimuthAngle };
1533
+
1534
+ // Build animation config
1535
+ const animConfig = options?.duration
1536
+ ? { duration: options.duration }
1537
+ : { tension: 80, friction: 18 };
1538
+
1539
+ // Animate from current tilt by the specified degrees
1540
+ tiltApi.set({ tiltAngle: currentTilt });
1541
+ tiltApi.start({ tiltAngle: currentTilt + degrees, config: animConfig });
1542
+ }, [camera, computeCurrentTilt, computeCurrentAngle, tiltApi]);
1543
+
1544
+ const getCurrentPosition = useCallback(() => {
1545
+ return {
1546
+ x: camera.position.x,
1547
+ y: camera.position.y,
1548
+ z: camera.position.z,
1549
+ };
1550
+ }, [camera]);
1551
+
1552
+ const getCurrentTarget = useCallback(() => {
1553
+ return {
1554
+ x: controlsRef.current?.target.x ?? 0,
1555
+ y: controlsRef.current?.target.y ?? 0,
1556
+ z: controlsRef.current?.target.z ?? 0,
1557
+ };
1558
+ }, []);
1559
+
1560
+ const getCurrentAngle = useCallback(() => {
1561
+ return computeCurrentAngle();
1562
+ }, [computeCurrentAngle]);
1563
+
1564
+ const getCurrentTilt = useCallback(() => {
1565
+ return computeCurrentTilt();
1566
+ }, [computeCurrentTilt]);
1567
+
1118
1568
  useEffect(() => {
1119
- cameraResetFn = resetToInitial;
1569
+ cameraApi = {
1570
+ reset: resetToInitial,
1571
+ moveTo,
1572
+ setTarget,
1573
+ rotateTo,
1574
+ rotateBy,
1575
+ tiltTo,
1576
+ tiltBy,
1577
+ getCurrentPosition,
1578
+ getCurrentTarget,
1579
+ getCurrentAngle,
1580
+ getCurrentTilt,
1581
+ };
1120
1582
  return () => {
1121
- cameraResetFn = null;
1583
+ cameraApi = null;
1122
1584
  };
1123
- }, [resetToInitial]);
1585
+ }, [resetToInitial, moveTo, setTarget, rotateTo, rotateBy, tiltTo, tiltBy, getCurrentPosition, getCurrentTarget, getCurrentAngle, getCurrentTilt]);
1124
1586
 
1125
1587
  return (
1126
1588
  <>
@@ -1147,9 +1609,6 @@ function InfoPanel({ building }: InfoPanelProps) {
1147
1609
 
1148
1610
  const fileName = building.path.split('/').pop();
1149
1611
  const dirPath = building.path.split('/').slice(0, -1).join('/');
1150
- const rawExt = building.fileExtension || building.path.split('.').pop() || '';
1151
- const ext = rawExt.replace(/^\./, '');
1152
- const isCode = isCodeFile(ext);
1153
1612
 
1154
1613
  return (
1155
1614
  <div
@@ -1243,6 +1702,8 @@ interface CitySceneProps {
1243
1702
  heightScaling: HeightScaling;
1244
1703
  linearScale: number;
1245
1704
  focusDirectory: string | null;
1705
+ focusColor?: string | null;
1706
+ adaptCameraToBuildings?: boolean;
1246
1707
  }
1247
1708
 
1248
1709
  function CityScene({
@@ -1258,6 +1719,8 @@ function CityScene({
1258
1719
  heightScaling,
1259
1720
  linearScale,
1260
1721
  focusDirectory,
1722
+ focusColor,
1723
+ adaptCameraToBuildings = false,
1261
1724
  }: CitySceneProps) {
1262
1725
  const centerOffset = useMemo(
1263
1726
  () => ({
@@ -1272,6 +1735,12 @@ function CityScene({
1272
1735
  cityData.bounds.maxZ - cityData.bounds.minZ,
1273
1736
  );
1274
1737
 
1738
+ // Calculate max building height for camera positioning (when adaptCameraToBuildings is true)
1739
+ const maxBuildingHeight = useMemo(() => {
1740
+ if (!adaptCameraToBuildings) return 0;
1741
+ return Math.max(...cityData.buildings.map(b => b.dimensions[1]), 0);
1742
+ }, [adaptCameraToBuildings, cityData.buildings]);
1743
+
1275
1744
  const activeHighlights = useMemo(() => hasActiveHighlights(highlightLayers), [highlightLayers]);
1276
1745
 
1277
1746
  // Helper to check if a path is inside a directory
@@ -1285,10 +1754,12 @@ function CityScene({
1285
1754
  // Phase 2: Buildings collapse/expand
1286
1755
  // Phase 3: Camera zooms into new directory
1287
1756
  //
1288
- // We track two separate states:
1757
+ // We track three separate states for smooth transitions:
1289
1758
  // - buildingFocusDirectory: controls which buildings are collapsed (passed to InstancedBuildings)
1759
+ // - buildingFocusColor: the color for the focused district (synced with buildingFocusDirectory)
1290
1760
  // - cameraFocusDirectory: controls camera position (used for focusTarget calculation)
1291
1761
  const [buildingFocusDirectory, setBuildingFocusDirectory] = useState<string | null>(null);
1762
+ const [buildingFocusColor, setBuildingFocusColor] = useState<string | null>(null);
1292
1763
  const [cameraFocusDirectory, setCameraFocusDirectory] = useState<string | null>(null);
1293
1764
  const prevFocusDirectoryRef = useRef<string | null>(null);
1294
1765
  const animationTimersRef = useRef<NodeJS.Timeout[]>([]);
@@ -1306,25 +1777,60 @@ function CityScene({
1306
1777
 
1307
1778
  // Case 1: Going from overview to a directory (null -> dir)
1308
1779
  if (prevFocus === null && focusDirectory !== null) {
1309
- // Phase 1: Collapse buildings immediately
1780
+ // Check if camera is already focused on this area via highlight layers
1781
+ const highlightMatchesFocus = highlightLayers.some(
1782
+ layer => layer.enabled && layer.items.some(
1783
+ item => item.type === 'directory' && (
1784
+ item.path === focusDirectory ||
1785
+ focusDirectory.startsWith(item.path + '/')
1786
+ )
1787
+ )
1788
+ );
1789
+
1790
+ // Phase 1: Collapse buildings immediately with the new color
1310
1791
  setBuildingFocusDirectory(focusDirectory);
1311
- // Phase 2: After collapse settles, zoom camera in
1312
- const timer = setTimeout(() => {
1792
+ setBuildingFocusColor(focusColor ?? null);
1793
+
1794
+ if (highlightMatchesFocus) {
1795
+ // Camera is already there, set immediately
1313
1796
  setCameraFocusDirectory(focusDirectory);
1314
- }, 600);
1315
- animationTimersRef.current.push(timer);
1797
+ } else {
1798
+ // Phase 2: After collapse settles, zoom camera in
1799
+ const timer = setTimeout(() => {
1800
+ setCameraFocusDirectory(focusDirectory);
1801
+ }, 600);
1802
+ animationTimersRef.current.push(timer);
1803
+ }
1316
1804
  return;
1317
1805
  }
1318
1806
 
1319
1807
  // Case 2: Going from a directory to overview (dir -> null)
1320
1808
  if (prevFocus !== null && focusDirectory === null) {
1321
- // Phase 1: Zoom camera out first
1322
- setCameraFocusDirectory(null);
1323
- // Phase 2: After zoom-out settles, expand buildings
1324
- const timer = setTimeout(() => {
1809
+ // Check if highlight layers will keep camera focused on same area
1810
+ const highlightMatchesPrevFocus = highlightLayers.some(
1811
+ layer => layer.enabled && layer.items.some(
1812
+ item => item.type === 'directory' && (
1813
+ item.path === prevFocus ||
1814
+ prevFocus.startsWith(item.path + '/')
1815
+ )
1816
+ )
1817
+ );
1818
+
1819
+ if (highlightMatchesPrevFocus) {
1820
+ // Camera will stay focused via highlights, just clear focus state
1821
+ setCameraFocusDirectory(null);
1325
1822
  setBuildingFocusDirectory(null);
1326
- }, 500);
1327
- animationTimersRef.current.push(timer);
1823
+ setBuildingFocusColor(null);
1824
+ } else {
1825
+ // Phase 1: Zoom camera out first
1826
+ setCameraFocusDirectory(null);
1827
+ // Phase 2: After zoom-out settles, expand buildings and clear color
1828
+ const timer = setTimeout(() => {
1829
+ setBuildingFocusDirectory(null);
1830
+ setBuildingFocusColor(null);
1831
+ }, 500);
1832
+ animationTimersRef.current.push(timer);
1833
+ }
1328
1834
  return;
1329
1835
  }
1330
1836
 
@@ -1332,9 +1838,10 @@ function CityScene({
1332
1838
  if (prevFocus !== null && focusDirectory !== null) {
1333
1839
  // Phase 1: Zoom camera out
1334
1840
  setCameraFocusDirectory(null);
1335
- // Phase 2: After zoom-out, collapse/expand buildings
1841
+ // Phase 2: After zoom-out, collapse/expand buildings with new color
1336
1842
  const timer1 = setTimeout(() => {
1337
1843
  setBuildingFocusDirectory(focusDirectory);
1844
+ setBuildingFocusColor(focusColor ?? null);
1338
1845
  }, 500);
1339
1846
  // Phase 3: After collapse settles, zoom camera into new directory
1340
1847
  const timer2 = setTimeout(() => {
@@ -1465,7 +1972,7 @@ function CityScene({
1465
1972
 
1466
1973
  return (
1467
1974
  <>
1468
- <AnimatedCamera citySize={citySize} isFlat={growProgress === 0} focusTarget={focusTarget} />
1975
+ <AnimatedCamera citySize={citySize} isFlat={growProgress === 0} focusTarget={focusTarget} maxBuildingHeight={maxBuildingHeight} />
1469
1976
 
1470
1977
  <ambientLight intensity={1.2} />
1471
1978
  <hemisphereLight args={['#ddeeff', '#667788', 0.8]} position={[0, citySize, 0]} />
@@ -1481,14 +1988,39 @@ function CityScene({
1481
1988
  />
1482
1989
  <directionalLight position={[citySize * 0.3, citySize, citySize]} intensity={0.6} />
1483
1990
 
1484
- {cityData.districts.map(district => (
1485
- <DistrictFloor
1486
- key={district.path}
1487
- district={district}
1488
- centerOffset={centerOffset}
1489
- opacity={1}
1490
- />
1491
- ))}
1991
+ {cityData.districts.map(district => {
1992
+ // Check if district matches focusDirectory
1993
+ const isFocused = buildingFocusDirectory
1994
+ ? district.path === buildingFocusDirectory
1995
+ : false;
1996
+
1997
+ // Check if district matches any highlight layer
1998
+ let highlightLayerColor: string | null = null;
1999
+ for (const layer of highlightLayers) {
2000
+ if (!layer.enabled) continue;
2001
+ for (const item of layer.items) {
2002
+ if (item.type === 'directory' && item.path === district.path) {
2003
+ highlightLayerColor = layer.color;
2004
+ break;
2005
+ }
2006
+ }
2007
+ if (highlightLayerColor) break;
2008
+ }
2009
+
2010
+ // Use buildingFocusColor (synced with animation) instead of focusColor prop
2011
+ // Focus color takes priority, then highlight layer color
2012
+ const districtColor = (isFocused && buildingFocusColor) ? buildingFocusColor : highlightLayerColor;
2013
+
2014
+ return (
2015
+ <DistrictFloor
2016
+ key={district.path}
2017
+ district={district}
2018
+ centerOffset={centerOffset}
2019
+ opacity={1}
2020
+ highlightColor={districtColor}
2021
+ />
2022
+ );
2023
+ })}
1492
2024
 
1493
2025
  <InstancedBuildings
1494
2026
  buildings={cityData.buildings}
@@ -1567,6 +2099,8 @@ export interface FileCity3DProps {
1567
2099
  linearScale?: number;
1568
2100
  /** Directory path to focus on - buildings outside will collapse */
1569
2101
  focusDirectory?: string | null;
2102
+ /** Color to highlight the focused directory (hex color, e.g. "#3b82f6") */
2103
+ focusColor?: string | null;
1570
2104
  /** Callback when user clicks on a district to navigate */
1571
2105
  onDirectorySelect?: (directory: string | null) => void;
1572
2106
  /** Background color for the canvas container */
@@ -1575,6 +2109,8 @@ export interface FileCity3DProps {
1575
2109
  textColor?: string;
1576
2110
  /** Currently selected building (controlled by host) */
1577
2111
  selectedBuilding?: CityBuilding | null;
2112
+ /** When true, camera height adjusts based on tallest building when grown */
2113
+ adaptCameraToBuildings?: boolean;
1578
2114
  }
1579
2115
 
1580
2116
  /**
@@ -1596,17 +2132,19 @@ export function FileCity3D({
1596
2132
  showControls = true,
1597
2133
  highlightLayers = [],
1598
2134
  isolationMode = 'transparent',
1599
- dimOpacity = 0.15,
2135
+ dimOpacity: _dimOpacity = 0.15,
1600
2136
  isLoading = false,
1601
2137
  loadingMessage = 'Loading file city...',
1602
2138
  emptyMessage = 'No file tree data available',
1603
2139
  heightScaling = 'logarithmic',
1604
2140
  linearScale = 0.05,
1605
2141
  focusDirectory = null,
1606
- onDirectorySelect,
2142
+ focusColor = null,
2143
+ onDirectorySelect: _onDirectorySelect,
1607
2144
  backgroundColor = '#0f172a',
1608
2145
  textColor = '#94a3b8',
1609
2146
  selectedBuilding = null,
2147
+ adaptCameraToBuildings = false,
1610
2148
  }: FileCity3DProps) {
1611
2149
  const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(null);
1612
2150
  const [internalIsGrown, setInternalIsGrown] = useState(false);
@@ -1628,6 +2166,7 @@ export function FileCity3D({
1628
2166
  } else if (!animationConfig.startFlat) {
1629
2167
  setIsGrown(true);
1630
2168
  }
2169
+ return undefined;
1631
2170
  // eslint-disable-next-line react-hooks/exhaustive-deps
1632
2171
  }, [animationConfig.startFlat, animationConfig.autoStartDelay]);
1633
2172
 
@@ -1720,6 +2259,8 @@ export function FileCity3D({
1720
2259
  heightScaling={heightScaling}
1721
2260
  linearScale={linearScale}
1722
2261
  focusDirectory={focusDirectory}
2262
+ focusColor={focusColor}
2263
+ adaptCameraToBuildings={adaptCameraToBuildings}
1723
2264
  />
1724
2265
  </Canvas>
1725
2266
  <InfoPanel building={selectedBuilding || hoveredBuilding} />