@principal-ai/file-city-react 0.5.10 → 0.5.11

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.
@@ -100,6 +100,8 @@ export interface FileCity3DProps {
100
100
  backgroundColor?: string;
101
101
  /** Text color for secondary/placeholder text */
102
102
  textColor?: string;
103
+ /** Currently selected building (controlled by host) */
104
+ selectedBuilding?: CityBuilding | null;
103
105
  }
104
106
  /**
105
107
  * FileCity3D - 3D visualization of codebase structure
@@ -107,6 +109,6 @@ export interface FileCity3DProps {
107
109
  * Renders CityData as an interactive 3D city where buildings represent files
108
110
  * and their height corresponds to line count or file size.
109
111
  */
110
- export declare function FileCity3D({ cityData, width, height, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls, highlightLayers, isolationMode, dimOpacity, isLoading, loadingMessage, emptyMessage, heightScaling, linearScale, focusDirectory, onDirectorySelect, backgroundColor, textColor, }: FileCity3DProps): import("react/jsx-runtime").JSX.Element;
112
+ export declare function FileCity3D({ cityData, width, height, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls, highlightLayers, isolationMode, dimOpacity, isLoading, loadingMessage, emptyMessage, heightScaling, linearScale, focusDirectory, onDirectorySelect, backgroundColor, textColor, selectedBuilding, }: FileCity3DProps): import("react/jsx-runtime").JSX.Element;
111
113
  export default FileCity3D;
112
114
  //# sourceMappingURL=FileCity3D.d.ts.map
@@ -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":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAA4D,MAAM,OAAO,CAAC;AAMjF,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAEb,MAAM,iCAAiC,CAAC;AAEzC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAGxD,OAAO,QAAQ,OAAO,CAAC;IAErB,UAAU,GAAG,CAAC;QAEZ,UAAU,iBAAkB,SAAQ,aAAa;SAAG;KACrD;CACF;AAGD,YAAY,EAAE,QAAQ,EAAE,YAAY,EAAE,YAAY,EAAE,CAAC;AAGrD,MAAM,WAAW,cAAc;IAC7B,wBAAwB;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,8BAA8B;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,4BAA4B;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,yBAAyB;IACzB,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,6BAA6B;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,mBAAmB;IACnB,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;CAC5B;AAED,gDAAgD;AAChD,MAAM,MAAM,aAAa,GACrB,MAAM,GACN,aAAa,GACb,UAAU,GACV,MAAM,CAAC;AAGX,MAAM,WAAW,eAAe;IAC9B,0CAA0C;IAC1C,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,mFAAmF;IACnF,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,2CAA2C;IAC3C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gDAAgD;IAChD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,wCAAwC;AACxC,MAAM,MAAM,aAAa,GAAG,aAAa,GAAG,QAAQ,CAAC;AA24BrD,wBAAgB,WAAW,SAE1B;AA+eD,MAAM,WAAW,eAAe;IAC9B,uCAAuC;IACvC,QAAQ,EAAE,QAAQ,CAAC;IACnB,6BAA6B;IAC7B,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,8BAA8B;IAC9B,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,0CAA0C;IAC1C,eAAe,CAAC,EAAE,CAAC,QAAQ,EAAE,YAAY,KAAK,IAAI,CAAC;IACnD,qBAAqB;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oBAAoB;IACpB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAC5B,8BAA8B;IAC9B,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,wEAAwE;IACxE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,uCAAuC;IACvC,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IAC1C,2BAA2B;IAC3B,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,kEAAkE;IAClE,eAAe,CAAC,EAAE,cAAc,EAAE,CAAC;IACnC,yEAAyE;IACzE,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,6DAA6D;IAC7D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wCAAwC;IACxC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,uCAAuC;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,8CAA8C;IAC9C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,+DAA+D;IAC/D,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,mEAAmE;IACnE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mEAAmE;IACnE,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,0DAA0D;IAC1D,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACvD,gDAAgD;IAChD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,gBAAgB,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;CACxC;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,KAAc,EACd,MAAY,EACZ,eAAe,EACf,SAAS,EACT,KAAK,EACL,SAAS,EACT,OAAO,EAAE,eAAe,EACxB,YAAY,EACZ,YAAmB,EACnB,eAAoB,EACpB,aAA6B,EAC7B,UAAiB,EACjB,SAAiB,EACjB,cAAuC,EACvC,YAA4C,EAC5C,aAA6B,EAC7B,WAAkB,EAClB,cAAqB,EACrB,iBAAiB,EACjB,eAA2B,EAC3B,SAAqB,EACrB,gBAAuB,GACxB,EAAE,eAAe,2CAyHjB;AAED,eAAe,UAAU,CAAC"}
@@ -259,7 +259,7 @@ function isPathInDirectory(path, directory) {
259
259
  return true;
260
260
  return path === directory || path.startsWith(directory + '/');
261
261
  }
