@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.
@@ -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
- // Calculate camera height to fit city in viewport (for top-down view)
669
+ // Helper to calculate flat camera height with known FOV (50) and aspect ratio
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 perspCam = camera;
673
- const fovRad = (perspCam.fov * Math.PI) / 180;
672
+ const calculateFlatCameraHeight = useCallback((aspect) => {
673
+ const fov = 50; // Known FOV that will be set on PerspectiveCamera
674
+ const fovRad = (fov * Math.PI) / 180;
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
- }, [camera, citySize]);
683
- // Compute target camera position
684
- // When flat, always use top-down view (ignore focusTarget)
685
- // When grown, use focusTarget if available, otherwise angled overview
686
- const targetPos = useMemo(() => {
687
- // Flat state: always top-down, ignore any focus
688
- // Height calculated mathematically to match 2D view zoom level
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: buildingAwareHeight,
720
- z: citySize * 1.3,
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
- }, [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
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
- 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
- }));
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 after initial setup, animate to new position
776
- // We track isFlat explicitly rather than targetPos to avoid spurious animations
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
- // Skip the first render - we handle that directly in useFrame
780
- if (!hasAppliedInitial.current)
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
- // Only animate if isFlat actually changed (flat <-> grown transition)
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
- // 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
- });
768
+ console.log('[useEffect] No isFlat change - skipping');
796
769
  return;
797
770
  }
798
- // isFlat changed - animate the transition
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: targetPos.x,
801
- camY: targetPos.y,
802
- camZ: targetPos.z,
803
- lookX: targetPos.targetX,
804
- lookY: targetPos.targetY,
805
- lookZ: targetPos.targetZ,
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
- }, [targetPos, api, isFlat]);
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
- // Skip first 2 frames to ensure OrbitControls is fully initialized
815
- if (frameCount.current < 3)
816
- return;
817
- if (!controlsRef.current)
818
- 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);
823
- controlsRef.current.update();
824
- // 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,
832
- });
833
- hasAppliedInitial.current = true;
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
- // Calculate spring duration for animation sync
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
- }, 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 }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principal-ai/file-city-react",
3
- "version": "0.5.33",
3
+ "version": "0.5.35",
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,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
- // Calculate camera height to fit city in viewport (for top-down view)
1160
+ // Helper to calculate flat camera height with known FOV (50) and aspect ratio
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 perspCam = camera as THREE.PerspectiveCamera;
1158
- const fovRad = (perspCam.fov * Math.PI) / 180;
1163
+ const calculateFlatCameraHeight = useCallback((aspect: number) => {
1164
+ const fov = 50; // Known FOV that will be set on PerspectiveCamera
1165
+ const fovRad = (fov * Math.PI) / 180;
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
- }, [camera, citySize]);
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
- // Grown state: use focusTarget if available
1187
- if (focusTarget) {
1188
- const distance = Math.max(focusTarget.size * 2, 50);
1189
- const height = Math.max(focusTarget.size * 1.5, 40);
1190
- return {
1191
- x: focusTarget.x,
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: buildingAwareHeight,
1208
- z: citySize * 1.3,
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
- }, [focusTarget, citySize, isFlat, maxBuildingHeight, calculateFlatCameraHeight]);
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
- 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
- }));
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 after initial setup, animate to new position
1282
- // We track isFlat explicitly rather than targetPos to avoid spurious animations
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
- // Skip the first render - we handle that directly in useFrame
1286
- if (!hasAppliedInitial.current) return;
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 actually changed (flat <-> grown transition)
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
- // 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
- });
1281
+ console.log('[useEffect] No isFlat change - skipping');
1303
1282
  return;
1304
1283
  }
1305
1284
 
1306
- // isFlat changed - animate the transition
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: targetPos.x,
1309
- camY: targetPos.y,
1310
- camZ: targetPos.z,
1311
- lookX: targetPos.targetX,
1312
- lookY: targetPos.targetY,
1313
- lookZ: targetPos.targetZ,
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
- }, [targetPos, api, isFlat]);
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
- // Skip first 2 frames to ensure OrbitControls is fully initialized
1325
- if (frameCount.current < 3) return;
1326
- if (!controlsRef.current) return;
1327
-
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);
1332
- controlsRef.current.update();
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
- hasAppliedInitial.current = true;
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
- }: CitySceneProps) {
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 citySize={citySize} isFlat={growProgress === 0} focusTarget={focusTarget} maxBuildingHeight={maxBuildingHeight} />
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
  */