@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.
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +208 -68
- package/package.json +1 -1
- package/src/components/FileCity3D/FileCity3D.tsx +250 -75
- package/src/stories/2D3DComparison.stories.tsx +93 -1
- package/src/stories/FileCity3D.stories.tsx +18 -0
|
@@ -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
|
-
|
|
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
|
-
//
|
|
727
|
-
// This
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
780
|
-
|
|
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,
|
|
787
|
-
//
|
|
788
|
-
|
|
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
|
-
|
|
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:
|
|
801
|
-
camY:
|
|
802
|
-
camZ:
|
|
803
|
-
lookX:
|
|
804
|
-
lookY:
|
|
805
|
-
lookZ:
|
|
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
|
-
}, [
|
|
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
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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:
|
|
1152
|
-
padding: '
|
|
1281
|
+
borderRadius: 8,
|
|
1282
|
+
padding: '10px',
|
|
1153
1283
|
color: '#e2e8f0',
|
|
1154
|
-
fontSize:
|
|
1284
|
+
fontSize: 14,
|
|
1155
1285
|
cursor: 'pointer',
|
|
1156
1286
|
display: 'flex',
|
|
1157
1287
|
alignItems: 'center',
|
|
1158
|
-
|
|
1288
|
+
justifyContent: 'center',
|
|
1289
|
+
width: 40,
|
|
1290
|
+
height: 40,
|
|
1291
|
+
fontWeight: 500,
|
|
1159
1292
|
};
|
|
1160
|
-
return (_jsxs("
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
-
|
|
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
|
@@ -1138,7 +1138,13 @@ export function getCameraTilt() {
|
|
|
1138
1138
|
return cameraApi?.getCurrentTilt() ?? null;
|
|
1139
1139
|
}
|
|
1140
1140
|
|
|
1141
|
-
const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
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
|
-
|
|
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
|
-
//
|
|
1216
|
-
// This
|
|
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
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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
|
-
|
|
1286
|
-
|
|
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,
|
|
1294
|
-
//
|
|
1295
|
-
|
|
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
|
-
|
|
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:
|
|
1309
|
-
camY:
|
|
1310
|
-
camZ:
|
|
1311
|
-
lookX:
|
|
1312
|
-
lookY:
|
|
1313
|
-
lookZ:
|
|
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
|
-
}, [
|
|
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
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
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:
|
|
1762
|
-
padding: '
|
|
1910
|
+
borderRadius: 8,
|
|
1911
|
+
padding: '10px',
|
|
1763
1912
|
color: '#e2e8f0',
|
|
1764
|
-
fontSize:
|
|
1913
|
+
fontSize: 14,
|
|
1765
1914
|
cursor: 'pointer',
|
|
1766
1915
|
display: 'flex',
|
|
1767
1916
|
alignItems: 'center',
|
|
1768
|
-
|
|
1917
|
+
justifyContent: 'center',
|
|
1918
|
+
width: 40,
|
|
1919
|
+
height: 40,
|
|
1920
|
+
fontWeight: 500,
|
|
1769
1921
|
};
|
|
1770
1922
|
|
|
1771
1923
|
return (
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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
|
-
|
|
1785
|
-
|
|
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
|
-
|
|
1951
|
+
</>
|
|
1788
1952
|
);
|
|
1789
1953
|
}
|
|
1790
1954
|
|
|
@@ -1823,7 +1987,8 @@ function CityScene({
|
|
|
1823
1987
|
focusDirectory,
|
|
1824
1988
|
focusColor,
|
|
1825
1989
|
adaptCameraToBuildings = false,
|
|
1826
|
-
|
|
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
|
|
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
|
|
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
|
*/
|