262
- function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hoveredIndex, growProgress, animationConfig, heightScaling, linearScale, staggerIndices, focusDirectory, highlightLayers, isolationMode, }) {
262
+ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hoveredIndex, selectedIndex, growProgress, animationConfig, heightScaling, linearScale, staggerIndices, focusDirectory, highlightLayers, isolationMode, }) {
263
263
  const meshRef = useRef(null);
264
264
  const startTimeRef = useRef(null);
265
265
  const tempObject = useMemo(() => new THREE.Object3D(), []);
@@ -395,7 +395,8 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
395
395
  const height = animProgress * fullHeight * newMultiplier + minHeight;
396
396
  const yPosition = height / 2 + baseOffset;
397
397
  const isHovered = hoveredIndex === data.index;
398
- const scale = isHovered ? 1.05 : 1;
398
+ const isSelected = selectedIndex === data.index;
399
+ const scale = isSelected ? 1.08 : isHovered ? 1.05 : 1;
399
400
  tempObject.position.set(x, yPosition, z);
400
401
  tempObject.scale.set(width * scale, height, depth * scale);
401
402
  tempObject.updateMatrix();
@@ -410,7 +411,10 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
410
411
  tempColor.g = tempColor.g * (1 - grayAmount) + gray * grayAmount;
411
412
  tempColor.b = tempColor.b * (1 - grayAmount) + gray * grayAmount;
412
413
  }
413
- if (isHovered) {
414
+ if (isSelected) {
415
+ tempColor.multiplyScalar(1.4);
416
+ }
417
+ else if (isHovered) {
414
418
  tempColor.multiplyScalar(1.2);
415
419
  }
416
420
  meshRef.current.setColorAt(instanceIndex, tempColor);
@@ -419,6 +423,8 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
419
423
  if (meshRef.current.instanceColor) {
420
424
  meshRef.current.instanceColor.needsUpdate = true;
421
425
  }
426
+ // Update bounding sphere for raycasting as buildings grow/animate
427
+ meshRef.current.computeBoundingSphere();
422
428
  });
423
429
  const handlePointerMove = useCallback((e) => {
424
430
  e.stopPropagation();
@@ -482,7 +488,7 @@ function AnimatedIcon({ x, z, targetHeight, iconSize, texture, opacity, growProg
482
488
  materialRef.current.opacity = opacity * animProgress;
483
489
  }
484
490
  });
485
- return (_jsx("sprite", { ref: spriteRef, position: [x, 0, z], scale: [iconSize, iconSize, 1], children: _jsx("spriteMaterial", { ref: materialRef, map: texture, transparent: true, opacity: 0, depthTest: true, depthWrite: false }) }));
491
+ return (_jsx("sprite", { ref: spriteRef, position: [x, 0, z], scale: [iconSize, iconSize, 1], raycast: () => null, children: _jsx("spriteMaterial", { ref: materialRef, map: texture, transparent: true, opacity: 0, depthTest: true, depthWrite: false }) }));
486
492
  }
