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

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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"}
@@ -654,7 +654,7 @@ export function getCameraAngle() {
654
654
  export function getCameraTilt() {
655
655
  return cameraApi?.getCurrentTilt() ?? null;
656
656
  }
657
- const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, focusTarget, maxBuildingHeight = 0 }) {
657
+ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, focusTarget, maxBuildingHeight = 0, onCameraReady, }) {
658
658
  // Use selector to only subscribe to camera, not the entire R3F state
659
659
  // This prevents re-renders on pointer movement
660
660
  const camera = useThree((state) => state.camera);
@@ -664,7 +664,9 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
664
664
  const isOrbitingRef = useRef(false);
665
665
  const hasAppliedInitial = useRef(false);
666
666
  const frameCount = useRef(0);
667
+ const hasNotifiedReady = useRef(false);
667
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
668
670
  // Calculate camera height to fit city in viewport (for top-down view)
669
671
  // Formula: height = citySize / (2 * tan(fov/2) * min(1, aspect))
670
672
  // Padding factor adds space around the city to match 2D component
@@ -678,7 +680,16 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
678
680
  const baseHeight = citySize / (2 * tanHalfFov * effectiveAspect);
679
681
  // Add padding to match 2D component's default padding
680
682
  const paddingFactor = 1.08;
681
- return baseHeight * paddingFactor;
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;
682
693
  }, [camera, citySize]);
683
694
  // Compute target camera position
684
695
  // When flat, always use top-down view (ignore focusTarget)
@@ -723,28 +734,42 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
723
734
  targetZ: 0,
724
735
  };
725
736
  }, [focusTarget, citySize, isFlat, maxBuildingHeight, calculateFlatCameraHeight]);
726
- // Capture initial camera position on first render only
727
- // This prevents the PerspectiveCamera position prop from causing jumps when targetPos changes
737
+ // Freeze initial camera position on Frame 1 (not during render)
738
+ // This ensures we capture the correct calculation after canvas is initialized
728
739
  const initialPosRef = useRef(null);
729
- if (!initialPosRef.current) {
730
- initialPosRef.current = targetPos;
731
- }
732
740
  // Spring animation for camera movement
733
- const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => ({
734
- camX: targetPos.x,
735
- camY: targetPos.y,
736
- camZ: targetPos.z,
737
- lookX: targetPos.targetX,
738
- lookY: targetPos.targetY,
739
- lookZ: targetPos.targetZ,
740
- config: { tension: 60, friction: 20 },
741
- onStart: () => {
742
- isAnimatingRef.current = true;
743
- },
744
- onRest: () => {
745
- isAnimatingRef.current = false;
746
- },
747
- }));
741
+ // Initialize spring with neutral values - will be set properly in Frame 3
742
+ const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => {
743
+ console.log('[Spring init] Initializing with neutral values');
744
+ return {
745
+ camX: 0,
746
+ camY: 0,
747
+ camZ: 0,
748
+ lookX: 0,
749
+ lookY: 0,
750
+ lookZ: 0,
751
+ config: { tension: 60, friction: 20 },
752
+ 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
+ // Only allow animations after initial setup is complete
759
+ if (hasAppliedInitial.current) {
760
+ console.log('[Spring onStart] Animation starting - camY:', camY.get());
761
+ isAnimatingRef.current = true;
762
+ }
763
+ else {
764
+ console.log('[Spring onStart] Blocked - initialization not complete');
765
+ }
766
+ },
767
+ onRest: () => {
768
+ console.log('[Spring onRest] Animation finished');
769
+ isAnimatingRef.current = false;
770
+ },
771
+ };
772
+ });
748
773
  // Separate spring for orbit angle animation (animates along horizontal arc)
