@principal-ai/file-city-react 0.5.16 → 0.5.17
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 +22 -9
- package/package.json +1 -1
- package/src/components/FileCity3D/FileCity3D.tsx +26 -6
- 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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"}
|
|
@@ -623,7 +623,7 @@ export function getCameraAngle() {
|
|
|
623
623
|
export function getCameraTilt() {
|
|
624
624
|
return cameraApi?.getCurrentTilt() ?? null;
|
|
625
625
|
}
|
|
626
|
-
function AnimatedCamera({ citySize, isFlat
|
|
626
|
+
function AnimatedCamera({ citySize, isFlat, focusTarget, maxBuildingHeight = 0 }) {
|
|
627
627
|
const { camera } = useThree();
|
|
628
628
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
629
629
|
const controlsRef = useRef(null);
|
|
@@ -632,6 +632,7 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }) {
|
|
|
632
632
|
const hasAppliedInitial = useRef(false);
|
|
633
633
|
const frameCount = useRef(0);
|
|
634
634
|
// Compute target camera position
|
|
635
|
+
// When flat, use a more top-down view; when grown, use an angled view
|
|
635
636
|
const targetPos = useMemo(() => {
|
|
636
637
|
if (focusTarget) {
|
|
637
638
|
const distance = Math.max(focusTarget.size * 2, 50);
|
|
@@ -645,9 +646,15 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }) {
|
|
|
645
646
|
targetZ: focusTarget.z,
|
|
646
647
|
};
|
|
647
648
|
}
|
|
648
|
-
// Default overview
|
|
649
|
-
|
|
650
|
-
|
|
649
|
+
// Default overview - adjust angle based on flat/grown state
|
|
650
|
+
// Flat: directly overhead (90 degrees, looking straight down)
|
|
651
|
+
// Grown: angled view to see building heights (optionally based on max building height)
|
|
652
|
+
const baseHeight = citySize * 1.1;
|
|
653
|
+
const buildingAwareHeight = maxBuildingHeight > 0
|
|
654
|
+
? Math.max(baseHeight, maxBuildingHeight * 2.5)
|
|
655
|
+
: baseHeight;
|
|
656
|
+
const targetHeight = isFlat ? citySize * 2.0 : buildingAwareHeight;
|
|
657
|
+
const targetZ = isFlat ? 0.001 : citySize * 1.3; // Near-zero for top-down (tiny offset to avoid gimbal lock)
|
|
651
658
|
return {
|
|
652
659
|
x: 0,
|
|
653
660
|
y: targetHeight,
|
|
@@ -656,7 +663,7 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }) {
|
|
|
656
663
|
targetY: 0,
|
|
657
664
|
targetZ: 0,
|
|
658
665
|
};
|
|
659
|
-
}, [focusTarget, citySize]);
|
|
666
|
+
}, [focusTarget, citySize, isFlat, maxBuildingHeight]);
|
|
660
667
|
// Spring animation for camera movement
|
|
661
668
|
const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => ({
|
|
662
669
|
camX: targetPos.x,
|
|
@@ -1074,12 +1081,18 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera }) {
|
|
|
1074
1081
|
gap: 8,
|
|
1075
1082
|
}, 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
1083
|
}
|
|
1077
|
-
function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, focusColor, }) {
|
|
1084
|
+
function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, focusColor, adaptCameraToBuildings = false, }) {
|
|
1078
1085
|
const centerOffset = useMemo(() => ({
|
|
1079
1086
|
x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
|
|
1080
1087
|
z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
|
|
1081
1088
|
}), [cityData.bounds]);
|
|
1082
1089
|
const citySize = Math.max(cityData.bounds.maxX - cityData.bounds.minX, cityData.bounds.maxZ - cityData.bounds.minZ);
|
|
1090
|
+
// Calculate max building height for camera positioning (when adaptCameraToBuildings is true)
|
|
1091
|
+
const maxBuildingHeight = useMemo(() => {
|
|
1092
|
+
if (!adaptCameraToBuildings)
|
|
1093
|
+
return 0;
|
|
1094
|
+
return Math.max(...cityData.buildings.map(b => b.dimensions[1]), 0);
|
|
1095
|
+
}, [adaptCameraToBuildings, cityData.buildings]);
|
|
1083
1096
|
const activeHighlights = useMemo(() => hasActiveHighlights(highlightLayers), [highlightLayers]);
|
|
1084
1097
|
// Helper to check if a path is inside a directory
|
|
1085
1098
|
const isPathInDirectory = useCallback((path, directory) => {
|
|
@@ -1262,7 +1275,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
1262
1275
|
const tension = animationConfig.tension || 120;
|
|
1263
1276
|
const friction = animationConfig.friction || 14;
|
|
1264
1277
|
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 => {
|
|
1278
|
+
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
1279
|
// Check if district matches focusDirectory
|
|
1267
1280
|
const isFocused = buildingFocusDirectory
|
|
1268
1281
|
? district.path === buildingFocusDirectory
|
|
@@ -1293,7 +1306,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
1293
1306
|
* Renders CityData as an interactive 3D city where buildings represent files
|
|
1294
1307
|
* and their height corresponds to line count or file size.
|
|
1295
1308
|
*/
|
|
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, }) {
|
|
1309
|
+
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
1310
|
const [hoveredBuilding, setHoveredBuilding] = useState(null);
|
|
1298
1311
|
const [internalIsGrown, setInternalIsGrown] = useState(false);
|
|
1299
1312
|
const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
|
|
@@ -1364,6 +1377,6 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
|
|
|
1364
1377
|
left: 0,
|
|
1365
1378
|
width: '100%',
|
|
1366
1379
|
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 }))] }));
|
|
1380
|
+
}, 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
1381
|
}
|
|
1369
1382
|
export default FileCity3D;
|
package/package.json
CHANGED
|
@@ -979,6 +979,7 @@ interface AnimatedCameraProps {
|
|
|
979
979
|
citySize: number;
|
|
980
980
|
isFlat: boolean;
|
|
981
981
|
focusTarget?: FocusTarget | null;
|
|
982
|
+
maxBuildingHeight?: number;
|
|
982
983
|
}
|
|
983
984
|
|
|
984
985
|
// Camera rotation options
|
|
@@ -1096,7 +1097,7 @@ export function getCameraTilt() {
|
|
|
1096
1097
|
return cameraApi?.getCurrentTilt() ?? null;
|
|
1097
1098
|
}
|
|
1098
1099
|
|
|
1099
|
-
function AnimatedCamera({ citySize, isFlat
|
|
1100
|
+
function AnimatedCamera({ citySize, isFlat, focusTarget, maxBuildingHeight = 0 }: AnimatedCameraProps) {
|
|
1100
1101
|
const { camera } = useThree();
|
|
1101
1102
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1102
1103
|
const controlsRef = useRef<any>(null);
|
|
@@ -1106,6 +1107,7 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }: AnimatedCame
|
|
|
1106
1107
|
const frameCount = useRef(0);
|
|
1107
1108
|
|
|
1108
1109
|
// Compute target camera position
|
|
1110
|
+
// When flat, use a more top-down view; when grown, use an angled view
|
|
1109
1111
|
const targetPos = useMemo(() => {
|
|
1110
1112
|
if (focusTarget) {
|
|
1111
1113
|
const distance = Math.max(focusTarget.size * 2, 50);
|
|
@@ -1119,9 +1121,15 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }: AnimatedCame
|
|
|
1119
1121
|
targetZ: focusTarget.z,
|
|
1120
1122
|
};
|
|
1121
1123
|
}
|
|
1122
|
-
// Default overview
|
|
1123
|
-
|
|
1124
|
-
|
|
1124
|
+
// Default overview - adjust angle based on flat/grown state
|
|
1125
|
+
// Flat: directly overhead (90 degrees, looking straight down)
|
|
1126
|
+
// Grown: angled view to see building heights (optionally based on max building height)
|
|
1127
|
+
const baseHeight = citySize * 1.1;
|
|
1128
|
+
const buildingAwareHeight = maxBuildingHeight > 0
|
|
1129
|
+
? Math.max(baseHeight, maxBuildingHeight * 2.5)
|
|
1130
|
+
: baseHeight;
|
|
1131
|
+
const targetHeight = isFlat ? citySize * 2.0 : buildingAwareHeight;
|
|
1132
|
+
const targetZ = isFlat ? 0.001 : citySize * 1.3; // Near-zero for top-down (tiny offset to avoid gimbal lock)
|
|
1125
1133
|
return {
|
|
1126
1134
|
x: 0,
|
|
1127
1135
|
y: targetHeight,
|
|
@@ -1130,7 +1138,7 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }: AnimatedCame
|
|
|
1130
1138
|
targetY: 0,
|
|
1131
1139
|
targetZ: 0,
|
|
1132
1140
|
};
|
|
1133
|
-
}, [focusTarget, citySize]);
|
|
1141
|
+
}, [focusTarget, citySize, isFlat, maxBuildingHeight]);
|
|
1134
1142
|
|
|
1135
1143
|
// Spring animation for camera movement
|
|
1136
1144
|
const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => ({
|
|
@@ -1695,6 +1703,7 @@ interface CitySceneProps {
|
|
|
1695
1703
|
linearScale: number;
|
|
1696
1704
|
focusDirectory: string | null;
|
|
1697
1705
|
focusColor?: string | null;
|
|
1706
|
+
adaptCameraToBuildings?: boolean;
|
|
1698
1707
|
}
|
|
1699
1708
|
|
|
1700
1709
|
function CityScene({
|
|
@@ -1711,6 +1720,7 @@ function CityScene({
|
|
|
1711
1720
|
linearScale,
|
|
1712
1721
|
focusDirectory,
|
|
1713
1722
|
focusColor,
|
|
1723
|
+
adaptCameraToBuildings = false,
|
|
1714
1724
|
}: CitySceneProps) {
|
|
1715
1725
|
const centerOffset = useMemo(
|
|
1716
1726
|
() => ({
|
|
@@ -1725,6 +1735,12 @@ function CityScene({
|
|
|
1725
1735
|
cityData.bounds.maxZ - cityData.bounds.minZ,
|
|
1726
1736
|
);
|
|
1727
1737
|
|
|
1738
|
+
// Calculate max building height for camera positioning (when adaptCameraToBuildings is true)
|
|
1739
|
+
const maxBuildingHeight = useMemo(() => {
|
|
1740
|
+
if (!adaptCameraToBuildings) return 0;
|
|
1741
|
+
return Math.max(...cityData.buildings.map(b => b.dimensions[1]), 0);
|
|
1742
|
+
}, [adaptCameraToBuildings, cityData.buildings]);
|
|
1743
|
+
|
|
1728
1744
|
const activeHighlights = useMemo(() => hasActiveHighlights(highlightLayers), [highlightLayers]);
|
|
1729
1745
|
|
|
1730
1746
|
// Helper to check if a path is inside a directory
|
|
@@ -1956,7 +1972,7 @@ function CityScene({
|
|
|
1956
1972
|
|
|
1957
1973
|
return (
|
|
1958
1974
|
<>
|
|
1959
|
-
<AnimatedCamera citySize={citySize} isFlat={growProgress === 0} focusTarget={focusTarget} />
|
|
1975
|
+
<AnimatedCamera citySize={citySize} isFlat={growProgress === 0} focusTarget={focusTarget} maxBuildingHeight={maxBuildingHeight} />
|
|
1960
1976
|
|
|
1961
1977
|
<ambientLight intensity={1.2} />
|
|
1962
1978
|
<hemisphereLight args={['#ddeeff', '#667788', 0.8]} position={[0, citySize, 0]} />
|
|
@@ -2093,6 +2109,8 @@ export interface FileCity3DProps {
|
|
|
2093
2109
|
textColor?: string;
|
|
2094
2110
|
/** Currently selected building (controlled by host) */
|
|
2095
2111
|
selectedBuilding?: CityBuilding | null;
|
|
2112
|
+
/** When true, camera height adjusts based on tallest building when grown */
|
|
2113
|
+
adaptCameraToBuildings?: boolean;
|
|
2096
2114
|
}
|
|
2097
2115
|
|
|
2098
2116
|
/**
|
|
@@ -2126,6 +2144,7 @@ export function FileCity3D({
|
|
|
2126
2144
|
backgroundColor = '#0f172a',
|
|
2127
2145
|
textColor = '#94a3b8',
|
|
2128
2146
|
selectedBuilding = null,
|
|
2147
|
+
adaptCameraToBuildings = false,
|
|
2129
2148
|
}: FileCity3DProps) {
|
|
2130
2149
|
const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(null);
|
|
2131
2150
|
const [internalIsGrown, setInternalIsGrown] = useState(false);
|
|
@@ -2241,6 +2260,7 @@ export function FileCity3D({
|
|
|
2241
2260
|
linearScale={linearScale}
|
|
2242
2261
|
focusDirectory={focusDirectory}
|
|
2243
2262
|
focusColor={focusColor}
|
|
2263
|
+
adaptCameraToBuildings={adaptCameraToBuildings}
|
|
2244
2264
|
/>
|
|
2245
2265
|
</Canvas>
|
|
2246
2266
|
<InfoPanel building={selectedBuilding || hoveredBuilding} />
|
|
@@ -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
|
+
};
|