487
493
  function BuildingIcons({ buildings, centerOffset, growProgress, heightScaling, linearScale, highlightLayers, isolationMode, hasActiveHighlights, staggerIndices, springDuration, staggerDelay, }) {
488
494
  // Pre-compute buildings with icons
@@ -563,6 +569,7 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
563
569
  const { camera } = useThree();
564
570
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
565
571
  const controlsRef = useRef(null);
572
+ const isAnimatingRef = useRef(true);
566
573
  // Animated camera position and target
567
574
  const targetPos = useMemo(() => {
568
575
  if (focusTarget) {
@@ -599,10 +606,17 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
599
606
  lookY: targetPos.targetY,
600
607
  lookZ: targetPos.targetZ,
601
608
  config: { tension: 60, friction: 20 },
609
+ onStart: () => {
610
+ isAnimatingRef.current = true;
611
+ },
612
+ onRest: () => {
613
+ isAnimatingRef.current = false;
614
+ },
602
615
  });
603
- // Update camera each frame based on spring values
616
+ // Update camera each frame only while spring is animating
617
+ // Once animation settles, let OrbitControls handle user interaction
604
618
  useFrame(() => {
605
- if (!controlsRef.current)
619
+ if (!controlsRef.current || !isAnimatingRef.current)
606
620
  return;
607
621
  camera.position.set(camX.get(), camY.get(), camZ.get());
608
622
  controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
@@ -681,7 +695,7 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera }) {
681
695
  gap: 8,
682
696
  }, children: [_jsx("button", { onClick: onResetCamera, style: buttonStyle, children: "Reset View" }), _jsx("button", { onClick: onToggle, style: buttonStyle, children: isFlat ? 'Grow to 3D' : 'Flatten to 2D' })] }));
683
697
  }
684
- function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, }) {
698
+ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, }) {
685
699
  const centerOffset = useMemo(() => ({
686
700
  x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
687
701
  z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
@@ -835,11 +849,16 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
835
849
  return null;
836
850
  return cityData.buildings.findIndex(b => b.path === hoveredBuilding.path);
837
851
  }, [hoveredBuilding, cityData.buildings]);
852
+ const selectedIndex = useMemo(() => {
853
+ if (!selectedBuilding)
854
+ return null;
855
+ return cityData.buildings.findIndex(b => b.path === selectedBuilding.path);
856
+ }, [selectedBuilding, cityData.buildings]);
838
857
  // Calculate spring duration for animation sync
839
858
  const tension = animationConfig.tension || 120;
840
859
  const friction = animationConfig.friction || 14;
841
860
  const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
842
- return (_jsxs(_Fragment, { children: [_jsx(AnimatedCamera, { citySize: citySize, isFlat: growProgress === 0, focusTarget: focusTarget }), _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 => (_jsx(DistrictFloor, { district: district, centerOffset: centerOffset, opacity: 1 }, district.path))), _jsx(InstancedBuildings, { buildings: cityData.buildings, centerOffset: centerOffset, onHover: onBuildingHover, onClick: onBuildingClick, hoveredIndex: hoveredIndex, growProgress: growProgress, animationConfig: animationConfig, heightScaling: heightScaling, linearScale: linearScale, staggerIndices: staggerIndices, focusDirectory: buildingFocusDirectory, highlightLayers: highlightLayers, isolationMode: isolationMode }), _jsx(BuildingIcons, { buildings: cityData.buildings, centerOffset: centerOffset, growProgress: growProgress, heightScaling: heightScaling, linearScale: linearScale, highlightLayers: highlightLayers, isolationMode: isolationMode, hasActiveHighlights: activeHighlights, staggerIndices: staggerIndices, springDuration: springDuration, staggerDelay: animationConfig.staggerDelay || 15 })] }));
861
+ return (_jsxs(_Fragment, { children: [_jsx(AnimatedCamera, { citySize: citySize, isFlat: growProgress === 0, focusTarget: focusTarget }), _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 => (_jsx(DistrictFloor, { district: district, centerOffset: centerOffset, opacity: 1 }, district.path))), _jsx(InstancedBuildings, { buildings: cityData.buildings, centerOffset: centerOffset, onHover: onBuildingHover, onClick: onBuildingClick, hoveredIndex: hoveredIndex, selectedIndex: selectedIndex, growProgress: growProgress, animationConfig: animationConfig, heightScaling: heightScaling, linearScale: linearScale, staggerIndices: staggerIndices, focusDirectory: buildingFocusDirectory, highlightLayers: highlightLayers, isolationMode: isolationMode }), _jsx(BuildingIcons, { buildings: cityData.buildings, centerOffset: centerOffset, growProgress: growProgress, heightScaling: heightScaling, linearScale: linearScale, highlightLayers: highlightLayers, isolationMode: isolationMode, hasActiveHighlights: activeHighlights, staggerIndices: staggerIndices, springDuration: springDuration, staggerDelay: animationConfig.staggerDelay || 15 })] }));
843
862
  }
