@principal-ai/file-city-react 0.5.33 → 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.
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +141 -120
- package/package.json +1 -1
- package/src/components/FileCity3D/FileCity3D.tsx +165 -122
- 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,87 +664,68 @@ 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
|
|
668
|
-
//
|
|
669
|
+
// Helper to calculate flat camera height with known FOV (50) and aspect ratio
|
|
669
670
|
// Formula: height = citySize / (2 * tan(fov/2) * min(1, aspect))
|
|
670
671
|
// Padding factor adds space around the city to match 2D component
|
|
671
|
-
const calculateFlatCameraHeight = useCallback(() => {
|
|
672
|
-
const
|
|
673
|
-
const fovRad = (
|
|
672
|
+
const calculateFlatCameraHeight = useCallback((aspect) => {
|
|
673
|
+
const fov = 50; // Known FOV that will be set on PerspectiveCamera
|
|
674
|
+
const fovRad = (fov * Math.PI) / 180;
|
|
674
675
|
const tanHalfFov = Math.tan(fovRad / 2);
|
|
675
|
-
const aspect = perspCam.aspect || 1;
|
|
676
676
|
// Use min(1, aspect) to handle both landscape and portrait viewports
|
|
677
677
|
const effectiveAspect = Math.min(1, aspect);
|
|
678
678
|
const baseHeight = citySize / (2 * tanHalfFov * effectiveAspect);
|
|
679
679
|
// Add padding to match 2D component's default padding
|
|
680
680
|
const paddingFactor = 1.08;
|
|
681
681
|
return baseHeight * paddingFactor;
|
|
682
|
-
}, [
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
if (isFlat) {
|
|
690
|
-
return {
|
|
691
|
-
x: 0,
|
|
692
|
-
y: calculateFlatCameraHeight(),
|
|
693
|
-
z: 0.001, // Near-zero for top-down (tiny offset to avoid gimbal lock)
|
|
694
|
-
targetX: 0,
|
|
695
|
-
targetY: 0,
|
|
696
|
-
targetZ: 0,
|
|
697
|
-
};
|
|
698
|
-
}
|
|
699
|
-
// Grown state: use focusTarget if available
|
|
700
|
-
if (focusTarget) {
|
|
701
|
-
const distance = Math.max(focusTarget.size * 2, 50);
|
|
702
|
-
const height = Math.max(focusTarget.size * 1.5, 40);
|
|
703
|
-
return {
|
|
704
|
-
x: focusTarget.x,
|
|
705
|
-
y: height,
|
|
706
|
-
z: focusTarget.z + distance,
|
|
707
|
-
targetX: focusTarget.x,
|
|
708
|
-
targetY: 0,
|
|
709
|
-
targetZ: focusTarget.z,
|
|
710
|
-
};
|
|
711
|
-
}
|
|
712
|
-
// Grown state without focus: angled overview
|
|
713
|
-
const baseHeight = citySize * 1.1;
|
|
714
|
-
const buildingAwareHeight = maxBuildingHeight > 0
|
|
715
|
-
? Math.max(baseHeight, maxBuildingHeight * 2.5)
|
|
716
|
-
: baseHeight;
|
|
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);
|
|
717
689
|
return {
|
|
718
690
|
x: 0,
|
|
719
|
-
y:
|
|
720
|
-
z:
|
|
691
|
+
y: height,
|
|
692
|
+
z: 0.001, // Near-zero for top-down (tiny offset to avoid gimbal lock)
|
|
721
693
|
targetX: 0,
|
|
722
694
|
targetY: 0,
|
|
723
695
|
targetZ: 0,
|
|
724
696
|
};
|
|
725
|
-
}, [
|
|
726
|
-
// Capture initial camera position on first render only
|
|
727
|
-
// This prevents the PerspectiveCamera position prop from causing jumps when targetPos changes
|
|
728
|
-
const initialPosRef = useRef(null);
|
|
729
|
-
if (!initialPosRef.current) {
|
|
730
|
-
initialPosRef.current = targetPos;
|
|
731
|
-
}
|
|
697
|
+
}, [camera, calculateFlatCameraHeight]);
|
|
732
698
|
// Spring animation for camera movement
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
699
|
+
// Initialize with correct 2D position from the start
|
|
700
|
+
const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => {
|
|
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);
|
|
705
|
+
return {
|
|
706
|
+
camX: 0,
|
|
707
|
+
camY: initialHeight,
|
|
708
|
+
camZ: 0.001,
|
|
709
|
+
lookX: 0,
|
|
710
|
+
lookY: 0,
|
|
711
|
+
lookZ: 0,
|
|
712
|
+
config: { tension: 60, friction: 20 },
|
|
713
|
+
onStart: () => {
|
|
714
|
+
// Only allow animations after initial setup is complete
|
|
715
|
+
if (hasAppliedInitial.current) {
|
|
716
|
+
console.log('[Spring onStart] Animation starting - camY:', camY.get());
|
|
717
|
+
isAnimatingRef.current = true;
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
console.log('[Spring onStart] Blocked - initialization not complete');
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
onRest: () => {
|
|
724
|
+
console.log('[Spring onRest] Animation finished');
|
|
725
|
+
isAnimatingRef.current = false;
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
});
|
|
748
729
|
// Separate spring for orbit angle animation (animates along horizontal arc)
|
|
749
730
|
const [{ orbitAngle }, orbitApi] = useSpring(() => ({
|
|
750
731
|
orbitAngle: 0,
|
|
@@ -772,67 +753,97 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
|
|
|
772
753
|
const orbitParamsRef = useRef(null);
|
|
773
754
|
// Track tilt parameters during vertical rotation
|
|
774
755
|
const tiltParamsRef = useRef(null);
|
|
775
|
-
// When isFlat changes
|
|
776
|
-
//
|
|
777
|
-
// 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
|
|
778
758
|
useEffect(() => {
|
|
779
|
-
|
|
780
|
-
|
|
759
|
+
console.log('[useEffect] isFlat:', isFlat, 'prevIsFlat:', prevIsFlatRef.current, 'hasAppliedInitial:', hasAppliedInitial.current);
|
|
760
|
+
// Skip until camera is initialized
|
|
761
|
+
if (!hasAppliedInitial.current) {
|
|
762
|
+
console.log('[useEffect] Skipping - not initialized yet');
|
|
781
763
|
return;
|
|
782
|
-
|
|
764
|
+
}
|
|
765
|
+
// Only animate if isFlat changed from true to false (2D → 3D transition)
|
|
783
766
|
const isFlatChanged = prevIsFlatRef.current !== isFlat;
|
|
784
|
-
prevIsFlatRef.current = isFlat;
|
|
785
767
|
if (!isFlatChanged) {
|
|
786
|
-
|
|
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
|
-
});
|
|
768
|
+
console.log('[useEffect] No isFlat change - skipping');
|
|
796
769
|
return;
|
|
797
770
|
}
|
|
798
|
-
|
|
771
|
+
console.log('[useEffect] isFlat changed from', prevIsFlatRef.current, 'to', isFlat, '- animating transition');
|
|
772
|
+
prevIsFlatRef.current = isFlat;
|
|
773
|
+
// Calculate target position for 3D view
|
|
774
|
+
const newPos = isFlat
|
|
775
|
+
? getInitial2DPosition() // Going back to 2D
|
|
776
|
+
: focusTarget
|
|
777
|
+
? {
|
|
778
|
+
x: focusTarget.x,
|
|
779
|
+
y: Math.max(focusTarget.size * 1.5, 40),
|
|
780
|
+
z: focusTarget.z + Math.max(focusTarget.size * 2, 50),
|
|
781
|
+
targetX: focusTarget.x,
|
|
782
|
+
targetY: 0,
|
|
783
|
+
targetZ: focusTarget.z,
|
|
784
|
+
}
|
|
785
|
+
: {
|
|
786
|
+
x: 0,
|
|
787
|
+
y: maxBuildingHeight > 0 ? Math.max(citySize * 1.1, maxBuildingHeight * 2.5) : citySize * 1.1,
|
|
788
|
+
z: citySize * 1.3,
|
|
789
|
+
targetX: 0,
|
|
790
|
+
targetY: 0,
|
|
791
|
+
targetZ: 0,
|
|
792
|
+
};
|
|
793
|
+
console.log('[useEffect] Animating to:', newPos);
|
|
799
794
|
api.start({
|
|
800
|
-
camX:
|
|
801
|
-
camY:
|
|
802
|
-
camZ:
|
|
803
|
-
lookX:
|
|
804
|
-
lookY:
|
|
805
|
-
lookZ:
|
|
806
|
-
onRest: () => {
|
|
807
|
-
isAnimatingRef.current = false;
|
|
808
|
-
},
|
|
795
|
+
camX: newPos.x,
|
|
796
|
+
camY: newPos.y,
|
|
797
|
+
camZ: newPos.z,
|
|
798
|
+
lookX: newPos.targetX,
|
|
799
|
+
lookY: newPos.targetY,
|
|
800
|
+
lookZ: newPos.targetZ,
|
|
809
801
|
});
|
|
810
|
-
|
|
802
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
803
|
+
}, [isFlat]); // Only animate when isFlat changes, not when focusTarget/citySize/etc change
|
|
811
804
|
// Update camera each frame
|
|
812
805
|
useFrame(() => {
|
|
813
806
|
frameCount.current++;
|
|
814
|
-
//
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
|
809
|
+
if (frameCount.current === 1) {
|
|
810
|
+
// Ensure camera FOV is correct (defaults to 75 before prop applies)
|
|
811
|
+
const perspCam = camera;
|
|
812
|
+
if (perspCam.fov !== 50) {
|
|
813
|
+
console.log('[Frame 1] Correcting FOV from', perspCam.fov, 'to 50');
|
|
814
|
+
perspCam.fov = 50;
|
|
815
|
+
perspCam.updateProjectionMatrix();
|
|
816
|
+
}
|
|
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
|
+
}
|
|
841
|
+
}
|
|
834
842
|
return;
|
|
835
843
|
}
|
|
844
|
+
// Wait for controls and initialization to complete
|
|
845
|
+
if (!controlsRef.current || !hasAppliedInitial.current)
|
|
846
|
+
return;
|
|
836
847
|
// Handle orbit animation (horizontal rotation along arc)
|
|
837
848
|
if (isOrbitingRef.current && orbitParamsRef.current) {
|
|
838
849
|
const { centerX, centerZ, distance, height } = orbitParamsRef.current;
|
|
@@ -882,6 +893,11 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
|
|
|
882
893
|
}
|
|
883
894
|
// Handle position animation
|
|
884
895
|
else if (isAnimatingRef.current) {
|
|
896
|
+
const springY = camY.get();
|
|
897
|
+
const currentY = camera.position.y;
|
|
898
|
+
if (Math.abs(springY - currentY) > 1) {
|
|
899
|
+
console.log('[useFrame] Spring animating - springY:', springY, 'currentY:', currentY);
|
|
900
|
+
}
|
|
885
901
|
camera.position.set(camX.get(), camY.get(), camZ.get());
|
|
886
902
|
controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
|
|
887
903
|
controlsRef.current.update();
|
|
@@ -1117,6 +1133,12 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
|
|
|
1117
1133
|
};
|
|
1118
1134
|
}, [resetToInitial, moveTo, setTarget, rotateTo, rotateBy, tiltTo, tiltBy, getCurrentPosition, getCurrentTarget, getCurrentAngle, getCurrentTilt]);
|
|
1119
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);
|
|
1120
1142
|
});
|
|
1121
1143
|
function InfoPanel({ building }) {
|
|
1122
1144
|
if (!building)
|
|
@@ -1172,7 +1194,7 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera }) {
|
|
|
1172
1194
|
right: 8,
|
|
1173
1195
|
}, title: "Reset View", children: "\u21BB" })] }));
|
|
1174
1196
|
}
|
|
1175
|
-
function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, flatPatterns, focusDirectory, focusColor, adaptCameraToBuildings = false, }) {
|
|
1197
|
+
function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, flatPatterns, focusDirectory, focusColor, adaptCameraToBuildings = false, onCameraReady, }) {
|
|
1176
1198
|
const centerOffset = useMemo(() => ({
|
|
1177
1199
|
x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
|
|
1178
1200
|
z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
|
|
@@ -1336,11 +1358,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
1336
1358
|
return null;
|
|
1337
1359
|
return cityData.buildings.findIndex(b => b.path === selectedBuilding.path);
|
|
1338
1360
|
}, [selectedBuilding, cityData.buildings]);
|
|
1339
|
-
|
|
1340
|
-
const tension = animationConfig.tension || 120;
|
|
1341
|
-
const friction = animationConfig.friction || 14;
|
|
1342
|
-
const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
|
|
1343
|
-
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 => {
|
|
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 => {
|
|
1344
1362
|
// Check if district matches focusDirectory
|
|
1345
1363
|
const isFocused = buildingFocusDirectory
|
|
1346
1364
|
? district.path === buildingFocusDirectory
|
|
@@ -1374,6 +1392,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
1374
1392
|
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, }) {
|
|
1375
1393
|
const [hoveredBuilding, setHoveredBuilding] = useState(null);
|
|
1376
1394
|
const [internalIsGrown, setInternalIsGrown] = useState(false);
|
|
1395
|
+
const [cameraReady, setCameraReady] = useState(false);
|
|
1377
1396
|
const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
|
|
1378
1397
|
// ============================================================================
|
|
1379
1398
|
// Visualization Resolution
|
|
@@ -1475,6 +1494,8 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
|
|
|
1475
1494
|
left: 0,
|
|
1476
1495
|
width: '100%',
|
|
1477
1496
|
height: '100%',
|
|
1478
|
-
|
|
1497
|
+
opacity: cameraReady ? 1 : 0,
|
|
1498
|
+
transition: 'opacity 0.1s ease-in',
|
|
1499
|
+
}, 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 }))] }));
|
|
1479
1500
|
}
|
|
1480
1501
|
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,93 +1154,72 @@ 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
|
|
1152
1159
|
|
|
1153
|
-
//
|
|
1160
|
+
// Helper to calculate flat camera height with known FOV (50) and aspect ratio
|
|
1154
1161
|
// Formula: height = citySize / (2 * tan(fov/2) * min(1, aspect))
|
|
1155
1162
|
// Padding factor adds space around the city to match 2D component
|
|
1156
|
-
const calculateFlatCameraHeight = useCallback(() => {
|
|
1157
|
-
const
|
|
1158
|
-
const fovRad = (
|
|
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;
|
|
1159
1166
|
const tanHalfFov = Math.tan(fovRad / 2);
|
|
1160
|
-
const aspect = perspCam.aspect || 1;
|
|
1161
1167
|
// Use min(1, aspect) to handle both landscape and portrait viewports
|
|
1162
1168
|
const effectiveAspect = Math.min(1, aspect);
|
|
1163
1169
|
const baseHeight = citySize / (2 * tanHalfFov * effectiveAspect);
|
|
1164
1170
|
// Add padding to match 2D component's default padding
|
|
1165
1171
|
const paddingFactor = 1.08;
|
|
1166
1172
|
return baseHeight * paddingFactor;
|
|
1167
|
-
}, [
|
|
1168
|
-
|
|
1169
|
-
// Compute target camera position
|
|
1170
|
-
// When flat, always use top-down view (ignore focusTarget)
|
|
1171
|
-
// When grown, use focusTarget if available, otherwise angled overview
|
|
1172
|
-
const targetPos = useMemo(() => {
|
|
1173
|
-
// Flat state: always top-down, ignore any focus
|
|
1174
|
-
// Height calculated mathematically to match 2D view zoom level
|
|
1175
|
-
if (isFlat) {
|
|
1176
|
-
return {
|
|
1177
|
-
x: 0,
|
|
1178
|
-
y: calculateFlatCameraHeight(),
|
|
1179
|
-
z: 0.001, // Near-zero for top-down (tiny offset to avoid gimbal lock)
|
|
1180
|
-
targetX: 0,
|
|
1181
|
-
targetY: 0,
|
|
1182
|
-
targetZ: 0,
|
|
1183
|
-
};
|
|
1184
|
-
}
|
|
1173
|
+
}, [citySize]);
|
|
1185
1174
|
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
y: height,
|
|
1193
|
-
z: focusTarget.z + distance,
|
|
1194
|
-
targetX: focusTarget.x,
|
|
1195
|
-
targetY: 0,
|
|
1196
|
-
targetZ: focusTarget.z,
|
|
1197
|
-
};
|
|
1198
|
-
}
|
|
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);
|
|
1199
1181
|
|
|
1200
|
-
// Grown state without focus: angled overview
|
|
1201
|
-
const baseHeight = citySize * 1.1;
|
|
1202
|
-
const buildingAwareHeight = maxBuildingHeight > 0
|
|
1203
|
-
? Math.max(baseHeight, maxBuildingHeight * 2.5)
|
|
1204
|
-
: baseHeight;
|
|
1205
1182
|
return {
|
|
1206
1183
|
x: 0,
|
|
1207
|
-
y:
|
|
1208
|
-
z:
|
|
1184
|
+
y: height,
|
|
1185
|
+
z: 0.001, // Near-zero for top-down (tiny offset to avoid gimbal lock)
|
|
1209
1186
|
targetX: 0,
|
|
1210
1187
|
targetY: 0,
|
|
1211
1188
|
targetZ: 0,
|
|
1212
1189
|
};
|
|
1213
|
-
}, [
|
|
1214
|
-
|
|
1215
|
-
// Capture initial camera position on first render only
|
|
1216
|
-
// This prevents the PerspectiveCamera position prop from causing jumps when targetPos changes
|
|
1217
|
-
const initialPosRef = useRef<typeof targetPos | null>(null);
|
|
1218
|
-
if (!initialPosRef.current) {
|
|
1219
|
-
initialPosRef.current = targetPos;
|
|
1220
|
-
}
|
|
1190
|
+
}, [camera, calculateFlatCameraHeight]);
|
|
1221
1191
|
|
|
1222
1192
|
// Spring animation for camera movement
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1193
|
+
// Initialize with correct 2D position from the start
|
|
1194
|
+
const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => {
|
|
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);
|
|
1200
|
+
return {
|
|
1201
|
+
camX: 0,
|
|
1202
|
+
camY: initialHeight,
|
|
1203
|
+
camZ: 0.001,
|
|
1204
|
+
lookX: 0,
|
|
1205
|
+
lookY: 0,
|
|
1206
|
+
lookZ: 0,
|
|
1207
|
+
config: { tension: 60, friction: 20 },
|
|
1208
|
+
onStart: () => {
|
|
1209
|
+
// Only allow animations after initial setup is complete
|
|
1210
|
+
if (hasAppliedInitial.current) {
|
|
1211
|
+
console.log('[Spring onStart] Animation starting - camY:', camY.get());
|
|
1212
|
+
isAnimatingRef.current = true;
|
|
1213
|
+
} else {
|
|
1214
|
+
console.log('[Spring onStart] Blocked - initialization not complete');
|
|
1215
|
+
}
|
|
1216
|
+
},
|
|
1217
|
+
onRest: () => {
|
|
1218
|
+
console.log('[Spring onRest] Animation finished');
|
|
1219
|
+
isAnimatingRef.current = false;
|
|
1220
|
+
},
|
|
1221
|
+
};
|
|
1222
|
+
});
|
|
1238
1223
|
|
|
1239
1224
|
// Separate spring for orbit angle animation (animates along horizontal arc)
|
|
1240
1225
|
const [{ orbitAngle }, orbitApi] = useSpring(() => ({
|
|
@@ -1278,73 +1263,112 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
|
|
|
1278
1263
|
azimuthAngle: number; // horizontal angle to maintain
|
|
1279
1264
|
} | null>(null);
|
|
1280
1265
|
|
|
1281
|
-
// When isFlat changes
|
|
1282
|
-
//
|
|
1283
|
-
// 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
|
|
1284
1268
|
useEffect(() => {
|
|
1285
|
-
|
|
1286
|
-
|
|
1269
|
+
console.log('[useEffect] isFlat:', isFlat, 'prevIsFlat:', prevIsFlatRef.current, 'hasAppliedInitial:', hasAppliedInitial.current);
|
|
1270
|
+
|
|
1271
|
+
// Skip until camera is initialized
|
|
1272
|
+
if (!hasAppliedInitial.current) {
|
|
1273
|
+
console.log('[useEffect] Skipping - not initialized yet');
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1287
1276
|
|
|
1288
|
-
// Only animate if isFlat
|
|
1277
|
+
// Only animate if isFlat changed from true to false (2D → 3D transition)
|
|
1289
1278
|
const isFlatChanged = prevIsFlatRef.current !== isFlat;
|
|
1290
|
-
prevIsFlatRef.current = isFlat;
|
|
1291
1279
|
|
|
1292
1280
|
if (!isFlatChanged) {
|
|
1293
|
-
|
|
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
|
-
});
|
|
1281
|
+
console.log('[useEffect] No isFlat change - skipping');
|
|
1303
1282
|
return;
|
|
1304
1283
|
}
|
|
1305
1284
|
|
|
1306
|
-
|
|
1285
|
+
console.log('[useEffect] isFlat changed from', prevIsFlatRef.current, 'to', isFlat, '- animating transition');
|
|
1286
|
+
prevIsFlatRef.current = isFlat;
|
|
1287
|
+
|
|
1288
|
+
// Calculate target position for 3D view
|
|
1289
|
+
const newPos = isFlat
|
|
1290
|
+
? getInitial2DPosition() // Going back to 2D
|
|
1291
|
+
: focusTarget
|
|
1292
|
+
? {
|
|
1293
|
+
x: focusTarget.x,
|
|
1294
|
+
y: Math.max(focusTarget.size * 1.5, 40),
|
|
1295
|
+
z: focusTarget.z + Math.max(focusTarget.size * 2, 50),
|
|
1296
|
+
targetX: focusTarget.x,
|
|
1297
|
+
targetY: 0,
|
|
1298
|
+
targetZ: focusTarget.z,
|
|
1299
|
+
}
|
|
1300
|
+
: {
|
|
1301
|
+
x: 0,
|
|
1302
|
+
y: maxBuildingHeight > 0 ? Math.max(citySize * 1.1, maxBuildingHeight * 2.5) : citySize * 1.1,
|
|
1303
|
+
z: citySize * 1.3,
|
|
1304
|
+
targetX: 0,
|
|
1305
|
+
targetY: 0,
|
|
1306
|
+
targetZ: 0,
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
console.log('[useEffect] Animating to:', newPos);
|
|
1307
1310
|
api.start({
|
|
1308
|
-
camX:
|
|
1309
|
-
camY:
|
|
1310
|
-
camZ:
|
|
1311
|
-
lookX:
|
|
1312
|
-
lookY:
|
|
1313
|
-
lookZ:
|
|
1314
|
-
onRest: () => {
|
|
1315
|
-
isAnimatingRef.current = false;
|
|
1316
|
-
},
|
|
1311
|
+
camX: newPos.x,
|
|
1312
|
+
camY: newPos.y,
|
|
1313
|
+
camZ: newPos.z,
|
|
1314
|
+
lookX: newPos.targetX,
|
|
1315
|
+
lookY: newPos.targetY,
|
|
1316
|
+
lookZ: newPos.targetZ,
|
|
1317
1317
|
});
|
|
1318
|
-
|
|
1318
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1319
|
+
}, [isFlat]); // Only animate when isFlat changes, not when focusTarget/citySize/etc change
|
|
1319
1320
|
|
|
1320
1321
|
// Update camera each frame
|
|
1321
1322
|
useFrame(() => {
|
|
1322
1323
|
frameCount.current++;
|
|
1323
1324
|
|
|
1324
|
-
//
|
|
1325
|
-
|
|
1326
|
-
if (
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
// 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,
|
|
1342
|
-
});
|
|
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
|
|
1327
|
+
if (frameCount.current === 1) {
|
|
1328
|
+
// Ensure camera FOV is correct (defaults to 75 before prop applies)
|
|
1329
|
+
const perspCam = camera as THREE.PerspectiveCamera;
|
|
1330
|
+
if (perspCam.fov !== 50) {
|
|
1331
|
+
console.log('[Frame 1] Correcting FOV from', perspCam.fov, 'to 50');
|
|
1332
|
+
perspCam.fov = 50;
|
|
1333
|
+
perspCam.updateProjectionMatrix();
|
|
1334
|
+
}
|
|
1343
1335
|
|
|
1344
|
-
|
|
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
|
+
}
|
|
1365
|
+
}
|
|
1345
1366
|
return;
|
|
1346
1367
|
}
|
|
1347
1368
|
|
|
1369
|
+
// Wait for controls and initialization to complete
|
|
1370
|
+
if (!controlsRef.current || !hasAppliedInitial.current) return;
|
|
1371
|
+
|
|
1348
1372
|
// Handle orbit animation (horizontal rotation along arc)
|
|
1349
1373
|
if (isOrbitingRef.current && orbitParamsRef.current) {
|
|
1350
1374
|
const { centerX, centerZ, distance, height } = orbitParamsRef.current;
|
|
@@ -1401,6 +1425,11 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
|
|
|
1401
1425
|
}
|
|
1402
1426
|
// Handle position animation
|
|
1403
1427
|
else if (isAnimatingRef.current) {
|
|
1428
|
+
const springY = camY.get();
|
|
1429
|
+
const currentY = camera.position.y;
|
|
1430
|
+
if (Math.abs(springY - currentY) > 1) {
|
|
1431
|
+
console.log('[useFrame] Spring animating - springY:', springY, 'currentY:', currentY);
|
|
1432
|
+
}
|
|
1404
1433
|
camera.position.set(camX.get(), camY.get(), camZ.get());
|
|
1405
1434
|
controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
|
|
1406
1435
|
controlsRef.current.update();
|
|
@@ -1697,6 +1726,14 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
|
|
|
1697
1726
|
/>
|
|
1698
1727
|
</>
|
|
1699
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
|
+
);
|
|
1700
1737
|
});
|
|
1701
1738
|
|
|
1702
1739
|
// Info panel overlay
|
|
@@ -1838,7 +1875,8 @@ function CityScene({
|
|
|
1838
1875
|
focusDirectory,
|
|
1839
1876
|
focusColor,
|
|
1840
1877
|
adaptCameraToBuildings = false,
|
|
1841
|
-
|
|
1878
|
+
onCameraReady,
|
|
1879
|
+
}: CitySceneProps & { onCameraReady?: () => void }) {
|
|
1842
1880
|
const centerOffset = useMemo(
|
|
1843
1881
|
() => ({
|
|
1844
1882
|
x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
|
|
@@ -2049,14 +2087,15 @@ function CityScene({
|
|
|
2049
2087
|
return cityData.buildings.findIndex(b => b.path === selectedBuilding.path);
|
|
2050
2088
|
}, [selectedBuilding, cityData.buildings]);
|
|
2051
2089
|
|
|
2052
|
-
// Calculate spring duration for animation sync
|
|
2053
|
-
const tension = animationConfig.tension || 120;
|
|
2054
|
-
const friction = animationConfig.friction || 14;
|
|
2055
|
-
const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
|
|
2056
|
-
|
|
2057
2090
|
return (
|
|
2058
2091
|
<>
|
|
2059
|
-
<AnimatedCamera
|
|
2092
|
+
<AnimatedCamera
|
|
2093
|
+
citySize={citySize}
|
|
2094
|
+
isFlat={growProgress === 0}
|
|
2095
|
+
focusTarget={focusTarget}
|
|
2096
|
+
maxBuildingHeight={maxBuildingHeight}
|
|
2097
|
+
onCameraReady={onCameraReady}
|
|
2098
|
+
/>
|
|
2060
2099
|
|
|
2061
2100
|
<ambientLight intensity={1.2} />
|
|
2062
2101
|
<hemisphereLight args={['#ddeeff', '#667788', 0.8]} position={[0, citySize, 0]} />
|
|
@@ -2239,6 +2278,7 @@ export function FileCity3D({
|
|
|
2239
2278
|
}: FileCity3DProps) {
|
|
2240
2279
|
const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(null);
|
|
2241
2280
|
const [internalIsGrown, setInternalIsGrown] = useState(false);
|
|
2281
|
+
const [cameraReady, setCameraReady] = useState(false);
|
|
2242
2282
|
|
|
2243
2283
|
const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
|
|
2244
2284
|
|
|
@@ -2371,6 +2411,8 @@ export function FileCity3D({
|
|
|
2371
2411
|
left: 0,
|
|
2372
2412
|
width: '100%',
|
|
2373
2413
|
height: '100%',
|
|
2414
|
+
opacity: cameraReady ? 1 : 0,
|
|
2415
|
+
transition: 'opacity 0.1s ease-in',
|
|
2374
2416
|
}}
|
|
2375
2417
|
>
|
|
2376
2418
|
<CityScene
|
|
@@ -2389,6 +2431,7 @@ export function FileCity3D({
|
|
|
2389
2431
|
focusDirectory={focusDirectory}
|
|
2390
2432
|
focusColor={focusColor}
|
|
2391
2433
|
adaptCameraToBuildings={adaptCameraToBuildings}
|
|
2434
|
+
onCameraReady={() => setCameraReady(true)}
|
|
2392
2435
|
/>
|
|
2393
2436
|
</Canvas>
|
|
2394
2437
|
<InfoPanel building={selectedBuilding} />
|
|
@@ -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
|
*/
|