@principal-ai/file-city-react 0.5.16 → 0.5.18
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 +58 -22
- package/dist/components/FileCity3D/index.d.ts +2 -2
- package/dist/components/FileCity3D/index.d.ts.map +1 -1
- package/dist/components/FileCity3D/index.js +1 -1
- package/package.json +1 -1
- package/src/components/FileCity3D/FileCity3D.tsx +76 -24
- package/src/components/FileCity3D/index.ts +13 -2
- package/src/stories/FileCity3D.stories.tsx +131 -0
|
@@ -168,6 +168,8 @@ export interface FileCity3DProps {
|
|
|
168
168
|
textColor?: string;
|
|
169
169
|
/** Currently selected building (controlled by host) */
|
|
170
170
|
selectedBuilding?: CityBuilding | null;
|
|
171
|
+
/** When true, camera height adjusts based on tallest building when grown */
|
|
172
|
+
adaptCameraToBuildings?: boolean;
|
|
171
173
|
}
|
|
172
174
|
/**
|
|
173
175
|
* FileCity3D - 3D visualization of codebase structure
|
|
@@ -175,6 +177,6 @@ export interface FileCity3DProps {
|
|
|
175
177
|
* Renders CityData as an interactive 3D city where buildings represent files
|
|
176
178
|
* and their height corresponds to line count or file size.
|
|
177
179
|
*/
|
|
178
|
-
export declare function FileCity3D({ cityData, width, height, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls, highlightLayers, isolationMode, dimOpacity: _dimOpacity, isLoading, loadingMessage, emptyMessage, heightScaling, linearScale, focusDirectory, focusColor, onDirectorySelect: _onDirectorySelect, backgroundColor, textColor, selectedBuilding, }: FileCity3DProps): import("react/jsx-runtime").JSX.Element;
|
|
180
|
+
export declare function FileCity3D({ cityData, width, height, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls, highlightLayers, isolationMode, dimOpacity: _dimOpacity, isLoading, loadingMessage, emptyMessage, heightScaling, linearScale, focusDirectory, focusColor, onDirectorySelect: _onDirectorySelect, backgroundColor, textColor, selectedBuilding, adaptCameraToBuildings, }: FileCity3DProps): import("react/jsx-runtime").JSX.Element;
|
|
179
181
|
export default FileCity3D;
|
|
180
182
|
//# 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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"}
|
|
@@ -439,11 +439,11 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
|
|
|
439
439
|
})), growProgress: growProgress, minHeight: minHeight, baseOffset: baseOffset, springDuration: springDuration, heightMultipliersRef: heightMultipliersRef })] }));
|
|
440
440
|
}
|
|
441
441
|
function AnimatedIcon({ x, z, targetHeight, iconSize, texture, opacity, growProgress, staggerDelayMs, springDuration, }) {
|
|
442
|
-
const
|
|
442
|
+
const meshRef = useRef(null);
|
|
443
443
|
const startTimeRef = useRef(null);
|
|
444
444
|
const materialRef = useRef(null);
|
|
445
445
|
useFrame(({ clock }) => {
|
|
446
|
-
if (!
|
|
446
|
+
if (!meshRef.current)
|
|
447
447
|
return;
|
|
448
448
|
if (startTimeRef.current === null && growProgress > 0) {
|
|
449
449
|
startTimeRef.current = clock.elapsedTime * 1000;
|
|
@@ -465,13 +465,26 @@ function AnimatedIcon({ x, z, targetHeight, iconSize, texture, opacity, growProg
|
|
|
465
465
|
const baseOffset = 0.2;
|
|
466
466
|
const height = animProgress * targetHeight + minHeight;
|
|
467
467
|
const buildingTop = height + baseOffset;
|
|
468
|
-
|
|
469
|
-
|
|
468
|
+
// When flat (animProgress=0): icon lies flat at ground level
|
|
469
|
+
// When grown (animProgress=1): icon floats above building
|
|
470
|
+
const flatY = minHeight + baseOffset + 0.5;
|
|
471
|
+
const grownY = buildingTop + iconSize / 2 + 2;
|
|
472
|
+
const yPosition = flatY + (grownY - flatY) * animProgress;
|
|
473
|
+
meshRef.current.position.y = yPosition;
|
|
474
|
+
// Rotate from flat (facing up) to upright (facing camera-ish)
|
|
475
|
+
// Flat: -Math.PI / 2 (facing up)
|
|
476
|
+
// Grown: 0 (facing forward)
|
|
477
|
+
const flatRotationX = -Math.PI / 2;
|
|
478
|
+
const grownRotationX = 0;
|
|
479
|
+
meshRef.current.rotation.x = flatRotationX + (grownRotationX - flatRotationX) * animProgress;
|
|
470
480
|
if (materialRef.current) {
|
|
471
|
-
|
|
481
|
+
// Show icons even when flat, fade out only slightly
|
|
482
|
+
const minOpacity = 0.8;
|
|
483
|
+
const effectiveOpacity = minOpacity + (1 - minOpacity) * animProgress;
|
|
484
|
+
materialRef.current.opacity = opacity * effectiveOpacity;
|
|
472
485
|
}
|
|
473
486
|
});
|
|
474
|
-
return (
|
|
487
|
+
return (_jsxs("mesh", { ref: meshRef, position: [x, 0, z], scale: [iconSize, iconSize, 1], raycast: () => null, children: [_jsx("planeGeometry", { args: [1, 1] }), _jsx("meshBasicMaterial", { ref: materialRef, map: texture, transparent: true, opacity: 0.8, depthTest: true, depthWrite: false, side: THREE.DoubleSide })] }));
|
|
475
488
|
}
|
|
476
489
|
function BuildingIcons({ buildings, centerOffset, growProgress, heightScaling, linearScale, highlightLayers, isolationMode, hasActiveHighlights, staggerIndices, springDuration, staggerDelay, }) {
|
|
477
490
|
// Pre-compute buildings with icons
|
|
@@ -516,9 +529,7 @@ function BuildingIcons({ buildings, centerOffset, growProgress, heightScaling, l
|
|
|
516
529
|
staggerIndices,
|
|
517
530
|
staggerDelay,
|
|
518
531
|
]);
|
|
519
|
-
//
|
|
520
|
-
if (growProgress < 0.1)
|
|
521
|
-
return null;
|
|
532
|
+
// Icons are now always rendered (flat or grown)
|
|
522
533
|
return (_jsx(_Fragment, { children: buildingsWithIcons.map(({ building, config, x, z, targetHeight, shouldDim, staggerDelayMs }) => {
|
|
523
534
|
const icon = config.icon;
|
|
524
535
|
const texture = getIconTexture(icon.name, icon.color || '#ffffff');
|
|
@@ -526,14 +537,14 @@ function BuildingIcons({ buildings, centerOffset, growProgress, heightScaling, l
|
|
|
526
537
|
return null;
|
|
527
538
|
// Icon size based on building dimensions
|
|
528
539
|
const [width] = building.dimensions;
|
|
529
|
-
const baseSize = Math.max(width *
|
|
540
|
+
const baseSize = Math.max(width * 1.2, 8);
|
|
530
541
|
const heightBoost = Math.min(targetHeight / 20, 3);
|
|
531
542
|
const iconSize = (baseSize + heightBoost) * (icon.size || 1);
|
|
532
543
|
const opacity = shouldDim && isolationMode === 'transparent' ? 0.3 : 1;
|
|
533
544
|
return (_jsx(AnimatedIcon, { x: x, z: z, targetHeight: targetHeight, iconSize: iconSize, texture: texture, opacity: opacity, growProgress: growProgress, staggerDelayMs: staggerDelayMs, springDuration: springDuration }, building.path));
|
|
534
545
|
}) }));
|
|
535
546
|
}
|
|
536
|
-
function DistrictFloor({ district, centerOffset, highlightColor }) {
|
|
547
|
+
function DistrictFloor({ district, centerOffset, highlightColor, growProgress }) {
|
|
537
548
|
const { worldBounds } = district;
|
|
538
549
|
const width = worldBounds.maxX - worldBounds.minX;
|
|
539
550
|
const depth = worldBounds.maxZ - worldBounds.minZ;
|
|
@@ -545,7 +556,19 @@ function DistrictFloor({ district, centerOffset, highlightColor }) {
|
|
|
545
556
|
const borderColor = highlightColor || '#475569';
|
|
546
557
|
const lineWidth = highlightColor ? 3 : 1;
|
|
547
558
|
const labelColor = highlightColor || '#cbd5e1';
|
|
548
|
-
|
|
559
|
+
// Interpolate text rotation and position based on growProgress
|
|
560
|
+
// Flat: -Math.PI / 2 (facing up), positioned at center of district
|
|
561
|
+
// Grown: -Math.PI / 6 (angled), positioned at edge of district
|
|
562
|
+
const flatRotationX = -Math.PI / 2;
|
|
563
|
+
const grownRotationX = -Math.PI / 6;
|
|
564
|
+
const textRotationX = flatRotationX + (grownRotationX - flatRotationX) * growProgress;
|
|
565
|
+
const flatY = 0.5;
|
|
566
|
+
const grownY = 1.5;
|
|
567
|
+
const textY = flatY + (grownY - flatY) * growProgress;
|
|
568
|
+
const flatZ = 0; // Center of district when flat
|
|
569
|
+
const grownZ = depth / 2 + 2; // Edge of district when grown
|
|
570
|
+
const textZ = flatZ + (grownZ - flatZ) * growProgress;
|
|
571
|
+
return (_jsxs("group", { position: [centerX, 0, centerZ], children: [_jsxs("lineSegments", { rotation: [-Math.PI / 2, 0, 0], position: [0, floorY, 0], renderOrder: -1, children: [_jsx("edgesGeometry", { args: [new THREE.PlaneGeometry(width, depth)], attach: "geometry" }), _jsx("lineBasicMaterial", { color: borderColor, linewidth: lineWidth, depthWrite: false })] }), highlightColor && (_jsxs("mesh", { rotation: [-Math.PI / 2, 0, 0], position: [0, floorY - 0.1, 0], renderOrder: -2, children: [_jsx("planeGeometry", { args: [width, depth] }), _jsx("meshBasicMaterial", { color: highlightColor, transparent: true, opacity: 0.15, depthWrite: false })] })), district.label && (_jsx(Text, { position: [0, textY, textZ], rotation: [textRotationX, 0, 0], fontSize: Math.min(3, width / 6), color: labelColor, anchorX: "center", anchorY: "middle", outlineWidth: 0.1, outlineColor: "#0f172a", children: dirName }))] }));
|
|
549
572
|
}
|
|
550
573
|
let cameraApi = null;
|
|
551
574
|
export function resetCamera() {
|
|
@@ -623,7 +646,7 @@ export function getCameraAngle() {
|
|
|
623
646
|
export function getCameraTilt() {
|
|
624
647
|
return cameraApi?.getCurrentTilt() ?? null;
|
|
625
648
|
}
|
|
626
|
-
function AnimatedCamera({ citySize, isFlat
|
|
649
|
+
function AnimatedCamera({ citySize, isFlat, focusTarget, maxBuildingHeight = 0 }) {
|
|
627
650
|
const { camera } = useThree();
|
|
628
651
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
629
652
|
const controlsRef = useRef(null);
|
|
@@ -632,6 +655,7 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }) {
|
|
|
632
655
|
const hasAppliedInitial = useRef(false);
|
|
633
656
|
const frameCount = useRef(0);
|
|
634
657
|
// Compute target camera position
|
|
658
|
+
// When flat, use a more top-down view; when grown, use an angled view
|
|
635
659
|
const targetPos = useMemo(() => {
|
|
636
660
|
if (focusTarget) {
|
|
637
661
|
const distance = Math.max(focusTarget.size * 2, 50);
|
|
@@ -645,9 +669,15 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }) {
|
|
|
645
669
|
targetZ: focusTarget.z,
|
|
646
670
|
};
|
|
647
671
|
}
|
|
648
|
-
// Default overview
|
|
649
|
-
|
|
650
|
-
|
|
672
|
+
// Default overview - adjust angle based on flat/grown state
|
|
673
|
+
// Flat: directly overhead (90 degrees, looking straight down)
|
|
674
|
+
// Grown: angled view to see building heights (optionally based on max building height)
|
|
675
|
+
const baseHeight = citySize * 1.1;
|
|
676
|
+
const buildingAwareHeight = maxBuildingHeight > 0
|
|
677
|
+
? Math.max(baseHeight, maxBuildingHeight * 2.5)
|
|
678
|
+
: baseHeight;
|
|
679
|
+
const targetHeight = isFlat ? citySize * 2.0 : buildingAwareHeight;
|
|
680
|
+
const targetZ = isFlat ? 0.001 : citySize * 1.3; // Near-zero for top-down (tiny offset to avoid gimbal lock)
|
|
651
681
|
return {
|
|
652
682
|
x: 0,
|
|
653
683
|
y: targetHeight,
|
|
@@ -656,7 +686,7 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }) {
|
|
|
656
686
|
targetY: 0,
|
|
657
687
|
targetZ: 0,
|
|
658
688
|
};
|
|
659
|
-
}, [focusTarget, citySize]);
|
|
689
|
+
}, [focusTarget, citySize, isFlat, maxBuildingHeight]);
|
|
660
690
|
// Spring animation for camera movement
|
|
661
691
|
const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => ({
|
|
662
692
|
camX: targetPos.x,
|
|
@@ -1074,12 +1104,18 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera }) {
|
|
|
1074
1104
|
gap: 8,
|
|
1075
1105
|
}, children: [_jsx("button", { onClick: onResetCamera, style: buttonStyle, children: "Reset View" }), _jsx("button", { onClick: onToggle, style: buttonStyle, children: isFlat ? 'Grow to 3D' : 'Flatten to 2D' })] }));
|
|
1076
1106
|
}
|
|
1077
|
-
function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, focusColor, }) {
|
|
1107
|
+
function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, focusColor, adaptCameraToBuildings = false, }) {
|
|
1078
1108
|
const centerOffset = useMemo(() => ({
|
|
1079
1109
|
x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
|
|
1080
1110
|
z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
|
|
1081
1111
|
}), [cityData.bounds]);
|
|
1082
1112
|
const citySize = Math.max(cityData.bounds.maxX - cityData.bounds.minX, cityData.bounds.maxZ - cityData.bounds.minZ);
|
|
1113
|
+
// Calculate max building height for camera positioning (when adaptCameraToBuildings is true)
|
|
1114
|
+
const maxBuildingHeight = useMemo(() => {
|
|
1115
|
+
if (!adaptCameraToBuildings)
|
|
1116
|
+
return 0;
|
|
1117
|
+
return Math.max(...cityData.buildings.map(b => b.dimensions[1]), 0);
|
|
1118
|
+
}, [adaptCameraToBuildings, cityData.buildings]);
|
|
1083
1119
|
const activeHighlights = useMemo(() => hasActiveHighlights(highlightLayers), [highlightLayers]);
|
|
1084
1120
|
// Helper to check if a path is inside a directory
|
|
1085
1121
|
const isPathInDirectory = useCallback((path, directory) => {
|
|
@@ -1262,7 +1298,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
1262
1298
|
const tension = animationConfig.tension || 120;
|
|
1263
1299
|
const friction = animationConfig.friction || 14;
|
|
1264
1300
|
const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
|
|
1265
|
-
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 => {
|
|
1301
|
+
return (_jsxs(_Fragment, { children: [_jsx(AnimatedCamera, { citySize: citySize, isFlat: growProgress === 0, focusTarget: focusTarget, maxBuildingHeight: maxBuildingHeight }), _jsx("ambientLight", { intensity: 1.2 }), _jsx("hemisphereLight", { args: ['#ddeeff', '#667788', 0.8], position: [0, citySize, 0] }), _jsx("directionalLight", { position: [citySize, citySize * 1.5, citySize * 0.5], intensity: 2, castShadow: true, "shadow-mapSize": [2048, 2048] }), _jsx("directionalLight", { position: [-citySize * 0.5, citySize * 0.8, -citySize * 0.5], intensity: 1 }), _jsx("directionalLight", { position: [citySize * 0.3, citySize, citySize], intensity: 0.6 }), cityData.districts.map(district => {
|
|
1266
1302
|
// Check if district matches focusDirectory
|
|
1267
1303
|
const isFocused = buildingFocusDirectory
|
|
1268
1304
|
? district.path === buildingFocusDirectory
|
|
@@ -1284,7 +1320,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
1284
1320
|
// Use buildingFocusColor (synced with animation) instead of focusColor prop
|
|
1285
1321
|
// Focus color takes priority, then highlight layer color
|
|
1286
1322
|
const districtColor = (isFocused && buildingFocusColor) ? buildingFocusColor : highlightLayerColor;
|
|
1287
|
-
return (_jsx(DistrictFloor, { district: district, centerOffset: centerOffset, opacity: 1, highlightColor: districtColor }, district.path));
|
|
1323
|
+
return (_jsx(DistrictFloor, { district: district, centerOffset: centerOffset, opacity: 1, highlightColor: districtColor, growProgress: growProgress }, district.path));
|
|
1288
1324
|
}), _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 })] }));
|
|
1289
1325
|
}
|
|
1290
1326
|
/**
|
|
@@ -1293,7 +1329,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
1293
1329
|
* Renders CityData as an interactive 3D city where buildings represent files
|
|
1294
1330
|
* and their height corresponds to line count or file size.
|
|
1295
1331
|
*/
|
|
1296
|
-
export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls = true, highlightLayers = [], isolationMode = 'transparent', dimOpacity: _dimOpacity = 0.15, isLoading = false, loadingMessage = 'Loading file city...', emptyMessage = 'No file tree data available', heightScaling = 'logarithmic', linearScale = 0.05, focusDirectory = null, focusColor = null, onDirectorySelect: _onDirectorySelect, backgroundColor = '#0f172a', textColor = '#94a3b8', selectedBuilding = null, }) {
|
|
1332
|
+
export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls = true, highlightLayers = [], isolationMode = 'transparent', dimOpacity: _dimOpacity = 0.15, isLoading = false, loadingMessage = 'Loading file city...', emptyMessage = 'No file tree data available', heightScaling = 'logarithmic', linearScale = 0.05, focusDirectory = null, focusColor = null, onDirectorySelect: _onDirectorySelect, backgroundColor = '#0f172a', textColor = '#94a3b8', selectedBuilding = null, adaptCameraToBuildings = false, }) {
|
|
1297
1333
|
const [hoveredBuilding, setHoveredBuilding] = useState(null);
|
|
1298
1334
|
const [internalIsGrown, setInternalIsGrown] = useState(false);
|
|
1299
1335
|
const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
|
|
@@ -1364,6 +1400,6 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
|
|
|
1364
1400
|
left: 0,
|
|
1365
1401
|
width: '100%',
|
|
1366
1402
|
height: '100%',
|
|
1367
|
-
}, 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, focusColor: focusColor }) }), _jsx(InfoPanel, { building: selectedBuilding || hoveredBuilding }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
|
|
1403
|
+
}, 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, focusColor: focusColor, adaptCameraToBuildings: adaptCameraToBuildings }) }), _jsx(InfoPanel, { building: selectedBuilding || hoveredBuilding }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
|
|
1368
1404
|
}
|
|
1369
1405
|
export default FileCity3D;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FileCity3D - 3D visualization component
|
|
3
3
|
*/
|
|
4
|
-
export { FileCity3D, resetCamera,
|
|
5
|
-
export type { FileCity3DProps, AnimationConfig, HighlightLayer, HighlightItem, IsolationMode, HeightScaling, CityData, CityBuilding, CityDistrict,
|
|
4
|
+
export { FileCity3D, resetCamera, getCameraAngle, getCameraTarget, getCameraTilt, rotateCameraTo, rotateCameraBy, tiltCameraTo, tiltCameraBy, moveCameraTo, setCameraTarget, } from './FileCity3D';
|
|
5
|
+
export type { FileCity3DProps, AnimationConfig, HighlightLayer, HighlightItem, IsolationMode, HeightScaling, CityData, CityBuilding, CityDistrict, } from './FileCity3D';
|
|
6
6
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/FileCity3D/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/FileCity3D/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,UAAU,EACV,WAAW,EACX,cAAc,EACd,eAAe,EACf,aAAa,EACb,cAAc,EACd,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,eAAe,GAChB,MAAM,cAAc,CAAC;AACtB,YAAY,EACV,eAAe,EACf,eAAe,EACf,cAAc,EACd,aAAa,EACb,aAAa,EACb,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,YAAY,GACb,MAAM,cAAc,CAAC"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FileCity3D - 3D visualization component
|
|
3
3
|
*/
|
|
4
|
-
export { FileCity3D, resetCamera,
|
|
4
|
+
export { FileCity3D, resetCamera, getCameraAngle, getCameraTarget, getCameraTilt, rotateCameraTo, rotateCameraBy, tiltCameraTo, tiltCameraBy, moveCameraTo, setCameraTarget, } from './FileCity3D';
|
package/package.json
CHANGED
|
@@ -745,12 +745,12 @@ function AnimatedIcon({
|
|
|
745
745
|
staggerDelayMs,
|
|
746
746
|
springDuration,
|
|
747
747
|
}: AnimatedIconProps) {
|
|
748
|
-
const
|
|
748
|
+
const meshRef = useRef<THREE.Mesh>(null);
|
|
749
749
|
const startTimeRef = useRef<number | null>(null);
|
|
750
|
-
const materialRef = useRef<THREE.
|
|
750
|
+
const materialRef = useRef<THREE.MeshBasicMaterial>(null);
|
|
751
751
|
|
|
752
752
|
useFrame(({ clock }) => {
|
|
753
|
-
if (!
|
|
753
|
+
if (!meshRef.current) return;
|
|
754
754
|
|
|
755
755
|
if (startTimeRef.current === null && growProgress > 0) {
|
|
756
756
|
startTimeRef.current = clock.elapsedTime * 1000;
|
|
@@ -775,31 +775,48 @@ function AnimatedIcon({
|
|
|
775
775
|
const baseOffset = 0.2;
|
|
776
776
|
const height = animProgress * targetHeight + minHeight;
|
|
777
777
|
const buildingTop = height + baseOffset;
|
|
778
|
-
const yPosition = buildingTop + iconSize / 2 + 2;
|
|
779
778
|
|
|
780
|
-
|
|
779
|
+
// When flat (animProgress=0): icon lies flat at ground level
|
|
780
|
+
// When grown (animProgress=1): icon floats above building
|
|
781
|
+
const flatY = minHeight + baseOffset + 0.5;
|
|
782
|
+
const grownY = buildingTop + iconSize / 2 + 2;
|
|
783
|
+
const yPosition = flatY + (grownY - flatY) * animProgress;
|
|
784
|
+
|
|
785
|
+
meshRef.current.position.y = yPosition;
|
|
786
|
+
|
|
787
|
+
// Rotate from flat (facing up) to upright (facing camera-ish)
|
|
788
|
+
// Flat: -Math.PI / 2 (facing up)
|
|
789
|
+
// Grown: 0 (facing forward)
|
|
790
|
+
const flatRotationX = -Math.PI / 2;
|
|
791
|
+
const grownRotationX = 0;
|
|
792
|
+
meshRef.current.rotation.x = flatRotationX + (grownRotationX - flatRotationX) * animProgress;
|
|
781
793
|
|
|
782
794
|
if (materialRef.current) {
|
|
783
|
-
|
|
795
|
+
// Show icons even when flat, fade out only slightly
|
|
796
|
+
const minOpacity = 0.8;
|
|
797
|
+
const effectiveOpacity = minOpacity + (1 - minOpacity) * animProgress;
|
|
798
|
+
materialRef.current.opacity = opacity * effectiveOpacity;
|
|
784
799
|
}
|
|
785
800
|
});
|
|
786
801
|
|
|
787
802
|
return (
|
|
788
|
-
<
|
|
789
|
-
ref={
|
|
803
|
+
<mesh
|
|
804
|
+
ref={meshRef}
|
|
790
805
|
position={[x, 0, z]}
|
|
791
806
|
scale={[iconSize, iconSize, 1]}
|
|
792
807
|
raycast={() => null}
|
|
793
808
|
>
|
|
794
|
-
<
|
|
809
|
+
<planeGeometry args={[1, 1]} />
|
|
810
|
+
<meshBasicMaterial
|
|
795
811
|
ref={materialRef}
|
|
796
812
|
map={texture}
|
|
797
813
|
transparent
|
|
798
|
-
opacity={0}
|
|
814
|
+
opacity={0.8}
|
|
799
815
|
depthTest={true}
|
|
800
816
|
depthWrite={false}
|
|
817
|
+
side={THREE.DoubleSide}
|
|
801
818
|
/>
|
|
802
|
-
</
|
|
819
|
+
</mesh>
|
|
803
820
|
);
|
|
804
821
|
}
|
|
805
822
|
|
|
@@ -871,9 +888,7 @@ function BuildingIcons({
|
|
|
871
888
|
staggerDelay,
|
|
872
889
|
]);
|
|
873
890
|
|
|
874
|
-
//
|
|
875
|
-
if (growProgress < 0.1) return null;
|
|
876
|
-
|
|
891
|
+
// Icons are now always rendered (flat or grown)
|
|
877
892
|
return (
|
|
878
893
|
<>
|
|
879
894
|
{buildingsWithIcons.map(
|
|
@@ -884,7 +899,7 @@ function BuildingIcons({
|
|
|
884
899
|
|
|
885
900
|
// Icon size based on building dimensions
|
|
886
901
|
const [width] = building.dimensions;
|
|
887
|
-
const baseSize = Math.max(width *
|
|
902
|
+
const baseSize = Math.max(width * 1.2, 8);
|
|
888
903
|
const heightBoost = Math.min(targetHeight / 20, 3);
|
|
889
904
|
const iconSize = (baseSize + heightBoost) * (icon.size || 1);
|
|
890
905
|
|
|
@@ -916,9 +931,10 @@ interface DistrictFloorProps {
|
|
|
916
931
|
centerOffset: { x: number; z: number };
|
|
917
932
|
opacity: number;
|
|
918
933
|
highlightColor?: string | null;
|
|
934
|
+
growProgress: number;
|
|
919
935
|
}
|
|
920
936
|
|
|
921
|
-
function DistrictFloor({ district, centerOffset, highlightColor }: DistrictFloorProps) {
|
|
937
|
+
function DistrictFloor({ district, centerOffset, highlightColor, growProgress }: DistrictFloorProps) {
|
|
922
938
|
const { worldBounds } = district;
|
|
923
939
|
const width = worldBounds.maxX - worldBounds.minX;
|
|
924
940
|
const depth = worldBounds.maxZ - worldBounds.minZ;
|
|
@@ -934,6 +950,21 @@ function DistrictFloor({ district, centerOffset, highlightColor }: DistrictFloor
|
|
|
934
950
|
const lineWidth = highlightColor ? 3 : 1;
|
|
935
951
|
const labelColor = highlightColor || '#cbd5e1';
|
|
936
952
|
|
|
953
|
+
// Interpolate text rotation and position based on growProgress
|
|
954
|
+
// Flat: -Math.PI / 2 (facing up), positioned at center of district
|
|
955
|
+
// Grown: -Math.PI / 6 (angled), positioned at edge of district
|
|
956
|
+
const flatRotationX = -Math.PI / 2;
|
|
957
|
+
const grownRotationX = -Math.PI / 6;
|
|
958
|
+
const textRotationX = flatRotationX + (grownRotationX - flatRotationX) * growProgress;
|
|
959
|
+
|
|
960
|
+
const flatY = 0.5;
|
|
961
|
+
const grownY = 1.5;
|
|
962
|
+
const textY = flatY + (grownY - flatY) * growProgress;
|
|
963
|
+
|
|
964
|
+
const flatZ = 0; // Center of district when flat
|
|
965
|
+
const grownZ = depth / 2 + 2; // Edge of district when grown
|
|
966
|
+
const textZ = flatZ + (grownZ - flatZ) * growProgress;
|
|
967
|
+
|
|
937
968
|
return (
|
|
938
969
|
<group position={[centerX, 0, centerZ]}>
|
|
939
970
|
{/* Border outline */}
|
|
@@ -952,8 +983,8 @@ function DistrictFloor({ district, centerOffset, highlightColor }: DistrictFloor
|
|
|
952
983
|
|
|
953
984
|
{district.label && (
|
|
954
985
|
<Text
|
|
955
|
-
position={[0,
|
|
956
|
-
rotation={[
|
|
986
|
+
position={[0, textY, textZ]}
|
|
987
|
+
rotation={[textRotationX, 0, 0]}
|
|
957
988
|
fontSize={Math.min(3, width / 6)}
|
|
958
989
|
color={labelColor}
|
|
959
990
|
anchorX="center"
|
|
@@ -979,6 +1010,7 @@ interface AnimatedCameraProps {
|
|
|
979
1010
|
citySize: number;
|
|
980
1011
|
isFlat: boolean;
|
|
981
1012
|
focusTarget?: FocusTarget | null;
|
|
1013
|
+
maxBuildingHeight?: number;
|
|
982
1014
|
}
|
|
983
1015
|
|
|
984
1016
|
// Camera rotation options
|
|
@@ -1096,7 +1128,7 @@ export function getCameraTilt() {
|
|
|
1096
1128
|
return cameraApi?.getCurrentTilt() ?? null;
|
|
1097
1129
|
}
|
|
1098
1130
|
|
|
1099
|
-
function AnimatedCamera({ citySize, isFlat
|
|
1131
|
+
function AnimatedCamera({ citySize, isFlat, focusTarget, maxBuildingHeight = 0 }: AnimatedCameraProps) {
|
|
1100
1132
|
const { camera } = useThree();
|
|
1101
1133
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1102
1134
|
const controlsRef = useRef<any>(null);
|
|
@@ -1106,6 +1138,7 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }: AnimatedCame
|
|
|
1106
1138
|
const frameCount = useRef(0);
|
|
1107
1139
|
|
|
1108
1140
|
// Compute target camera position
|
|
1141
|
+
// When flat, use a more top-down view; when grown, use an angled view
|
|
1109
1142
|
const targetPos = useMemo(() => {
|
|
1110
1143
|
if (focusTarget) {
|
|
1111
1144
|
const distance = Math.max(focusTarget.size * 2, 50);
|
|
@@ -1119,9 +1152,15 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }: AnimatedCame
|
|
|
1119
1152
|
targetZ: focusTarget.z,
|
|
1120
1153
|
};
|
|
1121
1154
|
}
|
|
1122
|
-
// Default overview
|
|
1123
|
-
|
|
1124
|
-
|
|
1155
|
+
// Default overview - adjust angle based on flat/grown state
|
|
1156
|
+
// Flat: directly overhead (90 degrees, looking straight down)
|
|
1157
|
+
// Grown: angled view to see building heights (optionally based on max building height)
|
|
1158
|
+
const baseHeight = citySize * 1.1;
|
|
1159
|
+
const buildingAwareHeight = maxBuildingHeight > 0
|
|
1160
|
+
? Math.max(baseHeight, maxBuildingHeight * 2.5)
|
|
1161
|
+
: baseHeight;
|
|
1162
|
+
const targetHeight = isFlat ? citySize * 2.0 : buildingAwareHeight;
|
|
1163
|
+
const targetZ = isFlat ? 0.001 : citySize * 1.3; // Near-zero for top-down (tiny offset to avoid gimbal lock)
|
|
1125
1164
|
return {
|
|
1126
1165
|
x: 0,
|
|
1127
1166
|
y: targetHeight,
|
|
@@ -1130,7 +1169,7 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }: AnimatedCame
|
|
|
1130
1169
|
targetY: 0,
|
|
1131
1170
|
targetZ: 0,
|
|
1132
1171
|
};
|
|
1133
|
-
}, [focusTarget, citySize]);
|
|
1172
|
+
}, [focusTarget, citySize, isFlat, maxBuildingHeight]);
|
|
1134
1173
|
|
|
1135
1174
|
// Spring animation for camera movement
|
|
1136
1175
|
const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => ({
|
|
@@ -1695,6 +1734,7 @@ interface CitySceneProps {
|
|
|
1695
1734
|
linearScale: number;
|
|
1696
1735
|
focusDirectory: string | null;
|
|
1697
1736
|
focusColor?: string | null;
|
|
1737
|
+
adaptCameraToBuildings?: boolean;
|
|
1698
1738
|
}
|
|
1699
1739
|
|
|
1700
1740
|
function CityScene({
|
|
@@ -1711,6 +1751,7 @@ function CityScene({
|
|
|
1711
1751
|
linearScale,
|
|
1712
1752
|
focusDirectory,
|
|
1713
1753
|
focusColor,
|
|
1754
|
+
adaptCameraToBuildings = false,
|
|
1714
1755
|
}: CitySceneProps) {
|
|
1715
1756
|
const centerOffset = useMemo(
|
|
1716
1757
|
() => ({
|
|
@@ -1725,6 +1766,12 @@ function CityScene({
|
|
|
1725
1766
|
cityData.bounds.maxZ - cityData.bounds.minZ,
|
|
1726
1767
|
);
|
|
1727
1768
|
|
|
1769
|
+
// Calculate max building height for camera positioning (when adaptCameraToBuildings is true)
|
|
1770
|
+
const maxBuildingHeight = useMemo(() => {
|
|
1771
|
+
if (!adaptCameraToBuildings) return 0;
|
|
1772
|
+
return Math.max(...cityData.buildings.map(b => b.dimensions[1]), 0);
|
|
1773
|
+
}, [adaptCameraToBuildings, cityData.buildings]);
|
|
1774
|
+
|
|
1728
1775
|
const activeHighlights = useMemo(() => hasActiveHighlights(highlightLayers), [highlightLayers]);
|
|
1729
1776
|
|
|
1730
1777
|
// Helper to check if a path is inside a directory
|
|
@@ -1956,7 +2003,7 @@ function CityScene({
|
|
|
1956
2003
|
|
|
1957
2004
|
return (
|
|
1958
2005
|
<>
|
|
1959
|
-
<AnimatedCamera citySize={citySize} isFlat={growProgress === 0} focusTarget={focusTarget} />
|
|
2006
|
+
<AnimatedCamera citySize={citySize} isFlat={growProgress === 0} focusTarget={focusTarget} maxBuildingHeight={maxBuildingHeight} />
|
|
1960
2007
|
|
|
1961
2008
|
<ambientLight intensity={1.2} />
|
|
1962
2009
|
<hemisphereLight args={['#ddeeff', '#667788', 0.8]} position={[0, citySize, 0]} />
|
|
@@ -2002,6 +2049,7 @@ function CityScene({
|
|
|
2002
2049
|
centerOffset={centerOffset}
|
|
2003
2050
|
opacity={1}
|
|
2004
2051
|
highlightColor={districtColor}
|
|
2052
|
+
growProgress={growProgress}
|
|
2005
2053
|
/>
|
|
2006
2054
|
);
|
|
2007
2055
|
})}
|
|
@@ -2093,6 +2141,8 @@ export interface FileCity3DProps {
|
|
|
2093
2141
|
textColor?: string;
|
|
2094
2142
|
/** Currently selected building (controlled by host) */
|
|
2095
2143
|
selectedBuilding?: CityBuilding | null;
|
|
2144
|
+
/** When true, camera height adjusts based on tallest building when grown */
|
|
2145
|
+
adaptCameraToBuildings?: boolean;
|
|
2096
2146
|
}
|
|
2097
2147
|
|
|
2098
2148
|
/**
|
|
@@ -2126,6 +2176,7 @@ export function FileCity3D({
|
|
|
2126
2176
|
backgroundColor = '#0f172a',
|
|
2127
2177
|
textColor = '#94a3b8',
|
|
2128
2178
|
selectedBuilding = null,
|
|
2179
|
+
adaptCameraToBuildings = false,
|
|
2129
2180
|
}: FileCity3DProps) {
|
|
2130
2181
|
const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(null);
|
|
2131
2182
|
const [internalIsGrown, setInternalIsGrown] = useState(false);
|
|
@@ -2241,6 +2292,7 @@ export function FileCity3D({
|
|
|
2241
2292
|
linearScale={linearScale}
|
|
2242
2293
|
focusDirectory={focusDirectory}
|
|
2243
2294
|
focusColor={focusColor}
|
|
2295
|
+
adaptCameraToBuildings={adaptCameraToBuildings}
|
|
2244
2296
|
/>
|
|
2245
2297
|
</Canvas>
|
|
2246
2298
|
<InfoPanel building={selectedBuilding || hoveredBuilding} />
|
|
@@ -2,7 +2,19 @@
|
|
|
2
2
|
* FileCity3D - 3D visualization component
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
export {
|
|
5
|
+
export {
|
|
6
|
+
FileCity3D,
|
|
7
|
+
resetCamera,
|
|
8
|
+
getCameraAngle,
|
|
9
|
+
getCameraTarget,
|
|
10
|
+
getCameraTilt,
|
|
11
|
+
rotateCameraTo,
|
|
12
|
+
rotateCameraBy,
|
|
13
|
+
tiltCameraTo,
|
|
14
|
+
tiltCameraBy,
|
|
15
|
+
moveCameraTo,
|
|
16
|
+
setCameraTarget,
|
|
17
|
+
} from './FileCity3D';
|
|
6
18
|
export type {
|
|
7
19
|
FileCity3DProps,
|
|
8
20
|
AnimationConfig,
|
|
@@ -13,5 +25,4 @@ export type {
|
|
|
13
25
|
CityData,
|
|
14
26
|
CityBuilding,
|
|
15
27
|
CityDistrict,
|
|
16
|
-
RotateOptions,
|
|
17
28
|
} from './FileCity3D';
|
|
@@ -2001,3 +2001,134 @@ export const CameraControls: Story = {
|
|
|
2001
2001
|
},
|
|
2002
2002
|
},
|
|
2003
2003
|
};
|
|
2004
|
+
|
|
2005
|
+
/**
|
|
2006
|
+
* 2D to 3D Transition - Tests the camera angle adjustment when transitioning from flat to grown
|
|
2007
|
+
* Camera starts top-down when flat, then animates to an angled view when buildings grow
|
|
2008
|
+
*/
|
|
2009
|
+
const FlatToGrownTransitionTemplate: React.FC = () => {
|
|
2010
|
+
const [isGrown, setIsGrown] = React.useState(false);
|
|
2011
|
+
const [autoTransition, setAutoTransition] = React.useState(false);
|
|
2012
|
+
const [adaptCamera, setAdaptCamera] = React.useState(true);
|
|
2013
|
+
|
|
2014
|
+
// Auto-transition effect (simulates the CodeCityPanel behavior)
|
|
2015
|
+
React.useEffect(() => {
|
|
2016
|
+
if (!autoTransition) return;
|
|
2017
|
+
setIsGrown(false);
|
|
2018
|
+
const timer = setTimeout(() => {
|
|
2019
|
+
setIsGrown(true);
|
|
2020
|
+
}, 600);
|
|
2021
|
+
return () => clearTimeout(timer);
|
|
2022
|
+
}, [autoTransition]);
|
|
2023
|
+
|
|
2024
|
+
return (
|
|
2025
|
+
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', position: 'relative' }}>
|
|
2026
|
+
{/* 3D City */}
|
|
2027
|
+
<FileCity3D
|
|
2028
|
+
cityData={sampleCityData}
|
|
2029
|
+
height="100%"
|
|
2030
|
+
heightScaling="linear"
|
|
2031
|
+
linearScale={0.5}
|
|
2032
|
+
isGrown={isGrown}
|
|
2033
|
+
adaptCameraToBuildings={adaptCamera}
|
|
2034
|
+
animation={{
|
|
2035
|
+
startFlat: true,
|
|
2036
|
+
autoStartDelay: null, // External control
|
|
2037
|
+
staggerDelay: 15,
|
|
2038
|
+
tension: 120,
|
|
2039
|
+
friction: 14,
|
|
2040
|
+
}}
|
|
2041
|
+
showControls={true}
|
|
2042
|
+
/>
|
|
2043
|
+
|
|
2044
|
+
{/* Control panel */}
|
|
2045
|
+
<div
|
|
2046
|
+
style={{
|
|
2047
|
+
position: 'absolute',
|
|
2048
|
+
bottom: 0,
|
|
2049
|
+
left: 0,
|
|
2050
|
+
right: 0,
|
|
2051
|
+
zIndex: 100,
|
|
2052
|
+
background: 'rgba(15, 23, 42, 0.95)',
|
|
2053
|
+
borderTop: '1px solid #334155',
|
|
2054
|
+
padding: '16px 24px',
|
|
2055
|
+
}}
|
|
2056
|
+
>
|
|
2057
|
+
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', justifyContent: 'center' }}>
|
|
2058
|
+
<div style={{ color: '#94a3b8', fontSize: '14px' }}>
|
|
2059
|
+
State: <strong style={{ color: isGrown ? '#22c55e' : '#f59e0b' }}>{isGrown ? 'GROWN' : 'FLAT'}</strong>
|
|
2060
|
+
</div>
|
|
2061
|
+
<button
|
|
2062
|
+
onClick={() => setIsGrown(false)}
|
|
2063
|
+
style={{
|
|
2064
|
+
padding: '8px 16px',
|
|
2065
|
+
background: !isGrown ? '#3b82f6' : '#1e293b',
|
|
2066
|
+
color: 'white',
|
|
2067
|
+
border: '1px solid #334155',
|
|
2068
|
+
borderRadius: '6px',
|
|
2069
|
+
cursor: 'pointer',
|
|
2070
|
+
}}
|
|
2071
|
+
>
|
|
2072
|
+
Flatten (2D)
|
|
2073
|
+
</button>
|
|
2074
|
+
<button
|
|
2075
|
+
onClick={() => setIsGrown(true)}
|
|
2076
|
+
style={{
|
|
2077
|
+
padding: '8px 16px',
|
|
2078
|
+
background: isGrown ? '#3b82f6' : '#1e293b',
|
|
2079
|
+
color: 'white',
|
|
2080
|
+
border: '1px solid #334155',
|
|
2081
|
+
borderRadius: '6px',
|
|
2082
|
+
cursor: 'pointer',
|
|
2083
|
+
}}
|
|
2084
|
+
>
|
|
2085
|
+
Grow (3D)
|
|
2086
|
+
</button>
|
|
2087
|
+
<button
|
|
2088
|
+
onClick={() => {
|
|
2089
|
+
setAutoTransition(false);
|
|
2090
|
+
setTimeout(() => setAutoTransition(true), 10);
|
|
2091
|
+
}}
|
|
2092
|
+
style={{
|
|
2093
|
+
padding: '8px 16px',
|
|
2094
|
+
background: '#7c3aed',
|
|
2095
|
+
color: 'white',
|
|
2096
|
+
border: '1px solid #334155',
|
|
2097
|
+
borderRadius: '6px',
|
|
2098
|
+
cursor: 'pointer',
|
|
2099
|
+
}}
|
|
2100
|
+
>
|
|
2101
|
+
Simulate 2D→3D Transition
|
|
2102
|
+
</button>
|
|
2103
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#94a3b8', fontSize: '14px', cursor: 'pointer' }}>
|
|
2104
|
+
<input
|
|
2105
|
+
type="checkbox"
|
|
2106
|
+
checked={adaptCamera}
|
|
2107
|
+
onChange={(e) => setAdaptCamera(e.target.checked)}
|
|
2108
|
+
style={{ cursor: 'pointer' }}
|
|
2109
|
+
/>
|
|
2110
|
+
Adapt to building heights
|
|
2111
|
+
</label>
|
|
2112
|
+
</div>
|
|
2113
|
+
<div style={{ color: '#64748b', fontSize: '12px', textAlign: 'center', marginTop: '8px' }}>
|
|
2114
|
+
Camera should start top-down when flat, then animate to angled view when grown.
|
|
2115
|
+
{adaptCamera && ' Camera height will adjust based on tallest building.'}
|
|
2116
|
+
</div>
|
|
2117
|
+
</div>
|
|
2118
|
+
</div>
|
|
2119
|
+
);
|
|
2120
|
+
};
|
|
2121
|
+
|
|
2122
|
+
export const FlatToGrownTransition: Story = {
|
|
2123
|
+
render: () => <FlatToGrownTransitionTemplate />,
|
|
2124
|
+
parameters: {
|
|
2125
|
+
docs: {
|
|
2126
|
+
description: {
|
|
2127
|
+
story:
|
|
2128
|
+
'Tests the camera angle adjustment when transitioning between flat (2D) and grown (3D) states. ' +
|
|
2129
|
+
'The camera should start with a top-down view when buildings are flat, then animate to an angled view when buildings grow. ' +
|
|
2130
|
+
'Use "Simulate 2D→3D Transition" to test the full transition sequence as it happens in CodeCityPanel.',
|
|
2131
|
+
},
|
|
2132
|
+
},
|
|
2133
|
+
},
|
|
2134
|
+
};
|