844
863
  /**
845
864
  * FileCity3D - 3D visualization of codebase structure
@@ -847,7 +866,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
847
866
  * Renders CityData as an interactive 3D city where buildings represent files
848
867
  * and their height corresponds to line count or file size.
849
868
  */
850
- export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls = true, highlightLayers = [], isolationMode = 'transparent', dimOpacity = 0.15, isLoading = false, loadingMessage = 'Loading file city...', emptyMessage = 'No file tree data available', heightScaling = 'logarithmic', linearScale = 0.05, focusDirectory = null, onDirectorySelect, backgroundColor = '#0f172a', textColor = '#94a3b8', }) {
869
+ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls = true, highlightLayers = [], isolationMode = 'transparent', dimOpacity = 0.15, isLoading = false, loadingMessage = 'Loading file city...', emptyMessage = 'No file tree data available', heightScaling = 'logarithmic', linearScale = 0.05, focusDirectory = null, onDirectorySelect, backgroundColor = '#0f172a', textColor = '#94a3b8', selectedBuilding = null, }) {
851
870
  const [hoveredBuilding, setHoveredBuilding] = useState(null);
852
871
  const [internalIsGrown, setInternalIsGrown] = useState(false);
853
872
  const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
@@ -917,6 +936,6 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
917
936
  left: 0,
918
937
  width: '100%',
919
938
  height: '100%',
920
- }, children: _jsx(CityScene, { cityData: cityData, onBuildingHover: setHoveredBuilding, onBuildingClick: onBuildingClick, hoveredBuilding: hoveredBuilding, growProgress: growProgress, animationConfig: animationConfig, highlightLayers: highlightLayers, isolationMode: isolationMode, heightScaling: heightScaling, linearScale: linearScale, focusDirectory: focusDirectory }) }), _jsx(InfoPanel, { building: hoveredBuilding }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
939
+ }, 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, focusDirectory: focusDirectory }) }), _jsx(InfoPanel, { building: selectedBuilding || hoveredBuilding }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
921
940
  }
922
941
  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.10",
3
+ "version": "0.5.11",
4
4
  "type": "module",
5
5
  "description": "React components for File City visualization",
6
6
  "main": "dist/index.js",
