@principal-ai/file-city-react 0.5.34 → 0.5.35

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.
@@ -1 +1 @@
1
- {"version":3,"file":"FileCity3D.d.ts","sourceRoot":"","sources":["../../../src/components/FileCity3D/FileCity3D.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"FileCity3D.d.ts","sourceRoot":"","sources":["../../../src/components/FileCity3D/FileCity3D.tsx"],"names":[],"mappings":"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"}
@@ -666,95 +666,51 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
666
666
  const frameCount = useRef(0);
667
667
  const hasNotifiedReady = useRef(false);
668
668
  const prevIsFlatRef = useRef(isFlat); // Track previous isFlat to detect actual state changes
669
- const isSyncingInitial = useRef(false); // Flag to prevent onStart from triggering during Frame 3 sync
670
- // Calculate camera height to fit city in viewport (for top-down view)
669
+ // Helper to calculate flat camera height with known FOV (50) and aspect ratio
671
670
  // Formula: height = citySize / (2 * tan(fov/2) * min(1, aspect))
672
671
  // Padding factor adds space around the city to match 2D component
673
- const calculateFlatCameraHeight = useCallback(() => {
674
- const perspCam = camera;
675
- const fovRad = (perspCam.fov * Math.PI) / 180;
672
+ const calculateFlatCameraHeight = useCallback((aspect) => {
673
+ const fov = 50; // Known FOV that will be set on PerspectiveCamera
674
+ const fovRad = (fov * Math.PI) / 180;
676
675
  const tanHalfFov = Math.tan(fovRad / 2);
677
- const aspect = perspCam.aspect || 1;
678
676
  // Use min(1, aspect) to handle both landscape and portrait viewports
679
677
  const effectiveAspect = Math.min(1, aspect);
680
678
  const baseHeight = citySize / (2 * tanHalfFov * effectiveAspect);
681
679
  // Add padding to match 2D component's default padding
682
680
  const paddingFactor = 1.08;
683
- const result = baseHeight * paddingFactor;
684
- console.log('[calculateFlatCameraHeight]', {
685
- fov: perspCam.fov,
686
- aspect,
687
- effectiveAspect,
688
- citySize,
689
- baseHeight,
690
- result,
691
- });
692
- return result;
693
- }, [camera, citySize]);
694
- // Compute target camera position
695
- // When flat, always use top-down view (ignore focusTarget)
696
- // When grown, use focusTarget if available, otherwise angled overview
697
- const targetPos = useMemo(() => {
698
- // Flat state: always top-down, ignore any focus
699
- // Height calculated mathematically to match 2D view zoom level
700
- if (isFlat) {
701
- return {
702
- x: 0,
703
- y: calculateFlatCameraHeight(),
704
- z: 0.001, // Near-zero for top-down (tiny offset to avoid gimbal lock)
705
- targetX: 0,
706
- targetY: 0,
707
- targetZ: 0,
708
- };
709
- }
710
- // Grown state: use focusTarget if available
711
- if (focusTarget) {
712
- const distance = Math.max(focusTarget.size * 2, 50);
713
- const height = Math.max(focusTarget.size * 1.5, 40);
714
- return {
715
- x: focusTarget.x,
716
- y: height,
717
- z: focusTarget.z + distance,
718
- targetX: focusTarget.x,
719
- targetY: 0,
720
- targetZ: focusTarget.z,
721
- };
722
- }
723
- // Grown state without focus: angled overview
724
- const baseHeight = citySize * 1.1;
725
- const buildingAwareHeight = maxBuildingHeight > 0
726
- ? Math.max(baseHeight, maxBuildingHeight * 2.5)
727
- : baseHeight;
681
+ return baseHeight * paddingFactor;
682
+ }, [citySize]);
683
+ // Calculate initial 2D position (component always starts in 2D mode)
684
+ // We need aspect ratio from the camera, but we'll use a default until Frame 1
685
+ const getInitial2DPosition = useCallback(() => {
686
+ const perspCam = camera;
687
+ const aspect = perspCam.aspect || 1;
688
+ const height = calculateFlatCameraHeight(aspect);
728
689
  return {
729
690
  x: 0,
730
- y: buildingAwareHeight,
731
- z: citySize * 1.3,
691
+ y: height,
692
+ z: 0.001, // Near-zero for top-down (tiny offset to avoid gimbal lock)
732
693
  targetX: 0,
733
694
  targetY: 0,
734
695
  targetZ: 0,
735
696
  };
736
- }, [focusTarget, citySize, isFlat, maxBuildingHeight, calculateFlatCameraHeight]);
737
- // Freeze initial camera position on Frame 1 (not during render)
738
- // This ensures we capture the correct calculation after canvas is initialized
739
- const initialPosRef = useRef(null);
697
+ }, [camera, calculateFlatCameraHeight]);
740
698
  // Spring animation for camera movement
741
- // Initialize spring with neutral values - will be set properly in Frame 3
699
+ // Initialize with correct 2D position from the start
742
700
  const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => {
743
- console.log('[Spring init] Initializing with neutral values');
701
+ // Calculate initial position with default aspect ratio
702
+ // This will be corrected in Frame 1 if aspect is different
703
+ const initialHeight = calculateFlatCameraHeight(1);
704
+ console.log('[Spring init] Initializing with 2D position, height:', initialHeight);
744
705
  return {
745
706
  camX: 0,
746
- camY: 0,
747
- camZ: 0,
707
+ camY: initialHeight,
708
+ camZ: 0.001,
748
709
  lookX: 0,
749
710
  lookY: 0,
750
711
  lookZ: 0,
751
712
  config: { tension: 60, friction: 20 },
752
713
  onStart: () => {
753
- // Block animations during initial sync in Frame 3
754
- if (isSyncingInitial.current) {
755
- console.log('[Spring onStart] Blocked - syncing initial position');
756
- return;
757
- }
758
714
  // Only allow animations after initial setup is complete
759
715
  if (hasAppliedInitial.current) {
760
716
  console.log('[Spring onStart] Animation starting - camY:', camY.get());
@@ -797,37 +753,26 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
797
753
  const orbitParamsRef = useRef(null);
798
754
  // Track tilt parameters during vertical rotation
799
755
  const tiltParamsRef = useRef(null);
800
- // When isFlat changes after initial setup, animate to new position
801
- // We track isFlat explicitly rather than targetPos to avoid spurious animations
802
- // from aspect ratio changes or other recalculations
756
+ // When isFlat changes from true to false, animate to 3D view
757
+ // Component always starts in 2D, so we only animate the 2D→3D transition
803
758
  useEffect(() => {
804
- console.log('[useEffect] Called - hasAppliedInitial:', hasAppliedInitial.current, 'isFlat:', isFlat, 'prevIsFlat:', prevIsFlatRef.current);
805
- console.log('[useEffect] Dependency check - focusTarget:', focusTarget, 'citySize:', citySize, 'maxBuildingHeight:', maxBuildingHeight);
759
+ console.log('[useEffect] isFlat:', isFlat, 'prevIsFlat:', prevIsFlatRef.current, 'hasAppliedInitial:', hasAppliedInitial.current);
806
760
  // Skip until camera is initialized
807
- if (!hasAppliedInitial.current || !initialPosRef.current) {
761
+ if (!hasAppliedInitial.current) {
808
762
  console.log('[useEffect] Skipping - not initialized yet');
809
763
  return;
810
764
  }
811
- // Only animate if isFlat actually changed (flat <-> grown transition)
765
+ // Only animate if isFlat changed from true to false (2D 3D transition)
812
766
  const isFlatChanged = prevIsFlatRef.current !== isFlat;
813
767
  if (!isFlatChanged) {
814
- // isFlat didn't change, don't update anything
815
- // Use initialPosRef to prevent updates from targetPos recalculations
816
- console.log('[useEffect] No isFlat change - skipping (prevIsFlat:', prevIsFlatRef.current, 'isFlat:', isFlat, ')');
768
+ console.log('[useEffect] No isFlat change - skipping');
817
769
  return;
818
770
  }
819
771
  console.log('[useEffect] isFlat changed from', prevIsFlatRef.current, 'to', isFlat, '- animating transition');
820
772
  prevIsFlatRef.current = isFlat;
821
- // isFlat changed - recalculate target position and animate
773
+ // Calculate target position for 3D view
822
774
  const newPos = isFlat
823
- ? {
824
- x: 0,
825
- y: calculateFlatCameraHeight(),
826
- z: 0.001,
827
- targetX: 0,
828
- targetY: 0,
829
- targetZ: 0,
830
- }
775
+ ? getInitial2DPosition() // Going back to 2D
831
776
  : focusTarget
832
777
  ? {
833
778
  x: focusTarget.x,
@@ -845,9 +790,7 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
845
790
  targetY: 0,
846
791
  targetZ: 0,
847
792
  };
848
- // Update frozen position for future reference
849
- initialPosRef.current = newPos;
850
- console.log('[useEffect] Starting animation to:', newPos);
793
+ console.log('[useEffect] Animating to:', newPos);
851
794
  api.start({
852
795
  camX: newPos.x,
853
796
  camY: newPos.y,
@@ -855,109 +798,52 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
855
798
  lookX: newPos.targetX,
856
799
  lookY: newPos.targetY,
857
800
  lookZ: newPos.targetZ,
858
- onRest: () => {
859
- console.log('[useEffect animation] onRest called');
860
- isAnimatingRef.current = false;
861
- },
862
801
  });
863
- }, [api, isFlat, focusTarget, citySize, maxBuildingHeight, calculateFlatCameraHeight]);
802
+ // eslint-disable-next-line react-hooks/exhaustive-deps
803
+ }, [isFlat]); // Only animate when isFlat changes, not when focusTarget/citySize/etc change
864
804
  // Update camera each frame
865
805
  useFrame(() => {
866
806
  frameCount.current++;
867
- // Capture and apply initial position on first frame
868
- // This prevents the camera from starting at (0,0,0) and causing a flicker
807
+ // On Frame 1: Set camera to initial 2D position and mark as ready
808
+ // Component always starts in 2D mode, so we just need to set the correct position once
869
809
  if (frameCount.current === 1) {
870
- // Ensure camera FOV is set correctly (it defaults to 75 before the prop applies)
810
+ // Ensure camera FOV is correct (defaults to 75 before prop applies)
871
811
  const perspCam = camera;
872
812
  if (perspCam.fov !== 50) {
873
813
  console.log('[Frame 1] Correcting FOV from', perspCam.fov, 'to 50');
874
814
  perspCam.fov = 50;
875
815
  perspCam.updateProjectionMatrix();
876
816
  }
877
- // Calculate position directly on Frame 1 when camera is properly initialized
878
- // Don't rely on memoized targetPos which may have been calculated with stale camera info
879
- if (!initialPosRef.current) {
880
- console.log('[Frame 1] Calculating initial position, isFlat:', isFlat);
881
- // Recalculate fresh to ensure we have correct camera aspect ratio and FOV
882
- const freshPos = isFlat
883
- ? {
884
- x: 0,
885
- y: calculateFlatCameraHeight(),
886
- z: 0.001,
887
- targetX: 0,
888
- targetY: 0,
889
- targetZ: 0,
890
- }
891
- : focusTarget
892
- ? {
893
- x: focusTarget.x,
894
- y: Math.max(focusTarget.size * 1.5, 40),
895
- z: focusTarget.z + Math.max(focusTarget.size * 2, 50),
896
- targetX: focusTarget.x,
897
- targetY: 0,
898
- targetZ: focusTarget.z,
899
- }
900
- : {
901
- x: 0,
902
- y: maxBuildingHeight > 0 ? Math.max(citySize * 1.1, maxBuildingHeight * 2.5) : citySize * 1.1,
903
- z: citySize * 1.3,
904
- targetX: 0,
905
- targetY: 0,
906
- targetZ: 0,
907
- };
908
- console.log('[Frame 1] Calculated position:', freshPos);
909
- initialPosRef.current = freshPos;
817
+ // Calculate initial 2D position with correct aspect ratio
818
+ const initialPos = getInitial2DPosition();
819
+ console.log('[Frame 1] Setting camera to initial 2D position:', initialPos);
820
+ camera.position.set(initialPos.x, initialPos.y, initialPos.z);
821
+ // Wait for controls to be ready, then set target and sync spring
822
+ if (controlsRef.current) {
823
+ controlsRef.current.target.set(initialPos.targetX, initialPos.targetY, initialPos.targetZ);
824
+ controlsRef.current.update();
825
+ // Sync spring to match camera position (use immediate to avoid animation)
826
+ api.start({
827
+ camX: initialPos.x,
828
+ camY: initialPos.y,
829
+ camZ: initialPos.z,
830
+ lookX: initialPos.targetX,
831
+ lookY: initialPos.targetY,
832
+ lookZ: initialPos.targetZ,
833
+ immediate: true,
834
+ });
835
+ hasAppliedInitial.current = true;
836
+ // Notify parent that camera is ready
837
+ if (!hasNotifiedReady.current && onCameraReady) {
838
+ hasNotifiedReady.current = true;
839
+ onCameraReady();
840
+ }
910
841
  }
911
- const pos = initialPosRef.current;
912
- console.log('[Frame 1] Setting camera to:', pos);
913
- camera.position.set(pos.x, pos.y, pos.z);
914
842
  return;
915
843
  }
916
- // Skip first 2 frames to ensure OrbitControls is fully initialized
917
- if (frameCount.current < 3)
918
- return;
919
- if (!controlsRef.current)
920
- return;
921
- // Set initial target and sync spring (after OrbitControls is ready)
922
- // Use frozen initialPosRef to match Frame 1 position
923
- if (!hasAppliedInitial.current && initialPosRef.current) {
924
- const pos = initialPosRef.current;
925
- camera.position.set(pos.x, pos.y, pos.z);
926
- controlsRef.current.target.set(pos.targetX, pos.targetY, pos.targetZ);
927
- controlsRef.current.update();
928
- // Sync spring to this position so future animations start from here
929
- console.log('[Frame 3] Syncing spring to:', pos);
930
- // Set flag to prevent onStart from triggering
931
- isSyncingInitial.current = true;
932
- // Stop any ongoing animations first
933
- api.stop();
934
- // Use api.start with immediate: true to set both current AND target values
935
- // This ensures the spring won't try to animate back to any previous target
936
- api.start({
937
- camX: pos.x,
938
- camY: pos.y,
939
- camZ: pos.z,
940
- lookX: pos.targetX,
941
- lookY: pos.targetY,
942
- lookZ: pos.targetZ,
943
- immediate: true,
944
- });
945
- // Clear the syncing flag after a small delay to ensure spring is settled
946
- setTimeout(() => {
947
- isSyncingInitial.current = false;
948
- console.log('[Frame 3] Sync complete - spring ready for animations');
949
- }, 100);
950
- // Ensure animation flag is off
951
- isAnimatingRef.current = false;
952
- console.log('[Frame 3] Spring values after sync:', { camY: camY.get() });
953
- hasAppliedInitial.current = true;
954
- // Notify parent that camera is ready (only once)
955
- if (!hasNotifiedReady.current && onCameraReady) {
956
- hasNotifiedReady.current = true;
957
- onCameraReady();
958
- }
844
+ // Wait for controls and initialization to complete
845
+ if (!controlsRef.current || !hasAppliedInitial.current)
959
846
  return;
960
- }
961
847
  // Handle orbit animation (horizontal rotation along arc)
962
848
  if (isOrbitingRef.current && orbitParamsRef.current) {
963
849
  const { centerX, centerZ, distance, height } = orbitParamsRef.current;
@@ -1247,6 +1133,12 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
1247
1133
  };
1248
1134
  }, [resetToInitial, moveTo, setTarget, rotateTo, rotateBy, tiltTo, tiltBy, getCurrentPosition, getCurrentTarget, getCurrentAngle, getCurrentTilt]);
1249
1135
  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 })] }));