749
774
  const [{ orbitAngle }, orbitApi] = useSpring(() => ({
750
775
  orbitAngle: 0,
@@ -776,61 +801,161 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
776
801
  // We track isFlat explicitly rather than targetPos to avoid spurious animations
777
802
  // from aspect ratio changes or other recalculations
778
803
  useEffect(() => {
779
- // Skip the first render - we handle that directly in useFrame
780
- if (!hasAppliedInitial.current)
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);
806
+ // Skip until camera is initialized
807
+ if (!hasAppliedInitial.current || !initialPosRef.current) {
808
+ console.log('[useEffect] Skipping - not initialized yet');
781
809
  return;
810
+ }
782
811
  // Only animate if isFlat actually changed (flat <-> grown transition)
783
812
  const isFlatChanged = prevIsFlatRef.current !== isFlat;
784
- prevIsFlatRef.current = isFlat;
785
813
  if (!isFlatChanged) {
786
- // isFlat didn't change, just update position directly without animation
787
- // This handles things like focusTarget changes within the same flat/grown state
788
- api.set({
789
- camX: targetPos.x,
790
- camY: targetPos.y,
791
- camZ: targetPos.z,
792
- lookX: targetPos.targetX,
793
- lookY: targetPos.targetY,
794
- lookZ: targetPos.targetZ,
795
- });
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, ')');
796
817
  return;
797
818
  }
798
- // isFlat changed - animate the transition
819
+ console.log('[useEffect] isFlat changed from', prevIsFlatRef.current, 'to', isFlat, '- animating transition');
820
+ prevIsFlatRef.current = isFlat;
821
+ // isFlat changed - recalculate target position and animate
822
+ const newPos = isFlat
823
+ ? {
824
+ x: 0,
825
+ y: calculateFlatCameraHeight(),
826
+ z: 0.001,
827
+ targetX: 0,
828
+ targetY: 0,
829
+ targetZ: 0,
830
+ }
831
+ : focusTarget
832
+ ? {
833
+ x: focusTarget.x,
834
+ y: Math.max(focusTarget.size * 1.5, 40),
835
+ z: focusTarget.z + Math.max(focusTarget.size * 2, 50),
836
+ targetX: focusTarget.x,
837
+ targetY: 0,
838
+ targetZ: focusTarget.z,
839
+ }
840
+ : {
841
+ x: 0,
842
+ y: maxBuildingHeight > 0 ? Math.max(citySize * 1.1, maxBuildingHeight * 2.5) : citySize * 1.1,
843
+ z: citySize * 1.3,
844
+ targetX: 0,
845
+ targetY: 0,
846
+ targetZ: 0,
847
+ };
848
+ // Update frozen position for future reference
849
+ initialPosRef.current = newPos;
850
+ console.log('[useEffect] Starting animation to:', newPos);
799
851
  api.start({
800
- camX: targetPos.x,
801
- camY: targetPos.y,
802
- camZ: targetPos.z,
803
- lookX: targetPos.targetX,
804
- lookY: targetPos.targetY,
805
- lookZ: targetPos.targetZ,
852
+ camX: newPos.x,
853
+ camY: newPos.y,
854
+ camZ: newPos.z,
855
+ lookX: newPos.targetX,
856
+ lookY: newPos.targetY,
857
+ lookZ: newPos.targetZ,
806
858
  onRest: () => {
859
+ console.log('[useEffect animation] onRest called');
807
860
  isAnimatingRef.current = false;
808
861
  },
809
862
  });
810
- }, [targetPos, api, isFlat]);
863
+ }, [api, isFlat, focusTarget, citySize, maxBuildingHeight, calculateFlatCameraHeight]);
811
864
  // Update camera each frame
812
865
  useFrame(() => {
813
866
  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
869
+ if (frameCount.current === 1) {
870
+ // Ensure camera FOV is set correctly (it defaults to 75 before the prop applies)
871
+ const perspCam = camera;
872
+ if (perspCam.fov !== 50) {
873
+ console.log('[Frame 1] Correcting FOV from', perspCam.fov, 'to 50');
874
+ perspCam.fov = 50;
875
+ perspCam.updateProjectionMatrix();
876
+ }
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;
910
+ }
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
+ return;
915
+ }
814
916
  // Skip first 2 frames to ensure OrbitControls is fully initialized
815
917
  if (frameCount.current < 3)
816
918
  return;
817
919
  if (!controlsRef.current)
818
920
  return;
819
- // Set initial position: apply camera position directly (no spring animation)
820
- if (!hasAppliedInitial.current) {
821
- camera.position.set(targetPos.x, targetPos.y, targetPos.z);
822
- controlsRef.current.target.set(targetPos.targetX, targetPos.targetY, targetPos.targetZ);
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);
823
927
  controlsRef.current.update();
824
928
  // Sync spring to this position so future animations start from here
825
- api.set({
826
- camX: targetPos.x,
827
- camY: targetPos.y,
828
- camZ: targetPos.z,
829
- lookX: targetPos.targetX,
830
- lookY: targetPos.targetY,
831
- lookZ: targetPos.targetZ,
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,
832
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() });
833
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
+ }
834
959
  return;
835
960
  }
836
961
  // Handle orbit animation (horizontal rotation along arc)
@@ -882,6 +1007,11 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
882
1007
  }
883
1008
  // Handle position animation
