@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.
- 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 +41 -11
- package/package.json +1 -1
- package/src/components/FileCity3D/FileCity3D.tsx +52 -7
- 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":"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;AA2fD,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
|
|
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,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
|
-
//
|
|
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
|
|
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
|
@@ -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,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
|
-
//
|
|
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
|
|
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
|
*/
|