1136
+ }, (prevProps, nextProps) => {
1137
+ // Custom comparison: only re-render if isFlat, citySize, or maxBuildingHeight changes
1138
+ // Skip re-render when only focusTarget changes (handled internally by useEffect on isFlat)
1139
+ return (prevProps.isFlat === nextProps.isFlat &&
1140
+ prevProps.citySize === nextProps.citySize &&
1141
+ prevProps.maxBuildingHeight === nextProps.maxBuildingHeight);
1250
1142
  });
1251
1143
  function InfoPanel({ building }) {
1252
1144
  if (!building)
@@ -1466,10 +1358,6 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
1466
1358
  return null;
1467
1359
  return cityData.buildings.findIndex(b => b.path === selectedBuilding.path);
1468
1360
  }, [selectedBuilding, cityData.buildings]);
1469
- // Calculate spring duration for animation sync
1470
- const tension = animationConfig.tension || 120;
1471
- const friction = animationConfig.friction || 14;
1472
- const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
1473
1361
  return (_jsxs(_Fragment, { children: [_jsx(AnimatedCamera, { citySize: citySize, isFlat: growProgress === 0, focusTarget: focusTarget, maxBuildingHeight: maxBuildingHeight, onCameraReady: onCameraReady }), _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 => {
1474
1362
  // Check if district matches focusDirectory
1475
1363
  const isFocused = buildingFocusDirectory
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principal-ai/file-city-react",
3
- "version": "0.5.34",
3
+ "version": "0.5.35",
4
4
  "type": "module",
5
5
  "description": "React components for File City visualization",
6
6
  "main": "dist/index.js",
@@ -1156,101 +1156,56 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
1156
1156
  const frameCount = useRef(0);
1157
1157
  const hasNotifiedReady = useRef(false);
1158
1158
  const prevIsFlatRef = useRef(isFlat); // Track previous isFlat to detect actual state changes
1159
- const isSyncingInitial = useRef(false); // Flag to prevent onStart from triggering during Frame 3 sync
1160
1159
 
1161
- // Calculate camera height to fit city in viewport (for top-down view)
1160
+ // Helper to calculate flat camera height with known FOV (50) and aspect ratio
1162
1161
  // Formula: height = citySize / (2 * tan(fov/2) * min(1, aspect))
1163
1162
  // Padding factor adds space around the city to match 2D component
1164
- const calculateFlatCameraHeight = useCallback(() => {
1165
- const perspCam = camera as THREE.PerspectiveCamera;
1166
- const fovRad = (perspCam.fov * Math.PI) / 180;
1163
+ const calculateFlatCameraHeight = useCallback((aspect: number) => {
1164
+ const fov = 50; // Known FOV that will be set on PerspectiveCamera
1165
+ const fovRad = (fov * Math.PI) / 180;
1167
1166
  const tanHalfFov = Math.tan(fovRad / 2);
1168
- const aspect = perspCam.aspect || 1;
1169
1167
  // Use min(1, aspect) to handle both landscape and portrait viewports
1170
1168
  const effectiveAspect = Math.min(1, aspect);
1171
1169
  const baseHeight = citySize / (2 * tanHalfFov * effectiveAspect);
1172
1170
  // Add padding to match 2D component's default padding
1173
1171
  const paddingFactor = 1.08;
1174
- const result = baseHeight * paddingFactor;
1175
- console.log('[calculateFlatCameraHeight]', {
1176
- fov: perspCam.fov,
1177
- aspect,
1178
- effectiveAspect,
1179
- citySize,
1180
- baseHeight,
1181
- result,
1182
- });
1183
- return result;
1184
- }, [camera, citySize]);
1185
-
1186
- // Compute target camera position
1187
- // When flat, always use top-down view (ignore focusTarget)
1188
- // When grown, use focusTarget if available, otherwise angled overview
1189
- const targetPos = useMemo(() => {
1190
- // Flat state: always top-down, ignore any focus
1191
- // Height calculated mathematically to match 2D view zoom level
1192
- if (isFlat) {
1193
- return {
1194
- x: 0,
1195
- y: calculateFlatCameraHeight(),
1196
- z: 0.001, // Near-zero for top-down (tiny offset to avoid gimbal lock)
1197
- targetX: 0,
1198
- targetY: 0,
1199
- targetZ: 0,
1200
- };
1201
- }
1172
+ return baseHeight * paddingFactor;
1173
+ }, [citySize]);
1202
1174
 
1203
- // Grown state: use focusTarget if available
1204
- if (focusTarget) {
1205
- const distance = Math.max(focusTarget.size * 2, 50);
1206
- const height = Math.max(focusTarget.size * 1.5, 40);
1207
- return {
1208
- x: focusTarget.x,
1209
- y: height,
1210
- z: focusTarget.z + distance,
1211
- targetX: focusTarget.x,
1212
- targetY: 0,
1213
- targetZ: focusTarget.z,
1214
- };
1215
- }
1175
+ // Calculate initial 2D position (component always starts in 2D mode)
1176
+ // We need aspect ratio from the camera, but we'll use a default until Frame 1
1177
+ const getInitial2DPosition = useCallback(() => {
1178
+ const perspCam = camera as THREE.PerspectiveCamera;
1179
+ const aspect = perspCam.aspect || 1;
1180
+ const height = calculateFlatCameraHeight(aspect);
1216
1181
 
1217
- // Grown state without focus: angled overview
1218
- const baseHeight = citySize * 1.1;
1219
- const buildingAwareHeight = maxBuildingHeight > 0
1220
- ? Math.max(baseHeight, maxBuildingHeight * 2.5)
1221
- : baseHeight;
1222
1182
  return {
1223
1183
  x: 0,
1224
- y: buildingAwareHeight,
1225
- z: citySize * 1.3,
1184
+ y: height,
1185
+ z: 0.001, // Near-zero for top-down (tiny offset to avoid gimbal lock)
1226
1186
  targetX: 0,
1227
1187
  targetY: 0,
1228
1188
  targetZ: 0,
1229
1189
  };
1230
- }, [focusTarget, citySize, isFlat, maxBuildingHeight, calculateFlatCameraHeight]);
1231
-
1232
- // Freeze initial camera position on Frame 1 (not during render)
1233
- // This ensures we capture the correct calculation after canvas is initialized
1234
- const initialPosRef = useRef<typeof targetPos | null>(null);
1190
+ }, [camera, calculateFlatCameraHeight]);
1235
1191
 
1236
1192
  // Spring animation for camera movement
1237
- // Initialize spring with neutral values - will be set properly in Frame 3
1193
+ // Initialize with correct 2D position from the start
1238
1194
  const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => {
1239
- console.log('[Spring init] Initializing with neutral values');
1195
+ // Calculate initial position with default aspect ratio
1196
+ // This will be corrected in Frame 1 if aspect is different
1197
+ const initialHeight = calculateFlatCameraHeight(1);
1198
+
1199
+ console.log('[Spring init] Initializing with 2D position, height:', initialHeight);
1240
1200
  return {
1241
1201
  camX: 0,
1242
- camY: 0,
1243
- camZ: 0,
1202
+ camY: initialHeight,
1203
+ camZ: 0.001,
1244
1204
  lookX: 0,
1245
1205
  lookY: 0,
1246
1206
  lookZ: 0,
1247
1207
  config: { tension: 60, friction: 20 },
1248
1208
  onStart: () => {
1249
- // Block animations during initial sync in Frame 3
1250
- if (isSyncingInitial.current) {
1251
- console.log('[Spring onStart] Blocked - syncing initial position');
1252
- return;
1253
- }
1254
1209
  // Only allow animations after initial setup is complete
1255
1210
  if (hasAppliedInitial.current) {
1256
1211
  console.log('[Spring onStart] Animation starting - camY:', camY.get());
@@ -1308,42 +1263,31 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
1308
1263
  azimuthAngle: number; // horizontal angle to maintain
1309
1264
  } | null>(null);
1310
1265
 
1311
- // When isFlat changes after initial setup, animate to new position
1312
- // We track isFlat explicitly rather than targetPos to avoid spurious animations
1313
- // from aspect ratio changes or other recalculations
1266
+ // When isFlat changes from true to false, animate to 3D view
1267
+ // Component always starts in 2D, so we only animate the 2D→3D transition
1314
1268
  useEffect(() => {
1315
- console.log('[useEffect] Called - hasAppliedInitial:', hasAppliedInitial.current, 'isFlat:', isFlat, 'prevIsFlat:', prevIsFlatRef.current);
1316
- console.log('[useEffect] Dependency check - focusTarget:', focusTarget, 'citySize:', citySize, 'maxBuildingHeight:', maxBuildingHeight);
1269
+ console.log('[useEffect] isFlat:', isFlat, 'prevIsFlat:', prevIsFlatRef.current, 'hasAppliedInitial:', hasAppliedInitial.current);
1317
1270
 
1318
1271
  // Skip until camera is initialized
1319
- if (!hasAppliedInitial.current || !initialPosRef.current) {
1272
+ if (!hasAppliedInitial.current) {
1320
1273
  console.log('[useEffect] Skipping - not initialized yet');
1321
1274
  return;
1322
1275
  }
1323
1276
 
1324
- // Only animate if isFlat actually changed (flat <-> grown transition)
1277
+ // Only animate if isFlat changed from true to false (2D 3D transition)
1325
1278
  const isFlatChanged = prevIsFlatRef.current !== isFlat;
1326
1279
 
1327
1280
  if (!isFlatChanged) {
1328
- // isFlat didn't change, don't update anything
1329
- // Use initialPosRef to prevent updates from targetPos recalculations
1330
- console.log('[useEffect] No isFlat change - skipping (prevIsFlat:', prevIsFlatRef.current, 'isFlat:', isFlat, ')');
1281
+ console.log('[useEffect] No isFlat change - skipping');
1331
1282
  return;
1332
1283
  }
1333
1284
 
1334
1285
  console.log('[useEffect] isFlat changed from', prevIsFlatRef.current, 'to', isFlat, '- animating transition');
1335
1286
  prevIsFlatRef.current = isFlat;
1336
1287
 
1337
- // isFlat changed - recalculate target position and animate
1288
+ // Calculate target position for 3D view
1338
1289
  const newPos = isFlat
1339
- ? {
1340
- x: 0,
1341
- y: calculateFlatCameraHeight(),
1342
- z: 0.001,
1343
- targetX: 0,
1344
- targetY: 0,
1345
- targetZ: 0,
1346
- }
1290
+ ? getInitial2DPosition() // Going back to 2D
1347
1291
  : focusTarget
1348
1292
  ? {
1349
1293
  x: focusTarget.x,
@@ -1362,10 +1306,7 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
1362
1306
  targetZ: 0,
1363
1307
  };
1364
1308
 
1365
- // Update frozen position for future reference
1366
- initialPosRef.current = newPos;
1367
-
1368
- console.log('[useEffect] Starting animation to:', newPos);
1309
+ console.log('[useEffect] Animating to:', newPos);
1369
1310
  api.start({
1370
1311
  camX: newPos.x,
1371
1312
  camY: newPos.y,
@@ -1373,21 +1314,18 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
1373
1314
  lookX: newPos.targetX,
1374
1315
  lookY: newPos.targetY,
1375
1316
  lookZ: newPos.targetZ,
1376
- onRest: () => {
1377
- console.log('[useEffect animation] onRest called');
1378
- isAnimatingRef.current = false;
1379
- },
1380
1317
  });
1381
- }, [api, isFlat, focusTarget, citySize, maxBuildingHeight, calculateFlatCameraHeight]);
1318
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1319
+ }, [isFlat]); // Only animate when isFlat changes, not when focusTarget/citySize/etc change
1382
1320
 
1383
1321
  // Update camera each frame
1384
1322
  useFrame(() => {
1385
1323
  frameCount.current++;
1386
1324
 
1387
- // Capture and apply initial position on first frame
1388
- // This prevents the camera from starting at (0,0,0) and causing a flicker
1325
+ // On Frame 1: Set camera to initial 2D position and mark as ready
1326
+ // Component always starts in 2D mode, so we just need to set the correct position once
1389
1327
  if (frameCount.current === 1) {
1390
- // Ensure camera FOV is set correctly (it defaults to 75 before the prop applies)
1328
+ // Ensure camera FOV is correct (defaults to 75 before prop applies)
1391
1329
  const perspCam = camera as THREE.PerspectiveCamera;
1392
1330
  if (perspCam.fov !== 50) {
1393
1331
  console.log('[Frame 1] Correcting FOV from', perspCam.fov, 'to 50');
@@ -1395,99 +1333,41 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
1395
1333
  perspCam.updateProjectionMatrix();
1396
1334
  }
1397
1335
 
1398
- // Calculate position directly on Frame 1 when camera is properly initialized
1399
- // Don't rely on memoized targetPos which may have been calculated with stale camera info
1400
- if (!initialPosRef.current) {
1401
- console.log('[Frame 1] Calculating initial position, isFlat:', isFlat);
1402
- // Recalculate fresh to ensure we have correct camera aspect ratio and FOV
1403
- const freshPos = isFlat
1404
- ? {
1405
- x: 0,
1406
- y: calculateFlatCameraHeight(),
1407
- z: 0.001,
1408
- targetX: 0,
1409
- targetY: 0,
1410
- targetZ: 0,
1411
- }
1412
- : focusTarget
1413
- ? {
1414
- x: focusTarget.x,
1415
- y: Math.max(focusTarget.size * 1.5, 40),
1416
- z: focusTarget.z + Math.max(focusTarget.size * 2, 50),
1417
- targetX: focusTarget.x,
1418
- targetY: 0,
1419
- targetZ: focusTarget.z,
1420
- }
1421
- : {
1422
- x: 0,
1423
- y: maxBuildingHeight > 0 ? Math.max(citySize * 1.1, maxBuildingHeight * 2.5) : citySize * 1.1,
1424
- z: citySize * 1.3,
1425
- targetX: 0,
1426
- targetY: 0,
1427
- targetZ: 0,
1428
- };
1429
- console.log('[Frame 1] Calculated position:', freshPos);
1430
- initialPosRef.current = freshPos;
1336
+ // Calculate initial 2D position with correct aspect ratio
1337
+ const initialPos = getInitial2DPosition();
1338
+ console.log('[Frame 1] Setting camera to initial 2D position:', initialPos);
1339
+
1340
+ camera.position.set(initialPos.x, initialPos.y, initialPos.z);
1341
+
1342
+ // Wait for controls to be ready, then set target and sync spring
1343
+ if (controlsRef.current) {
1344
+ controlsRef.current.target.set(initialPos.targetX, initialPos.targetY, initialPos.targetZ);
1345
+ controlsRef.current.update();
1346
+
1347
+ // Sync spring to match camera position (use immediate to avoid animation)
1348
+ api.start({
1349
+ camX: initialPos.x,
1350
+ camY: initialPos.y,
1351
+ camZ: initialPos.z,
1352
+ lookX: initialPos.targetX,
1353
+ lookY: initialPos.targetY,
1354
+ lookZ: initialPos.targetZ,
1355
+ immediate: true,
1356
+ });
1357
+
1358
+ hasAppliedInitial.current = true;
1359
+
1360
+ // Notify parent that camera is ready
1361
+ if (!hasNotifiedReady.current && onCameraReady) {
1362
+ hasNotifiedReady.current = true;
1363
+ onCameraReady();
1364
+ }
1431
1365
  }
1432
- const pos = initialPosRef.current;
1433
- console.log('[Frame 1] Setting camera to:', pos);
1434
- camera.position.set(pos.x, pos.y, pos.z);
1435
1366
  return;
1436
1367
  }
1437
1368
 
1438
- // Skip first 2 frames to ensure OrbitControls is fully initialized
1439
- if (frameCount.current < 3) return;
1440
- if (!controlsRef.current) return;
1441
-
1442
- // Set initial target and sync spring (after OrbitControls is ready)
1443
- // Use frozen initialPosRef to match Frame 1 position
1444
- if (!hasAppliedInitial.current && initialPosRef.current) {
1445
- const pos = initialPosRef.current;
1446
- camera.position.set(pos.x, pos.y, pos.z);
1447
- controlsRef.current.target.set(pos.targetX, pos.targetY, pos.targetZ);
1448
- controlsRef.current.update();
1449
-
1450
- // Sync spring to this position so future animations start from here
1451
- console.log('[Frame 3] Syncing spring to:', pos);
1452
-
1453
- // Set flag to prevent onStart from triggering
1454
- isSyncingInitial.current = true;
1455
-
1456
- // Stop any ongoing animations first
1457
- api.stop();
1458
-
1459
- // Use api.start with immediate: true to set both current AND target values
1460
- // This ensures the spring won't try to animate back to any previous target
1461
- api.start({
1462
- camX: pos.x,
1463
- camY: pos.y,
1464
- camZ: pos.z,
1465
- lookX: pos.targetX,
1466
- lookY: pos.targetY,
1467
- lookZ: pos.targetZ,
1468
- immediate: true,
1469
- });
1470
-
1471
- // Clear the syncing flag after a small delay to ensure spring is settled
1472
- setTimeout(() => {
1473
- isSyncingInitial.current = false;
1474
- console.log('[Frame 3] Sync complete - spring ready for animations');
1475
- }, 100);
1476
-
1477
- // Ensure animation flag is off
1478
- isAnimatingRef.current = false;
1479
-
1480
- console.log('[Frame 3] Spring values after sync:', { camY: camY.get() });
1481
-
1482
- hasAppliedInitial.current = true;
1483
-
1484
- // Notify parent that camera is ready (only once)
1485
- if (!hasNotifiedReady.current && onCameraReady) {
1486
- hasNotifiedReady.current = true;
1487
- onCameraReady();
1488
- }
1489
- return;
1490
- }
1369
+ // Wait for controls and initialization to complete
1370
+ if (!controlsRef.current || !hasAppliedInitial.current) return;
1491
1371
 
1492
1372
  // Handle orbit animation (horizontal rotation along arc)
1493
1373
  if (isOrbitingRef.current && orbitParamsRef.current) {
@@ -1846,6 +1726,14 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
1846
1726
  />
1847
1727
  </>
1848
1728
  );
1729
+ }, (prevProps, nextProps) => {
1730
+ // Custom comparison: only re-render if isFlat, citySize, or maxBuildingHeight changes
1731
+ // Skip re-render when only focusTarget changes (handled internally by useEffect on isFlat)
1732
+ return (
1733
+ prevProps.isFlat === nextProps.isFlat &&
1734
+ prevProps.citySize === nextProps.citySize &&
1735
+ prevProps.maxBuildingHeight === nextProps.maxBuildingHeight
1736
+ );
1849
1737
  });
1850
1738
 
1851
1739
  // Info panel overlay
@@ -2199,11 +2087,6 @@ function CityScene({
2199
2087
  return cityData.buildings.findIndex(b => b.path === selectedBuilding.path);
2200
2088
  }, [selectedBuilding, cityData.buildings]);
2201
2089
 
2202
- // Calculate spring duration for animation sync
2203
- const tension = animationConfig.tension || 120;
2204
- const friction = animationConfig.friction || 14;
2205
- const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
2206
-
2207
2090
  return (
2208
2091
  <>
2209
2092
  <AnimatedCamera