884
1009
  else if (isAnimatingRef.current) {
1010
+ const springY = camY.get();
1011
+ const currentY = camera.position.y;
1012
+ if (Math.abs(springY - currentY) > 1) {
1013
+ console.log('[useFrame] Spring animating - springY:', springY, 'currentY:', currentY);
1014
+ }
885
1015
  camera.position.set(camX.get(), camY.get(), camZ.get());
886
1016
  controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
887
1017
  controlsRef.current.update();
@@ -1148,24 +1278,31 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera }) {
1148
1278
  const buttonStyle = {
1149
1279
  background: 'rgba(15, 23, 42, 0.9)',
1150
1280
  border: '1px solid #334155',
1151
- borderRadius: 6,
1152
- padding: '8px 16px',
1281
+ borderRadius: 8,
1282
+ padding: '10px',
1153
1283
  color: '#e2e8f0',
1154
- fontSize: 13,
1284
+ fontSize: 14,
1155
1285
  cursor: 'pointer',
1156
1286
  display: 'flex',
1157
1287
  alignItems: 'center',
1158
- gap: 6,
1288
+ justifyContent: 'center',
1289
+ width: 40,
1290
+ height: 40,
1291
+ fontWeight: 500,
1159
1292
  };
1160
- return (_jsxs("div", { style: {
1161
- position: 'absolute',
1162
- top: 16,
1163
- right: 16,
1164
- display: 'flex',
1165
- gap: 8,
1166
- }, children: [_jsx("button", { onClick: onResetCamera, style: buttonStyle, children: "Reset View" }), _jsx("button", { onClick: onToggle, style: buttonStyle, children: isFlat ? 'Grow to 3D' : 'Flatten to 2D' })] }));
1293
+ return (_jsxs(_Fragment, { children: [_jsx("button", { onClick: onToggle, style: {
1294
+ ...buttonStyle,
1295
+ position: 'absolute',
1296
+ top: 8,
1297
+ left: 8,
1298
+ }, children: isFlat ? '3D' : '2D' }), _jsx("button", { onClick: onResetCamera, style: {
1299
+ ...buttonStyle,
1300
+ position: 'absolute',
1301
+ top: 8,
1302
+ right: 8,
1303
+ }, title: "Reset View", children: "\u21BB" })] }));
1167
1304
  }
