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

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":"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"}
@@ -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,8 @@ 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
+ // Start false - only block rotation during active camera animations
573
+ const isAnimatingRef = useRef(false);
566
574
  // Animated camera position and target
567
575
  const targetPos = useMemo(() => {
568
576
  if (focusTarget) {
@@ -590,7 +598,17 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
590
598
  targetZ: 0,
591
599
  };
592
600
  }, [focusTarget, isFlat, citySize]);
593
- // Spring animation for camera movement
601
+ // Set initial camera position on mount
602
+ useEffect(() => {
603
+ camera.position.set(targetPos.x, targetPos.y, targetPos.z);
604
+ if (controlsRef.current) {
605
+ controlsRef.current.target.set(targetPos.targetX, targetPos.targetY, targetPos.targetZ);
606
+ controlsRef.current.update();
607
+ }
608
+ // Only run on mount
609
+ // eslint-disable-next-line react-hooks/exhaustive-deps
610
+ }, []);
611
+ // Spring animation for camera movement (only for subsequent changes)
594
612
  const { camX, camY, camZ, lookX, lookY, lookZ } = useSpring({
595
613
  camX: targetPos.x,
596
614
  camY: targetPos.y,
@@ -599,10 +617,17 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
599
617
  lookY: targetPos.targetY,
600
618
  lookZ: targetPos.targetZ,
601
619
  config: { tension: 60, friction: 20 },
620
+ onStart: () => {
621
+ isAnimatingRef.current = true;
622
+ },
623
+ onRest: () => {
624
+ isAnimatingRef.current = false;
625
+ },
602
626
  });
603
- // Update camera each frame based on spring values
627
+ // Update camera each frame only while spring is animating
628
+ // Once animation settles, let OrbitControls handle user interaction
604
629
  useFrame(() => {
605
- if (!controlsRef.current)
630
+ if (!controlsRef.current || !isAnimatingRef.current)
606
631
  return;
607
632
  camera.position.set(camX.get(), camY.get(), camZ.get());
608
633
  controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
@@ -681,7 +706,7 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera }) {
681
706
  gap: 8,
682
707
  }, 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
708
  }
684
- function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, }) {
709
+ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, }) {
685
710
  const centerOffset = useMemo(() => ({
686
711
  x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
687
712
  z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
@@ -835,11 +860,16 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
835
860
  return null;
836
861
  return cityData.buildings.findIndex(b => b.path === hoveredBuilding.path);
837
862
  }, [hoveredBuilding, cityData.buildings]);
863
+ const selectedIndex = useMemo(() => {
864
+ if (!selectedBuilding)
865
+ return null;
866
+ return cityData.buildings.findIndex(b => b.path === selectedBuilding.path);
867
+ }, [selectedBuilding, cityData.buildings]);
838
868
  // Calculate spring duration for animation sync
839
869
  const tension = animationConfig.tension || 120;
840
870
  const friction = animationConfig.friction || 14;
841
871
  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 })] }));
872
+ 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
873
  }
844
874
  /**
845
875
  * FileCity3D - 3D visualization of codebase structure
@@ -847,7 +877,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
847
877
  * Renders CityData as an interactive 3D city where buildings represent files
848
878
  * and their height corresponds to line count or file size.
849
879
  */
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', }) {
880
+ 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
881
  const [hoveredBuilding, setHoveredBuilding] = useState(null);
852
882
  const [internalIsGrown, setInternalIsGrown] = useState(false);
853
883
  const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
@@ -917,6 +947,6 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
917
947
  left: 0,
918
948
  width: '100%',
919
949
  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 }))] }));
950
+ }, 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
951
  }
922
952
  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.12",
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,8 @@ 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
+ // Start false - only block rotation during active camera animations
1000
+ const isAnimatingRef = useRef(false);
986
1001
 
987
1002
  // Animated camera position and target
988
1003
  const targetPos = useMemo(() => {
@@ -1012,7 +1027,18 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps)
1012
1027
  };
1013
1028
  }, [focusTarget, isFlat, citySize]);
1014
1029
 
1015
- // Spring animation for camera movement
1030
+ // Set initial camera position on mount
1031
+ useEffect(() => {
1032
+ camera.position.set(targetPos.x, targetPos.y, targetPos.z);
1033
+ if (controlsRef.current) {
1034
+ controlsRef.current.target.set(targetPos.targetX, targetPos.targetY, targetPos.targetZ);
1035
+ controlsRef.current.update();
1036
+ }
1037
+ // Only run on mount
1038
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1039
+ }, []);
1040
+
1041
+ // Spring animation for camera movement (only for subsequent changes)
1016
1042
  const { camX, camY, camZ, lookX, lookY, lookZ } = useSpring({
1017
1043
  camX: targetPos.x,
1018
1044
  camY: targetPos.y,
@@ -1021,11 +1047,18 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps)
1021
1047
  lookY: targetPos.targetY,
1022
1048
  lookZ: targetPos.targetZ,
1023
1049
  config: { tension: 60, friction: 20 },
1050
+ onStart: () => {
1051
+ isAnimatingRef.current = true;
1052
+ },
1053
+ onRest: () => {
1054
+ isAnimatingRef.current = false;
1055
+ },
1024
1056
  });