@@ -424,6 +424,7 @@ interface InstancedBuildingsProps {
424
424
  onHover?: (building: CityBuilding | null) => void;
425
425
  onClick?: (building: CityBuilding) => void;
426
426
  hoveredIndex: number | null;
427
+ selectedIndex: number | null;
427
428
  growProgress: number;
428
429
  animationConfig: AnimationConfig;
429
430
  heightScaling: HeightScaling;
@@ -446,6 +447,7 @@ function InstancedBuildings({
446
447
  onHover,
447
448
  onClick,
448
449
  hoveredIndex,
450
+ selectedIndex,
449
451
  growProgress,
450
452
  animationConfig,
451
453
  heightScaling,
@@ -620,7 +622,8 @@ function InstancedBuildings({
620
622
  const yPosition = height / 2 + baseOffset;
621
623
 
622
624
  const isHovered = hoveredIndex === data.index;
623
- const scale = isHovered ? 1.05 : 1;
625
+ const isSelected = selectedIndex === data.index;
626
+ const scale = isSelected ? 1.08 : isHovered ? 1.05 : 1;
624
627
 
625
628
  tempObject.position.set(x, yPosition, z);
626
629
  tempObject.scale.set(width * scale, height, depth * scale);
@@ -638,7 +641,9 @@ function InstancedBuildings({
638
641
  tempColor.g = tempColor.g * (1 - grayAmount) + gray * grayAmount;
639
642
  tempColor.b = tempColor.b * (1 - grayAmount) + gray * grayAmount;
640
643
  }
641
- if (isHovered) {
644
+ if (isSelected) {
645
+ tempColor.multiplyScalar(1.4);
646
+ } else if (isHovered) {
642
647
  tempColor.multiplyScalar(1.2);
643
648
  }
644
649
  meshRef.current!.setColorAt(instanceIndex, tempColor);
@@ -648,6 +653,9 @@ function InstancedBuildings({
648
653
  if (meshRef.current.instanceColor) {
649
654
  meshRef.current.instanceColor.needsUpdate = true;
650
655
  }
656
+
657
+ // Update bounding sphere for raycasting as buildings grow/animate
658
+ meshRef.current.computeBoundingSphere();
651
659
  });
652
660
 
653
661
  const handlePointerMove = useCallback(
@@ -796,7 +804,12 @@ function AnimatedIcon({
796
804
  });
797
805
 
798
806
  return (
799
- <sprite ref={spriteRef} position={[x, 0, z]} scale={[iconSize, iconSize, 1]}>
807
+ <sprite
808
+ ref={spriteRef}
809
+ position={[x, 0, z]}
810
+ scale={[iconSize, iconSize, 1]}
811
+ raycast={() => null}
812
+ >
800
813
  <spriteMaterial
801
814
  ref={materialRef}
802
815
  map={texture}
@@ -983,6 +996,7 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps)
983
996
  const { camera } = useThree();
984
997
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
985
998
  const controlsRef = useRef<any>(null);
999
+ const isAnimatingRef = useRef(true);
986
1000
 
987
1001
  // Animated camera position and target
988
1002
  const targetPos = useMemo(() => {
@@ -1021,11 +1035,18 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps)
1021
1035
  lookY: targetPos.targetY,
1022
1036
  lookZ: targetPos.targetZ,
1023
1037
  config: { tension: 60, friction: 20 },
1038
+ onStart: () => {
1039
+ isAnimatingRef.current = true;
1040
+ },
1041
+ onRest: () => {
1042
+ isAnimatingRef.current = false;
1043
+ },
1024
1044
  });
1025
1045
 
1026
- // Update camera each frame based on spring values
1046
+ // Update camera each frame only while spring is animating
1047
+ // Once animation settles, let OrbitControls handle user interaction
1027
1048
  useFrame(() => {
1028
- if (!controlsRef.current) return;
1049
+ if (!controlsRef.current || !isAnimatingRef.current) return;
1029
1050
 
1030
1051
  camera.position.set(camX.get(), camY.get(), camZ.get());
1031
1052
  controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
@@ -1171,6 +1192,7 @@ interface CitySceneProps {
1171
1192
  onBuildingHover?: (building: CityBuilding | null) => void;
1172
1193
  onBuildingClick?: (building: CityBuilding) => void;
1173
1194
  hoveredBuilding: CityBuilding | null;
1195
+ selectedBuilding: CityBuilding | null;
1174
1196
  growProgress: number;
1175
1197
  animationConfig: AnimationConfig;
1176
1198
  highlightLayers: HighlightLayer[];
@@ -1185,6 +1207,7 @@ function CityScene({
1185
1207
  onBuildingHover,
1186
1208
  onBuildingClick,
1187
1209
  hoveredBuilding,
1210
+ selectedBuilding,
1188
1211
  growProgress,
1189
1212
  animationConfig,
1190
1213
  highlightLayers,
@@ -1387,6 +1410,11 @@ function CityScene({
1387
1410
  return cityData.buildings.findIndex(b => b.path === hoveredBuilding.path);
1388
1411
  }, [hoveredBuilding, cityData.buildings]);
1389
1412
 
1413
+ const selectedIndex = useMemo(() => {
1414
+ if (!selectedBuilding) return null;
1415
+ return cityData.buildings.findIndex(b => b.path === selectedBuilding.path);
1416
+ }, [selectedBuilding, cityData.buildings]);
1417
+
1390
1418
  // Calculate spring duration for animation sync
1391
1419
  const tension = animationConfig.tension || 120;
1392
1420
  const friction = animationConfig.friction || 14;
@@ -1425,6 +1453,7 @@ function CityScene({
1425
1453
  onHover={onBuildingHover}
1426
1454
  onClick={onBuildingClick}
1427
1455
  hoveredIndex={hoveredIndex}
1456
+ selectedIndex={selectedIndex}
1428
1457
  growProgress={growProgress}
1429
1458
  animationConfig={animationConfig}
1430
1459
  heightScaling={heightScaling}
@@ -1501,6 +1530,8 @@ export interface FileCity3DProps {
1501
1530
  backgroundColor?: string;
1502
1531
  /** Text color for secondary/placeholder text */
1503
1532
  textColor?: string;
1533
+ /** Currently selected building (controlled by host) */
1534
+ selectedBuilding?: CityBuilding | null;
1504
1535
  }
1505
1536
 
1506
1537
  /**
@@ -1532,6 +1563,7 @@ export function FileCity3D({
1532
1563
  onDirectorySelect,
1533
1564
  backgroundColor = '#0f172a',
1534
1565
  textColor = '#94a3b8',
1566
+ selectedBuilding = null,
1535
1567
  }: FileCity3DProps) {
1536
1568
  const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(null);
1537
1569
  const [internalIsGrown, setInternalIsGrown] = useState(false);
@@ -1637,6 +1669,7 @@ export function FileCity3D({
1637
1669
  onBuildingHover={setHoveredBuilding}
1638
1670
  onBuildingClick={onBuildingClick}
1639
1671
  hoveredBuilding={hoveredBuilding}
1672
+ selectedBuilding={selectedBuilding}
1640
1673
  growProgress={growProgress}
1641
1674
  animationConfig={animationConfig}
1642
1675
  highlightLayers={highlightLayers}
@@ -1646,7 +1679,7 @@ export function FileCity3D({
1646
1679
  focusDirectory={focusDirectory}
1647
1680
  />
1648
1681
  </Canvas>
1649
- <InfoPanel building={hoveredBuilding} />
1682
+ <InfoPanel building={selectedBuilding || hoveredBuilding} />
1650
1683
  {showControls && (
1651
1684
  <ControlsOverlay isFlat={!isGrown} onToggle={handleToggle} onResetCamera={resetCamera} />
1652
1685
  )}
@@ -373,6 +373,28 @@ export const WithClickHandler: Story = {
373
373
  },
374
374
  };
375
375
 
376
+ /**
377
+ * With selection - Click to select a building, click again to deselect
378
+ */
379
+ const WithSelectionTemplate: React.FC = () => {
380
+ const [selectedBuilding, setSelectedBuilding] = React.useState<CityBuilding | null>(null);
381
+
382
+ return (
383
+ <FileCity3D
384
+ cityData={sampleCityData}
385
+ height="100vh"
386
+ selectedBuilding={selectedBuilding}
387
+ onBuildingClick={building => {
388
+ setSelectedBuilding(prev => (prev?.path === building.path ? null : building));
389
+ }}
390
+ />
391
+ );
392
+ };
393
+
394
+ export const WithSelection: Story = {
395
+ render: () => <WithSelectionTemplate />,
396
+ };
397
+
376
398
  /**
377
399
  * Isolation - transparent mode
378
400
  */