1168
- function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, flatPatterns, focusDirectory, focusColor, adaptCameraToBuildings = false, }) {
1305
+ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, flatPatterns, focusDirectory, focusColor, adaptCameraToBuildings = false, onCameraReady, }) {
1169
1306
  const centerOffset = useMemo(() => ({
1170
1307
  x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
1171
1308
  z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
@@ -1333,7 +1470,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
1333
1470
  const tension = animationConfig.tension || 120;
1334
1471
  const friction = animationConfig.friction || 14;
1335
1472
  const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
1336
- return (_jsxs(_Fragment, { children: [_jsx(AnimatedCamera, { citySize: citySize, isFlat: growProgress === 0, focusTarget: focusTarget, maxBuildingHeight: maxBuildingHeight }), _jsx("ambientLight", { intensity: 1.2 }), _jsx("hemisphereLight", { args: ['#ddeeff', '#667788', 0.8], position: [0, citySize, 0] }), _jsx("directionalLight", { position: [citySize, citySize * 1.5, citySize * 0.5], intensity: 2, castShadow: true, "shadow-mapSize": [2048, 2048] }), _jsx("directionalLight", { position: [-citySize * 0.5, citySize * 0.8, -citySize * 0.5], intensity: 1 }), _jsx("directionalLight", { position: [citySize * 0.3, citySize, citySize], intensity: 0.6 }), cityData.districts.map(district => {
1473
+ 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 => {
1337
1474
  // Check if district matches focusDirectory
1338
1475
  const isFocused = buildingFocusDirectory
1339
1476
  ? district.path === buildingFocusDirectory
@@ -1367,6 +1504,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
1367
1504
  export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls = false, highlightLayers: externalHighlightLayers, isolationMode: externalIsolationMode, dimOpacity: _dimOpacity = 0.15, isLoading = false, loadingMessage = 'Loading file city...', emptyMessage = 'No file tree data available', heightScaling = 'linear', linearScale = 1, flatPatterns = DEFAULT_FLAT_PATTERNS, focusDirectory: externalFocusDirectory, focusColor: externalFocusColor, onDirectorySelect: _onDirectorySelect, backgroundColor = '#0f172a', textColor = '#94a3b8', selectedBuilding = null, adaptCameraToBuildings = false, fileColorLayers, }) {
1368
1505
  const [hoveredBuilding, setHoveredBuilding] = useState(null);
1369
1506
  const [internalIsGrown, setInternalIsGrown] = useState(false);
1507
+ const [cameraReady, setCameraReady] = useState(false);
1370
1508
  const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
1371
1509
  // ============================================================================
1372
1510
  // Visualization Resolution
@@ -1468,6 +1606,8 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
1468
1606
  left: 0,
1469
1607
  width: '100%',
1470
1608
  height: '100%',
1471
- }, 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, flatPatterns: flatPatterns, focusDirectory: focusDirectory, focusColor: focusColor, adaptCameraToBuildings: adaptCameraToBuildings }) }), _jsx(InfoPanel, { building: selectedBuilding || hoveredBuilding }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
1609
+ opacity: cameraReady ? 1 : 0,
1610
+ transition: 'opacity 0.1s ease-in',
1611
+ }, 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, flatPatterns: flatPatterns, focusDirectory: focusDirectory, focusColor: focusColor, adaptCameraToBuildings: adaptCameraToBuildings, onCameraReady: () => setCameraReady(true) }) }), _jsx(InfoPanel, { building: selectedBuilding }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
1472
1612
  }
1473
1613
  export default FileCity3D;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principal-ai/file-city-react",
3
- "version": "0.5.32",
3
+ "version": "0.5.34",
4
4
  "type": "module",
5
5
  "description": "React components for File City visualization",
6
6
  "main": "dist/index.js",
@@ -1138,7 +1138,13 @@ export function getCameraTilt() {
1138
1138
  return cameraApi?.getCurrentTilt() ?? null;
1139
1139
  }
1140
1140
 
1141
- const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, focusTarget, maxBuildingHeight = 0 }: AnimatedCameraProps) {
1141
+ const AnimatedCamera = React.memo(function AnimatedCamera({
1142
+ citySize,
1143
+ isFlat,
1144
+ focusTarget,
1145
+ maxBuildingHeight = 0,
1146
+ onCameraReady,
1147
+ }: AnimatedCameraProps & { onCameraReady?: () => void }) {
1142
1148
  // Use selector to only subscribe to camera, not the entire R3F state
1143
1149
  // This prevents re-renders on pointer movement
1144
1150
  const camera = useThree((state) => state.camera);
@@ -1148,7 +1154,9 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
1148
1154
  const isOrbitingRef = useRef(false);
1149
1155
  const hasAppliedInitial = useRef(false);
1150
1156
  const frameCount = useRef(0);
1157
+ const hasNotifiedReady = useRef(false);
1151
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
1152
1160
 
1153
1161
  // Calculate camera height to fit city in viewport (for top-down view)
1154
1162
  // Formula: height = citySize / (2 * tan(fov/2) * min(1, aspect))
@@ -1163,7 +1171,16 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
1163
1171
  const baseHeight = citySize / (2 * tanHalfFov * effectiveAspect);
1164
1172
  // Add padding to match 2D component's default padding
1165
1173
  const paddingFactor = 1.08;
1166
- return baseHeight * paddingFactor;
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;
1167
1184
  }, [camera, citySize]);
1168
1185
 
1169
1186
  // Compute target camera position
@@ -1212,29 +1229,42 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
1212
1229
  };
1213
1230
  }, [focusTarget, citySize, isFlat, maxBuildingHeight, calculateFlatCameraHeight]);
1214
1231
 
1215
- // Capture initial camera position on first render only
1216
- // This prevents the PerspectiveCamera position prop from causing jumps when targetPos changes
1232
+ // Freeze initial camera position on Frame 1 (not during render)
1233
+ // This ensures we capture the correct calculation after canvas is initialized
1217
1234
  const initialPosRef = useRef<typeof targetPos | null>(null);
1218
- if (!initialPosRef.current) {
1219
- initialPosRef.current = targetPos;
1220
- }
1221
1235
 
1222
1236
  // Spring animation for camera movement
1223
- const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => ({
1224
- camX: targetPos.x,
1225
- camY: targetPos.y,
1226
- camZ: targetPos.z,
1227
- lookX: targetPos.targetX,
1228
- lookY: targetPos.targetY,
1229
- lookZ: targetPos.targetZ,
1230
- config: { tension: 60, friction: 20 },
1231
- onStart: () => {
1232
- isAnimatingRef.current = true;
1233
- },
1234
- onRest: () => {
1235
- isAnimatingRef.current = false;
1236
- },
1237
- }));
1237
+ // Initialize spring with neutral values - will be set properly in Frame 3
1238
+ const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => {
1239
+ console.log('[Spring init] Initializing with neutral values');
1240
+ return {
1241
+ camX: 0,
1242
+ camY: 0,
1243
+ camZ: 0,
1244
+ lookX: 0,
1245
+ lookY: 0,
1246
+ lookZ: 0,
1247
+ config: { tension: 60, friction: 20 },
1248
+ 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
+ // Only allow animations after initial setup is complete
1255
+ if (hasAppliedInitial.current) {
1256
+ console.log('[Spring onStart] Animation starting - camY:', camY.get());
1257
+ isAnimatingRef.current = true;
1258
+ } else {
1259
+ console.log('[Spring onStart] Blocked - initialization not complete');
1260
+ }
1261
+ },
1262
+ onRest: () => {
1263
+ console.log('[Spring onRest] Animation finished');
1264
+ isAnimatingRef.current = false;
1265
+ },
1266
+ };
1267
+ });
1238
1268
 
