@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.
- package/dist/components/FileCity3D/FileCity3D.d.ts +3 -1
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +29 -10
- package/package.json +1 -1
- package/src/components/FileCity3D/FileCity3D.tsx +39 -6
- package/src/stories/FileCity3D.stories.tsx +22 -0
|
@@ -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":"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;
|
|
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
|
|
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 (
|
|
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
|
|
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
|
@@ -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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
*/
|