1025
1057
 
1026
- // Update camera each frame based on spring values
1058
+ // Update camera each frame only while spring is animating
1059
+ // Once animation settles, let OrbitControls handle user interaction
1027
1060
  useFrame(() => {
1028
- if (!controlsRef.current) return;
1061
+ if (!controlsRef.current || !isAnimatingRef.current) return;
1029
1062
 
1030
1063
  camera.position.set(camX.get(), camY.get(), camZ.get());
1031
1064
  controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
@@ -1171,6 +1204,7 @@ interface CitySceneProps {
1171
1204
  onBuildingHover?: (building: CityBuilding | null) => void;
1172
1205
  onBuildingClick?: (building: CityBuilding) => void;
1173
1206
  hoveredBuilding: CityBuilding | null;
1207
+ selectedBuilding: CityBuilding | null;
1174
1208
  growProgress: number;
1175
1209
  animationConfig: AnimationConfig;
1176
1210
  highlightLayers: HighlightLayer[];
@@ -1185,6 +1219,7 @@ function CityScene({
1185
1219
  onBuildingHover,
1186
1220
  onBuildingClick,
1187
1221
  hoveredBuilding,
1222
+ selectedBuilding,
1188
1223
  growProgress,
1189
1224
  animationConfig,
1190
1225
  highlightLayers,
@@ -1387,6 +1422,11 @@ function CityScene({
1387
1422
  return cityData.buildings.findIndex(b => b.path === hoveredBuilding.path);
1388
1423
  }, [hoveredBuilding, cityData.buildings]);
1389
1424
 
1425
+ const selectedIndex = useMemo(() => {
1426
+ if (!selectedBuilding) return null;
1427
+ return cityData.buildings.findIndex(b => b.path === selectedBuilding.path);
1428
+ }, [selectedBuilding, cityData.buildings]);
1429
+
1390
1430
  // Calculate spring duration for animation sync
1391
1431
  const tension = animationConfig.tension || 120;
1392
1432
  const friction = animationConfig.friction || 14;
@@ -1425,6 +1465,7 @@ function CityScene({
1425
1465
  onHover={onBuildingHover}
1426
1466
  onClick={onBuildingClick}
1427
1467
  hoveredIndex={hoveredIndex}
1468
+ selectedIndex={selectedIndex}
1428
1469
  growProgress={growProgress}
1429
1470
  animationConfig={animationConfig}
1430
1471
  heightScaling={heightScaling}
@@ -1501,6 +1542,8 @@ export interface FileCity3DProps {
1501
1542
  backgroundColor?: string;
1502
1543
  /** Text color for secondary/placeholder text */
1503
1544
  textColor?: string;
1545
+ /** Currently selected building (controlled by host) */
1546
+ selectedBuilding?: CityBuilding | null;
1504
1547
  }
1505
1548
 
1506
1549
  /**
@@ -1532,6 +1575,7 @@ export function FileCity3D({
1532
1575
  onDirectorySelect,
1533
1576
  backgroundColor = '#0f172a',
1534
1577
  textColor = '#94a3b8',
1578
+ selectedBuilding = null,
1535
1579
  }: FileCity3DProps) {
1536
1580
  const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(null);
1537
1581
  const [internalIsGrown, setInternalIsGrown] = useState(false);
@@ -1637,6 +1681,7 @@ export function FileCity3D({
1637
1681
  onBuildingHover={setHoveredBuilding}
1638
1682
  onBuildingClick={onBuildingClick}
1639
1683
  hoveredBuilding={hoveredBuilding}
1684
+ selectedBuilding={selectedBuilding}
1640
1685
  growProgress={growProgress}
1641
1686
  animationConfig={animationConfig}
1642
1687
  highlightLayers={highlightLayers}
@@ -1646,7 +1691,7 @@ export function FileCity3D({
1646
1691
  focusDirectory={focusDirectory}
1647
1692
  />
1648
1693
  </Canvas>
1649
- <InfoPanel building={hoveredBuilding} />
1694
+ <InfoPanel building={selectedBuilding || hoveredBuilding} />
1650
1695
  {showControls && (
1651
1696
  <ControlsOverlay isFlat={!isGrown} onToggle={handleToggle} onResetCamera={resetCamera} />
1652
1697
  )}
@@ -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
  */