1239
1269
  // Separate spring for orbit angle animation (animates along horizontal arc)
1240
1270
  const [{ orbitAngle }, orbitApi] = useSpring(() => ({
@@ -1282,66 +1312,180 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
1282
1312
  // We track isFlat explicitly rather than targetPos to avoid spurious animations
1283
1313
  // from aspect ratio changes or other recalculations
1284
1314
  useEffect(() => {
1285
- // Skip the first render - we handle that directly in useFrame
1286
- if (!hasAppliedInitial.current) return;
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);
1317
+
1318
+ // Skip until camera is initialized
1319
+ if (!hasAppliedInitial.current || !initialPosRef.current) {
1320
+ console.log('[useEffect] Skipping - not initialized yet');
1321
+ return;
1322
+ }
1287
1323
 
1288
1324
  // Only animate if isFlat actually changed (flat <-> grown transition)
1289
1325
  const isFlatChanged = prevIsFlatRef.current !== isFlat;
1290
- prevIsFlatRef.current = isFlat;
1291
1326
 
1292
1327
  if (!isFlatChanged) {
1293
- // isFlat didn't change, just update position directly without animation
1294
- // This handles things like focusTarget changes within the same flat/grown state
1295
- api.set({
1296
- camX: targetPos.x,
1297
- camY: targetPos.y,
1298
- camZ: targetPos.z,
1299
- lookX: targetPos.targetX,
1300
- lookY: targetPos.targetY,
1301
- lookZ: targetPos.targetZ,
1302
- });
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, ')');
1303
1331
  return;
1304
1332
  }
1305
1333
 
1306
- // isFlat changed - animate the transition
1334
+ console.log('[useEffect] isFlat changed from', prevIsFlatRef.current, 'to', isFlat, '- animating transition');
1335
+ prevIsFlatRef.current = isFlat;
1336
+
1337
+ // isFlat changed - recalculate target position and animate
1338
+ const newPos = isFlat
1339
+ ? {
1340
+ x: 0,
1341
+ y: calculateFlatCameraHeight(),
1342
+ z: 0.001,
1343
+ targetX: 0,
1344
+ targetY: 0,
1345
+ targetZ: 0,
1346
+ }
1347
+ : focusTarget
1348
+ ? {
1349
+ x: focusTarget.x,
1350
+ y: Math.max(focusTarget.size * 1.5, 40),
1351
+ z: focusTarget.z + Math.max(focusTarget.size * 2, 50),
1352
+ targetX: focusTarget.x,
1353
+ targetY: 0,
1354
+ targetZ: focusTarget.z,
1355
+ }
1356
+ : {
1357
+ x: 0,
1358
+ y: maxBuildingHeight > 0 ? Math.max(citySize * 1.1, maxBuildingHeight * 2.5) : citySize * 1.1,
1359
+ z: citySize * 1.3,
1360
+ targetX: 0,
1361
+ targetY: 0,
1362
+ targetZ: 0,
1363
+ };
1364
+
1365
+ // Update frozen position for future reference
1366
+ initialPosRef.current = newPos;
1367
+
1368
+ console.log('[useEffect] Starting animation to:', newPos);
1307
1369
  api.start({
1308
- camX: targetPos.x,
1309
- camY: targetPos.y,
1310
- camZ: targetPos.z,
1311
- lookX: targetPos.targetX,
1312
- lookY: targetPos.targetY,
1313
- lookZ: targetPos.targetZ,
1370
+ camX: newPos.x,
1371
+ camY: newPos.y,
1372
+ camZ: newPos.z,
1373
+ lookX: newPos.targetX,
1374
+ lookY: newPos.targetY,
1375
+ lookZ: newPos.targetZ,
1314
1376
  onRest: () => {
1377
+ console.log('[useEffect animation] onRest called');
1315
1378
  isAnimatingRef.current = false;
1316
1379
  },
1317
1380
  });
1318
- }, [targetPos, api, isFlat]);
1381
+ }, [api, isFlat, focusTarget, citySize, maxBuildingHeight, calculateFlatCameraHeight]);
1319
1382
 
1320
1383
  // Update camera each frame
1321
1384
  useFrame(() => {
1322
1385
  frameCount.current++;
1323
1386
 
1387
+ // Capture and apply initial position on first frame
1388
+ // This prevents the camera from starting at (0,0,0) and causing a flicker
1389
+ if (frameCount.current === 1) {
1390
+ // Ensure camera FOV is set correctly (it defaults to 75 before the prop applies)
1391
+ const perspCam = camera as THREE.PerspectiveCamera;
1392
+ if (perspCam.fov !== 50) {
1393
+ console.log('[Frame 1] Correcting FOV from', perspCam.fov, 'to 50');
1394
+ perspCam.fov = 50;
1395
+ perspCam.updateProjectionMatrix();
1396
+ }
1397
+
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;
1431
+ }
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
+ return;
1436
+ }
1437
+
1324
1438
  // Skip first 2 frames to ensure OrbitControls is fully initialized
1325
1439
  if (frameCount.current < 3) return;
1326
1440
  if (!controlsRef.current) return;
1327
1441
 
1328
- // Set initial position: apply camera position directly (no spring animation)
1329
- if (!hasAppliedInitial.current) {
1330
- camera.position.set(targetPos.x, targetPos.y, targetPos.z);
1331
- controlsRef.current.target.set(targetPos.targetX, targetPos.targetY, targetPos.targetZ);
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);
1332
1448
  controlsRef.current.update();
1333
1449
 
1334
1450
  // Sync spring to this position so future animations start from here
1335
- api.set({
1336
- camX: targetPos.x,
1337
- camY: targetPos.y,
1338
- camZ: targetPos.z,
1339
- lookX: targetPos.targetX,
1340
- lookY: targetPos.targetY,
1341
- lookZ: targetPos.targetZ,
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,
1342
1469
  });
1343
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
+
1344
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
+ }
1345
1489
  return;
1346
1490
  }
1347
1491
 
@@ -1401,6 +1545,11 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
1401
1545
  }
1402
1546
  // Handle position animation
1403
1547
  else if (isAnimatingRef.current) {
1548
+ const springY = camY.get();
1549
+ const currentY = camera.position.y;
1550
+ if (Math.abs(springY - currentY) > 1) {
1551
+ console.log('[useFrame] Spring animating - springY:', springY, 'currentY:', currentY);
1552
+ }
1404
1553
  camera.position.set(camX.get(), camY.get(), camZ.get());
1405
1554
  controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
1406
1555
  controlsRef.current.update();
@@ -1758,33 +1907,48 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera }: ControlsOverlayPro
1758
1907
  const buttonStyle = {
1759
1908
  background: 'rgba(15, 23, 42, 0.9)',
1760
1909
  border: '1px solid #334155',
1761
- borderRadius: 6,
1762
- padding: '8px 16px',
1910
+ borderRadius: 8,
1911
+ padding: '10px',
1763
1912
  color: '#e2e8f0',
1764
- fontSize: 13,
1913
+ fontSize: 14,
1765
1914
  cursor: 'pointer',
1766
1915
  display: 'flex',
1767
1916
  alignItems: 'center',
1768
- gap: 6,
1917
+ justifyContent: 'center',
1918
+ width: 40,
1919
+ height: 40,
1920
+ fontWeight: 500,
1769
1921
  };
1770
1922
 
1771
1923
  return (
1772
- <div
1773
- style={{
1774
- position: 'absolute',
1775
- top: 16,
1776
- right: 16,
1777
- display: 'flex',
1778
- gap: 8,
1779
- }}
1780
- >
1781
- <button onClick={onResetCamera} style={buttonStyle}>
1782
- Reset View
1924
+ <>
1925
+ {/* 2D/3D Toggle - Top Left */}
1926
+ <button
1927
+ onClick={onToggle}
1928
+ style={{
1929
+ ...buttonStyle,
1930
+ position: 'absolute',
1931
+ top: 8,
1932
+ left: 8,
1933
+ }}
1934
+ >
1935
+ {isFlat ? '3D' : '2D'}
1783
1936
  </button>
1784
- <button onClick={onToggle} style={buttonStyle}>
1785
- {isFlat ? 'Grow to 3D' : 'Flatten to 2D'}
1937
+
1938
+ {/* Reset Camera - Top Right */}
1939
+ <button
1940
+ onClick={onResetCamera}
1941
+ style={{
1942
+ ...buttonStyle,
1943
+ position: 'absolute',
1944
+ top: 8,
1945
+ right: 8,
1946
+ }}
1947
+ title="Reset View"
1948
+ >
1949
+
1786
1950
  </button>
1787
- </div>
1951
+ </>
1788
1952
  );
1789
1953
  }
1790
1954
 
@@ -1823,7 +1987,8 @@ function CityScene({
1823
1987
  focusDirectory,
1824
1988
  focusColor,
1825
1989
  adaptCameraToBuildings = false,
1826
- }: CitySceneProps) {
1990
+ onCameraReady,
1991
+ }: CitySceneProps & { onCameraReady?: () => void }) {
1827
1992
  const centerOffset = useMemo(
1828
1993
  () => ({
1829
1994
  x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
@@ -2041,7 +2206,13 @@ function CityScene({
2041
2206
 
2042
2207
  return (
2043
2208
  <>
2044
- <AnimatedCamera citySize={citySize} isFlat={growProgress === 0} focusTarget={focusTarget} maxBuildingHeight={maxBuildingHeight} />
2209
+ <AnimatedCamera
2210
+ citySize={citySize}
2211
+ isFlat={growProgress === 0}
2212
+ focusTarget={focusTarget}
2213
+ maxBuildingHeight={maxBuildingHeight}
2214
+ onCameraReady={onCameraReady}
2215
+ />
2045
2216
 
2046
2217
  <ambientLight intensity={1.2} />
2047
2218
  <hemisphereLight args={['#ddeeff', '#667788', 0.8]} position={[0, citySize, 0]} />
@@ -2224,6 +2395,7 @@ export function FileCity3D({
2224
2395
  }: FileCity3DProps) {
2225
2396
  const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(null);
2226
2397
  const [internalIsGrown, setInternalIsGrown] = useState(false);
2398
+ const [cameraReady, setCameraReady] = useState(false);
2227
2399
 
2228
2400
  const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
2229
2401
 
@@ -2356,6 +2528,8 @@ export function FileCity3D({
2356
2528
  left: 0,
2357
2529
  width: '100%',
2358
2530
  height: '100%',
2531
+ opacity: cameraReady ? 1 : 0,
2532
+ transition: 'opacity 0.1s ease-in',
2359
2533
  }}
2360
2534
  >
2361
2535
  <CityScene
@@ -2374,9 +2548,10 @@ export function FileCity3D({
2374
2548
  focusDirectory={focusDirectory}
2375
2549
  focusColor={focusColor}
2376
2550
  adaptCameraToBuildings={adaptCameraToBuildings}
2551
+ onCameraReady={() => setCameraReady(true)}
2377
2552
  />
2378
2553
  </Canvas>
2379
- <InfoPanel building={selectedBuilding || hoveredBuilding} />
2554
+ <InfoPanel building={selectedBuilding} />
2380
2555
  {showControls && (
2381
2556
  <ControlsOverlay isFlat={!isGrown} onToggle={handleToggle} onResetCamera={resetCamera} />
2382
2557
  )}
@@ -536,12 +536,77 @@ export const ScenarioComparison: StoryObj = {
536
536
  * This tests the camera initialization issue where the camera
537
537
  * might flash to an angled position before settling to flat.
538
538
  */
539
+ /**
540
+ * Simple 3D Component in 2D Mode - Tests camera initialization in flat state
541
+ * Renders FileCity3D with buildings flat and camera top-down (no transitions)
542
+ */
543
+ export const ThreeDComponentIn2DMode: StoryObj = {
544
+ render: function RenderThreeDIn2DMode() {
545
+ const [mountKey, setMountKey] = useState(0);
546
+ const cityData = authServerCityData as CityData;
547
+ const highlightLayers = createFileColorHighlightLayers(cityData.buildings);
548
+
549
+ const handleRemount = () => {
550
+ setMountKey(prev => prev + 1);
551
+ };
552
+
553
+ return (
554
+ <div style={{ width: '100vw', height: '100vh', display: 'flex', flexDirection: 'column' }}>
555
+ <div
556
+ style={{
557
+ padding: '12px 16px',
558
+ backgroundColor: '#1f2937',
559
+ borderBottom: '1px solid #374151',
560
+ display: 'flex',
561
+ gap: '16px',
562
+ alignItems: 'center',
563
+ }}
564
+ >
565
+ <button
566
+ onClick={handleRemount}
567
+ style={{
568
+ padding: '8px 16px',
569
+ borderRadius: '6px',
570
+ border: 'none',
571
+ cursor: 'pointer',
572
+ fontSize: '14px',
573
+ fontWeight: 500,
574
+ backgroundColor: '#8b5cf6',
575
+ color: '#ffffff',
576
+ }}
577
+ >
578
+ Remount Component
579
+ </button>
580
+ <span style={{ color: '#9ca3af', fontSize: '13px' }}>
581
+ 3D component in 2D mode (flat) | Mount count: {mountKey + 1}
582
+ </span>
583
+ </div>
584
+
585
+ <div style={{ flex: 1, backgroundColor: '#0f1419' }}>
586
+ <FileCity3D
587
+ key={`filecity-2d-${mountKey}`}
588
+ cityData={cityData}
589
+ highlightLayers={highlightLayers}
590
+ width="100%"
591
+ height="100%"
592
+ isGrown={false}
593
+ animation={{ startFlat: true, autoStartDelay: null }}
594
+ showControls={true}
595
+ backgroundColor="#0f1419"
596
+ />
597
+ </div>
598
+ </div>
599
+ );
600
+ },
601
+ };
602
+
539
603
  export const PanelTransitionTest: StoryObj = {
540
604
  render: function RenderPanelTransitionTest() {
541
605
  const [viewMode, setViewMode] = useState<'2d' | '3d'>('2d');
542
606
  const [isGrown, setIsGrown] = useState(false);
543
607
  const [overlayOpacity, setOverlayOpacity] = useState(1);
544
608
  const [hideOverlay, setHideOverlay] = useState(true);
609
+ const [mountKey, setMountKey] = useState(0);
545
610
  const cityData = authServerCityData as CityData;
546
611
  const highlightLayers = createFileColorHighlightLayers(cityData.buildings);
547
612
 
@@ -558,6 +623,16 @@ export const PanelTransitionTest: StoryObj = {
558
623
  }
559
624
  };
560
625
 
626
+ // Handle remount - force component to unmount and remount
627
+ const handleRemount = () => {
628
+ setMountKey(prev => prev + 1);
629
+ // Reset to initial state
630
+ setViewMode('2d');
631
+ setIsGrown(false);
632
+ setOverlayOpacity(1);
633
+ setHideOverlay(true);
634
+ };
635
+
561
636
  // Handle transition timing
562
637
  useEffect(() => {
563
638
  if (viewMode === '3d') {
@@ -612,8 +687,23 @@ export const PanelTransitionTest: StoryObj = {
612
687
  >
613
688
  {viewMode === '2d' ? 'Switch to 3D' : 'Switch to 2D'}
614
689
  </button>
690
+ <button
691
+ onClick={handleRemount}
692
+ style={{
693
+ padding: '8px 16px',
694
+ borderRadius: '6px',
695
+ border: 'none',
696
+ cursor: 'pointer',
697
+ fontSize: '14px',
698
+ fontWeight: 500,
699
+ backgroundColor: '#8b5cf6',
700
+ color: '#ffffff',
701
+ }}
702
+ >
703
+ Remount Component
704
+ </button>
615
705
  <span style={{ color: '#9ca3af', fontSize: '13px' }}>
616
- viewMode: {viewMode} | isGrown: {String(isGrown)} | hideOverlay: {String(hideOverlay)} | opacity: {overlayOpacity}
706
+ viewMode: {viewMode} | isGrown: {String(isGrown)} | hideOverlay: {String(hideOverlay)} | opacity: {overlayOpacity} | mounts: {mountKey + 1}
617
707
  </span>
618
708
  </div>
619
709
 
@@ -621,6 +711,7 @@ export const PanelTransitionTest: StoryObj = {
621
711
  {/* 3D layer - renders when in 3D mode */}
622
712
  {viewMode === '3d' && (
623
713
  <FileCity3D
714
+ key={`filecity-${mountKey}`}
624
715
  cityData={cityData}
625
716
  highlightLayers={highlightLayers}
626
717
  width="100%"
@@ -650,6 +741,7 @@ export const PanelTransitionTest: StoryObj = {
650
741
  }}
651
742
  >
652
743
  <ArchitectureMapHighlightLayers
744
+ key={`canvas-${mountKey}`}
653
745
  cityData={cityData}
654
746
  highlightLayers={highlightLayers}
655
747
  fullSize={true}
@@ -680,6 +680,24 @@ export const ElectronApp: Story = {
680
680
  },
681
681
  };
682
682
 
683
+ /**
684
+ * Electron App Flicker Test - Reproduces camera flicker issue
685
+ * Stays flat (no auto-grow) to isolate the camera position jump on initial load
686
+ */
687
+ export const ElectronAppFlickerTest: Story = {
688
+ args: {
689
+ cityData: electronAppCityData as CityData,
690
+ height: '100vh',
691
+ heightScaling: 'linear',
692
+ linearScale: 0.5,
693
+ animation: {
694
+ startFlat: true,
695
+ autoStartDelay: null, // Don't auto-grow, stay flat
696
+ },
697
+ showControls: true,
698
+ },
699
+ };
700
+
683
701
  /**
684
702
  * This Repo - industry-themed-repository-composition-panels
685
703
  */