@principal-ai/file-city-react 0.5.7 → 0.5.9
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 +5 -1
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +479 -62
- package/package.json +1 -1
- package/src/components/FileCity3D/FileCity3D.tsx +749 -103
- package/src/stories/FileCity3D.stories.tsx +421 -1
|
@@ -10,7 +10,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
10
10
|
import { useMemo, useRef, useState, useEffect, useCallback, } from 'react';
|
|
11
11
|
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
|
12
12
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
13
|
-
import { animated } from '@react-spring/three';
|
|
13
|
+
import { animated, useSpring } from '@react-spring/three';
|
|
14
14
|
import { OrbitControls, PerspectiveCamera, Text, RoundedBox, } from '@react-three/drei';
|
|
15
15
|
import { getFileConfig } from '@principal-ai/file-city-builder';
|
|
16
16
|
import * as THREE from 'three';
|
|
@@ -95,6 +95,66 @@ function calculateBuildingHeight(building, scaling = 'logarithmic', linearScale
|
|
|
95
95
|
// Fallback to dimension height if no metrics available
|
|
96
96
|
return building.dimensions[1];
|
|
97
97
|
}
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// Icon Texture Generation - Lucide icon SVG paths
|
|
100
|
+
// ============================================================================
|
|
101
|
+
// Lucide icon paths (from lucide.dev)
|
|
102
|
+
const LUCIDE_ICONS = {
|
|
103
|
+
Atom: '<circle cx="12" cy="12" r="1"/><path d="M20.2 20.2c2.04-2.03.02-7.36-4.5-11.9-4.54-4.52-9.87-6.54-11.9-4.5-2.04 2.03-.02 7.36 4.5 11.9 4.54 4.52 9.87 6.54 11.9 4.5Z"/><path d="M15.7 15.7c4.52-4.54 6.54-9.87 4.5-11.9-2.03-2.04-7.36-.02-11.9 4.5-4.52 4.54-6.54 9.87-4.5 11.9 2.03 2.04 7.36.02 11.9-4.5Z"/>',
|
|
104
|
+
Lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
|
105
|
+
EyeOff: '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>',
|
|
106
|
+
Key: '<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/>',
|
|
107
|
+
GitBranch: '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
|
|
108
|
+
TestTube: '<path d="M14.5 2v17.5c0 1.4-1.1 2.5-2.5 2.5c-1.4 0-2.5-1.1-2.5-2.5V2"/><path d="M8.5 2h7"/><path d="M14.5 16h-5"/>',
|
|
109
|
+
FlaskConical: '<path d="M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2"/><path d="M8.5 2h7"/><path d="M7 16h10"/>',
|
|
110
|
+
BookText: '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/><path d="M8 11h8"/><path d="M8 7h6"/>',
|
|
111
|
+
BookOpen: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
|
|
112
|
+
ScrollText: '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
|
|
113
|
+
Settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
|
114
|
+
Home: '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
|
115
|
+
};
|
|
116
|
+
// Cache for icon textures
|
|
117
|
+
const iconTextureCache = new Map();
|
|
118
|
+
/**
|
|
119
|
+
* Generate a texture from a Lucide icon
|
|
120
|
+
*/
|
|
121
|
+
function getIconTexture(iconName, color = '#ffffff') {
|
|
122
|
+
const cacheKey = `${iconName}-${color}`;
|
|
123
|
+
if (iconTextureCache.has(cacheKey)) {
|
|
124
|
+
return iconTextureCache.get(cacheKey);
|
|
125
|
+
}
|
|
126
|
+
const iconPath = LUCIDE_ICONS[iconName];
|
|
127
|
+
if (!iconPath) {
|
|
128
|
+
// Icon not in our subset, skip silently
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">${iconPath}</svg>`;
|
|
132
|
+
// Create canvas and draw SVG
|
|
133
|
+
const canvas = document.createElement('canvas');
|
|
134
|
+
canvas.width = 128;
|
|
135
|
+
canvas.height = 128;
|
|
136
|
+
const ctx = canvas.getContext('2d');
|
|
137
|
+
if (!ctx)
|
|
138
|
+
return null;
|
|
139
|
+
// Create image from SVG
|
|
140
|
+
const img = new Image();
|
|
141
|
+
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
|
142
|
+
const url = URL.createObjectURL(svgBlob);
|
|
143
|
+
// Create texture (will update when image loads)
|
|
144
|
+
const texture = new THREE.Texture(canvas);
|
|
145
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
146
|
+
img.onload = () => {
|
|
147
|
+
// Clear canvas with transparent background
|
|
148
|
+
ctx.clearRect(0, 0, 128, 128);
|
|
149
|
+
// Draw centered icon
|
|
150
|
+
ctx.drawImage(img, 32, 32, 64, 64);
|
|
151
|
+
texture.needsUpdate = true;
|
|
152
|
+
URL.revokeObjectURL(url);
|
|
153
|
+
};
|
|
154
|
+
img.src = url;
|
|
155
|
+
iconTextureCache.set(cacheKey, texture);
|
|
156
|
+
return texture;
|
|
157
|
+
}
|
|
98
158
|
// Get full file config from centralized file-city-builder lookup
|
|
99
159
|
function getConfigForFile(building) {
|
|
100
160
|
if (building.color) {
|
|
@@ -135,24 +195,118 @@ function hasActiveHighlights(layers) {
|
|
|
135
195
|
}
|
|
136
196
|
// Animated RoundedBox wrapper
|
|
137
197
|
const AnimatedRoundedBox = animated(RoundedBox);
|
|
138
|
-
|
|
198
|
+
// Animated meshStandardMaterial for opacity transitions
|
|
199
|
+
const AnimatedMeshStandardMaterial = animated('meshStandardMaterial');
|
|
200
|
+
function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springDuration, heightMultipliersRef }) {
|
|
201
|
+
const meshRef = useRef(null);
|
|
202
|
+
const startTimeRef = useRef(null);
|
|
203
|
+
const tempObject = useMemo(() => new THREE.Object3D(), []);
|
|
204
|
+
// 4 corner edges per building
|
|
205
|
+
const numEdges = buildings.length * 4;
|
|
206
|
+
// Pre-compute edge data
|
|
207
|
+
const edgeData = useMemo(() => {
|
|
208
|
+
return buildings.flatMap((data) => {
|
|
209
|
+
const { width, depth, x, z, fullHeight, staggerDelayMs, buildingIndex } = data;
|
|
210
|
+
const halfW = width / 2;
|
|
211
|
+
const halfD = depth / 2;
|
|
212
|
+
return [
|
|
213
|
+
{ x: x - halfW, z: z - halfD, fullHeight, staggerDelayMs, buildingIndex },
|
|
214
|
+
{ x: x + halfW, z: z - halfD, fullHeight, staggerDelayMs, buildingIndex },
|
|
215
|
+
{ x: x - halfW, z: z + halfD, fullHeight, staggerDelayMs, buildingIndex },
|
|
216
|
+
{ x: x + halfW, z: z + halfD, fullHeight, staggerDelayMs, buildingIndex },
|
|
217
|
+
];
|
|
218
|
+
});
|
|
219
|
+
}, [buildings]);
|
|
220
|
+
// Animate edges
|
|
221
|
+
useFrame(({ clock }) => {
|
|
222
|
+
if (!meshRef.current || edgeData.length === 0)
|
|
223
|
+
return;
|
|
224
|
+
if (startTimeRef.current === null && growProgress > 0) {
|
|
225
|
+
startTimeRef.current = clock.elapsedTime * 1000;
|
|
226
|
+
}
|
|
227
|
+
const currentTime = clock.elapsedTime * 1000;
|
|
228
|
+
const animStartTime = startTimeRef.current ?? currentTime;
|
|
229
|
+
edgeData.forEach((edge, idx) => {
|
|
230
|
+
const { x, z, fullHeight, staggerDelayMs, buildingIndex } = edge;
|
|
231
|
+
// Get height multiplier from shared ref (for collapse animation)
|
|
232
|
+
const heightMultiplier = heightMultipliersRef.current?.[buildingIndex] ?? 1;
|
|
233
|
+
// Calculate per-building animation progress
|
|
234
|
+
const elapsed = currentTime - animStartTime - staggerDelayMs;
|
|
235
|
+
let animProgress = growProgress;
|
|
236
|
+
if (growProgress > 0 && elapsed >= 0) {
|
|
237
|
+
const t = Math.min(elapsed / springDuration, 1);
|
|
238
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
239
|
+
animProgress = eased * growProgress;
|
|
240
|
+
}
|
|
241
|
+
else if (growProgress > 0 && elapsed < 0) {
|
|
242
|
+
animProgress = 0;
|
|
243
|
+
}
|
|
244
|
+
// Apply both grow animation and collapse multiplier
|
|
245
|
+
const height = animProgress * fullHeight * heightMultiplier + minHeight;
|
|
246
|
+
const yPosition = height / 2 + baseOffset;
|
|
247
|
+
tempObject.position.set(x, yPosition, z);
|
|
248
|
+
tempObject.scale.set(0.3, height, 0.3); // Thin box for edge
|
|
249
|
+
tempObject.updateMatrix();
|
|
250
|
+
meshRef.current.setMatrixAt(idx, tempObject.matrix);
|
|
251
|
+
});
|
|
252
|
+
meshRef.current.instanceMatrix.needsUpdate = true;
|
|
253
|
+
});
|
|
254
|
+
if (numEdges === 0)
|
|
255
|
+
return null;
|
|
256
|
+
return (_jsxs("instancedMesh", { ref: meshRef, args: [undefined, undefined, numEdges], frustumCulled: false, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx("meshBasicMaterial", { color: "#1a1a2e", transparent: true, opacity: 0.7 })] }));
|
|
257
|
+
}
|
|
258
|
+
// Helper to check if a path is inside a directory
|
|
259
|
+
function isPathInDirectory(path, directory) {
|
|
260
|
+
if (!directory)
|
|
261
|
+
return true;
|
|
262
|
+
return path === directory || path.startsWith(directory + '/');
|
|
263
|
+
}
|
|
264
|
+
function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hoveredIndex, growProgress, animationConfig, heightScaling, linearScale, staggerIndices, focusDirectory, highlightLayers, isolationMode, }) {
|
|
139
265
|
const meshRef = useRef(null);
|
|
140
266
|
const startTimeRef = useRef(null);
|
|
141
267
|
const tempObject = useMemo(() => new THREE.Object3D(), []);
|
|
142
268
|
const tempColor = useMemo(() => new THREE.Color(), []);
|
|
269
|
+
// Track animated height multipliers for each building (for collapse animation)
|
|
270
|
+
const heightMultipliersRef = useRef(null);
|
|
271
|
+
const targetMultipliersRef = useRef(null);
|
|
272
|
+
// Check if highlight layers have any active items
|
|
273
|
+
const hasActiveHighlightLayers = useMemo(() => {
|
|
274
|
+
return highlightLayers.some(layer => layer.enabled && layer.items.length > 0);
|
|
275
|
+
}, [highlightLayers]);
|
|
276
|
+
// Initialize height multiplier arrays
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
if (buildings.length > 0) {
|
|
279
|
+
if (!heightMultipliersRef.current || heightMultipliersRef.current.length !== buildings.length) {
|
|
280
|
+
heightMultipliersRef.current = new Float32Array(buildings.length).fill(1);
|
|
281
|
+
targetMultipliersRef.current = new Float32Array(buildings.length).fill(1);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}, [buildings.length]);
|
|
285
|
+
// Update target multipliers when focusDirectory or highlightLayers change
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
if (!targetMultipliersRef.current)
|
|
288
|
+
return;
|
|
289
|
+
buildings.forEach((building, index) => {
|
|
290
|
+
let shouldCollapse = false;
|
|
291
|
+
// Priority 1: focusDirectory - collapse buildings outside
|
|
292
|
+
if (focusDirectory) {
|
|
293
|
+
const isInFocus = isPathInDirectory(building.path, focusDirectory);
|
|
294
|
+
shouldCollapse = !isInFocus;
|
|
295
|
+
}
|
|
296
|
+
// Priority 2: highlightLayers with collapse isolation mode
|
|
297
|
+
else if (hasActiveHighlightLayers && isolationMode === 'collapse') {
|
|
298
|
+
const highlight = getHighlightForPath(building.path, highlightLayers);
|
|
299
|
+
shouldCollapse = highlight === null;
|
|
300
|
+
}
|
|
301
|
+
targetMultipliersRef.current[index] = shouldCollapse ? 0.05 : 1;
|
|
302
|
+
});
|
|
303
|
+
}, [focusDirectory, buildings, highlightLayers, isolationMode, hasActiveHighlightLayers]);
|
|
143
304
|
// Pre-compute building data
|
|
144
305
|
const buildingData = useMemo(() => {
|
|
145
306
|
return buildings.map((building, index) => {
|
|
146
307
|
const [width, , depth] = building.dimensions;
|
|
147
|
-
const highlight = getHighlightForPath(building.path, highlightLayers);
|
|
148
|
-
const isHighlighted = highlight !== null;
|
|
149
|
-
const shouldDim = hasActiveHighlights && !isHighlighted;
|
|
150
|
-
const shouldCollapse = shouldDim && isolationMode === 'collapse';
|
|
151
|
-
const shouldHide = shouldDim && isolationMode === 'hide';
|
|
152
308
|
const fullHeight = calculateBuildingHeight(building, heightScaling, linearScale);
|
|
153
|
-
const
|
|
154
|
-
const baseColor = getColorForFile(building);
|
|
155
|
-
const color = isHighlighted ? highlight.color : baseColor;
|
|
309
|
+
const color = getColorForFile(building);
|
|
156
310
|
const x = building.position.x - centerOffset.x;
|
|
157
311
|
const z = building.position.z - centerOffset.z;
|
|
158
312
|
const staggerIndex = staggerIndices[index] ?? index;
|
|
@@ -162,39 +316,30 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
|
|
|
162
316
|
index,
|
|
163
317
|
width,
|
|
164
318
|
depth,
|
|
165
|
-
|
|
319
|
+
fullHeight,
|
|
166
320
|
color,
|
|
167
321
|
x,
|
|
168
322
|
z,
|
|
169
|
-
shouldHide,
|
|
170
|
-
shouldDim,
|
|
171
323
|
staggerDelayMs,
|
|
172
|
-
isHighlighted,
|
|
173
324
|
};
|
|
174
325
|
});
|
|
175
|
-
}, [
|
|
176
|
-
buildings,
|
|
177
|
-
centerOffset,
|
|
178
|
-
highlightLayers,
|
|
179
|
-
hasActiveHighlights,
|
|
180
|
-
isolationMode,
|
|
181
|
-
heightScaling,
|
|
182
|
-
linearScale,
|
|
183
|
-
staggerIndices,
|
|
184
|
-
animationConfig.staggerDelay,
|
|
185
|
-
]);
|
|
186
|
-
const visibleBuildings = useMemo(() => buildingData.filter((b) => !b.shouldHide), [buildingData]);
|
|
326
|
+
}, [buildings, centerOffset, heightScaling, linearScale, staggerIndices, animationConfig.staggerDelay]);
|
|
187
327
|
const minHeight = 0.3;
|
|
188
328
|
const baseOffset = 0.2;
|
|
189
329
|
const tension = animationConfig.tension || 120;
|
|
190
330
|
const friction = animationConfig.friction || 14;
|
|
191
331
|
const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
|
|
332
|
+
// Initialize all buildings (only on first render or when building data changes)
|
|
333
|
+
// DO NOT include focusDirectory here - that would bypass the animation
|
|
334
|
+
const initializedRef = useRef(false);
|
|
192
335
|
useEffect(() => {
|
|
193
|
-
if (!meshRef.current)
|
|
336
|
+
if (!meshRef.current || buildingData.length === 0)
|
|
194
337
|
return;
|
|
195
|
-
|
|
196
|
-
const { width, depth, x, z, color,
|
|
197
|
-
|
|
338
|
+
buildingData.forEach((data, instanceIndex) => {
|
|
339
|
+
const { width, depth, x, z, color, fullHeight } = data;
|
|
340
|
+
// Use the current animated multiplier, or default to 1 on first render
|
|
341
|
+
const multiplier = heightMultipliersRef.current?.[instanceIndex] ?? 1;
|
|
342
|
+
const height = growProgress * fullHeight * multiplier + minHeight;
|
|
198
343
|
const yPosition = height / 2 + baseOffset;
|
|
199
344
|
tempObject.position.set(x, yPosition, z);
|
|
200
345
|
tempObject.scale.set(width, height, depth);
|
|
@@ -207,24 +352,29 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
|
|
|
207
352
|
if (meshRef.current.instanceColor) {
|
|
208
353
|
meshRef.current.instanceColor.needsUpdate = true;
|
|
209
354
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
tempObject,
|
|
214
|
-
tempColor,
|
|
215
|
-
minHeight,
|
|
216
|
-
baseOffset,
|
|
217
|
-
]);
|
|
355
|
+
initializedRef.current = true;
|
|
356
|
+
}, [buildingData, growProgress, tempObject, tempColor, minHeight, baseOffset]);
|
|
357
|
+
// Animate buildings each frame
|
|
218
358
|
useFrame(({ clock }) => {
|
|
219
|
-
if (!meshRef.current)
|
|
359
|
+
if (!meshRef.current || buildingData.length === 0)
|
|
360
|
+
return;
|
|
361
|
+
if (!heightMultipliersRef.current || !targetMultipliersRef.current)
|
|
220
362
|
return;
|
|
221
363
|
if (startTimeRef.current === null && growProgress > 0) {
|
|
222
364
|
startTimeRef.current = clock.elapsedTime * 1000;
|
|
223
365
|
}
|
|
224
366
|
const currentTime = clock.elapsedTime * 1000;
|
|
225
367
|
const animStartTime = startTimeRef.current ?? currentTime;
|
|
226
|
-
|
|
227
|
-
|
|
368
|
+
// Animation speed for collapse/expand (lerp factor per frame)
|
|
369
|
+
const collapseSpeed = 0.08;
|
|
370
|
+
buildingData.forEach((data, instanceIndex) => {
|
|
371
|
+
const { width, depth, fullHeight, x, z, staggerDelayMs } = data;
|
|
372
|
+
// Animate height multiplier towards target
|
|
373
|
+
const currentMultiplier = heightMultipliersRef.current[instanceIndex];
|
|
374
|
+
const targetMultiplier = targetMultipliersRef.current[instanceIndex];
|
|
375
|
+
const newMultiplier = currentMultiplier + (targetMultiplier - currentMultiplier) * collapseSpeed;
|
|
376
|
+
heightMultipliersRef.current[instanceIndex] = newMultiplier;
|
|
377
|
+
// Calculate grow animation progress
|
|
228
378
|
const elapsed = currentTime - animStartTime - staggerDelayMs;
|
|
229
379
|
let animProgress = growProgress;
|
|
230
380
|
if (growProgress > 0 && elapsed >= 0) {
|
|
@@ -235,7 +385,8 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
|
|
|
235
385
|
else if (growProgress > 0 && elapsed < 0) {
|
|
236
386
|
animProgress = 0;
|
|
237
387
|
}
|
|
238
|
-
|
|
388
|
+
// Apply both grow animation and collapse multiplier
|
|
389
|
+
const height = animProgress * fullHeight * newMultiplier + minHeight;
|
|
239
390
|
const yPosition = height / 2 + baseOffset;
|
|
240
391
|
const isHovered = hoveredIndex === data.index;
|
|
241
392
|
const scale = isHovered ? 1.05 : 1;
|
|
@@ -243,10 +394,15 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
|
|
|
243
394
|
tempObject.scale.set(width * scale, height, depth * scale);
|
|
244
395
|
tempObject.updateMatrix();
|
|
245
396
|
meshRef.current.setMatrixAt(instanceIndex, tempObject.matrix);
|
|
246
|
-
|
|
397
|
+
// Desaturate collapsed buildings
|
|
247
398
|
tempColor.set(data.color);
|
|
248
|
-
if (
|
|
249
|
-
|
|
399
|
+
if (newMultiplier < 0.5) {
|
|
400
|
+
// Lerp towards gray based on collapse amount
|
|
401
|
+
const grayAmount = 1 - newMultiplier * 2; // 0 at multiplier=0.5, 1 at multiplier=0
|
|
402
|
+
const gray = 0.3;
|
|
403
|
+
tempColor.r = tempColor.r * (1 - grayAmount) + gray * grayAmount;
|
|
404
|
+
tempColor.g = tempColor.g * (1 - grayAmount) + gray * grayAmount;
|
|
405
|
+
tempColor.b = tempColor.b * (1 - grayAmount) + gray * grayAmount;
|
|
250
406
|
}
|
|
251
407
|
if (isHovered) {
|
|
252
408
|
tempColor.multiplyScalar(1.2);
|
|
@@ -260,26 +416,117 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
|
|
|
260
416
|
});
|
|
261
417
|
const handlePointerMove = useCallback((e) => {
|
|
262
418
|
e.stopPropagation();
|
|
263
|
-
if (e.instanceId !== undefined &&
|
|
264
|
-
e.instanceId
|
|
265
|
-
const data = visibleBuildings[e.instanceId];
|
|
419
|
+
if (e.instanceId !== undefined && e.instanceId < buildingData.length) {
|
|
420
|
+
const data = buildingData[e.instanceId];
|
|
266
421
|
onHover?.(data.building);
|
|
267
422
|
}
|
|
268
|
-
}, [
|
|
423
|
+
}, [buildingData, onHover]);
|
|
269
424
|
const handlePointerOut = useCallback(() => {
|
|
270
425
|
onHover?.(null);
|
|
271
426
|
}, [onHover]);
|
|
272
427
|
const handleClick = useCallback((e) => {
|
|
273
428
|
e.stopPropagation();
|
|
274
|
-
if (e.instanceId !== undefined &&
|
|
275
|
-
e.instanceId
|
|
276
|
-
const data = visibleBuildings[e.instanceId];
|
|
429
|
+
if (e.instanceId !== undefined && e.instanceId < buildingData.length) {
|
|
430
|
+
const data = buildingData[e.instanceId];
|
|
277
431
|
onClick?.(data.building);
|
|
278
432
|
}
|
|
279
|
-
}, [
|
|
280
|
-
if (
|
|
433
|
+
}, [buildingData, onClick]);
|
|
434
|
+
if (buildingData.length === 0)
|
|
281
435
|
return null;
|
|
282
|
-
return (_jsxs("instancedMesh", { ref: meshRef, args: [undefined, undefined,
|
|
436
|
+
return (_jsxs("group", { children: [_jsxs("instancedMesh", { ref: meshRef, args: [undefined, undefined, buildingData.length], onPointerMove: handlePointerMove, onPointerOut: handlePointerOut, onClick: handleClick, frustumCulled: false, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx("meshStandardMaterial", { metalness: 0.1, roughness: 0.35 })] }), _jsx(BuildingEdges, { buildings: buildingData.map(d => ({
|
|
437
|
+
width: d.width,
|
|
438
|
+
depth: d.depth,
|
|
439
|
+
fullHeight: d.fullHeight,
|
|
440
|
+
x: d.x,
|
|
441
|
+
z: d.z,
|
|
442
|
+
staggerDelayMs: d.staggerDelayMs,
|
|
443
|
+
buildingIndex: d.index,
|
|
444
|
+
})), growProgress: growProgress, minHeight: minHeight, baseOffset: baseOffset, springDuration: springDuration, heightMultipliersRef: heightMultipliersRef })] }));
|
|
445
|
+
}
|
|
446
|
+
function AnimatedIcon({ x, z, targetHeight, iconSize, texture, opacity, growProgress, staggerDelayMs, springDuration, }) {
|
|
447
|
+
const spriteRef = useRef(null);
|
|
448
|
+
const startTimeRef = useRef(null);
|
|
449
|
+
const materialRef = useRef(null);
|
|
450
|
+
useFrame(({ clock }) => {
|
|
451
|
+
if (!spriteRef.current)
|
|
452
|
+
return;
|
|
453
|
+
if (startTimeRef.current === null && growProgress > 0) {
|
|
454
|
+
startTimeRef.current = clock.elapsedTime * 1000;
|
|
455
|
+
}
|
|
456
|
+
const currentTime = clock.elapsedTime * 1000;
|
|
457
|
+
const animStartTime = startTimeRef.current ?? currentTime;
|
|
458
|
+
// Calculate per-icon animation progress
|
|
459
|
+
const elapsed = currentTime - animStartTime - staggerDelayMs;
|
|
460
|
+
let animProgress = growProgress;
|
|
461
|
+
if (growProgress > 0 && elapsed >= 0) {
|
|
462
|
+
const t = Math.min(elapsed / springDuration, 1);
|
|
463
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
464
|
+
animProgress = eased * growProgress;
|
|
465
|
+
}
|
|
466
|
+
else if (growProgress > 0 && elapsed < 0) {
|
|
467
|
+
animProgress = 0;
|
|
468
|
+
}
|
|
469
|
+
const minHeight = 0.3;
|
|
470
|
+
const baseOffset = 0.2;
|
|
471
|
+
const height = animProgress * targetHeight + minHeight;
|
|
472
|
+
const buildingTop = height + baseOffset;
|
|
473
|
+
const yPosition = buildingTop + iconSize / 2 + 2;
|
|
474
|
+
spriteRef.current.position.y = yPosition;
|
|
475
|
+
if (materialRef.current) {
|
|
476
|
+
materialRef.current.opacity = opacity * animProgress;
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
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 }) }));
|
|
480
|
+
}
|
|
481
|
+
function BuildingIcons({ buildings, centerOffset, growProgress, heightScaling, linearScale, highlightLayers, isolationMode, hasActiveHighlights, staggerIndices, springDuration, staggerDelay, }) {
|
|
482
|
+
// Pre-compute buildings with icons
|
|
483
|
+
const buildingsWithIcons = useMemo(() => {
|
|
484
|
+
return buildings
|
|
485
|
+
.map((building, index) => {
|
|
486
|
+
const config = getConfigForFile(building);
|
|
487
|
+
if (!config.icon)
|
|
488
|
+
return null;
|
|
489
|
+
const highlight = getHighlightForPath(building.path, highlightLayers);
|
|
490
|
+
const isHighlighted = highlight !== null;
|
|
491
|
+
const shouldDim = hasActiveHighlights && !isHighlighted;
|
|
492
|
+
const shouldHide = shouldDim && isolationMode === 'hide';
|
|
493
|
+
const shouldCollapse = shouldDim && isolationMode === 'collapse';
|
|
494
|
+
if (shouldHide)
|
|
495
|
+
return null;
|
|
496
|
+
const fullHeight = calculateBuildingHeight(building, heightScaling, linearScale);
|
|
497
|
+
const targetHeight = shouldCollapse ? 0.5 : fullHeight;
|
|
498
|
+
const x = building.position.x - centerOffset.x;
|
|
499
|
+
const z = building.position.z - centerOffset.z;
|
|
500
|
+
const staggerIndex = staggerIndices[index] ?? index;
|
|
501
|
+
const staggerDelayMs = staggerDelay * staggerIndex;
|
|
502
|
+
return {
|
|
503
|
+
building,
|
|
504
|
+
config,
|
|
505
|
+
x,
|
|
506
|
+
z,
|
|
507
|
+
targetHeight,
|
|
508
|
+
shouldDim,
|
|
509
|
+
staggerDelayMs,
|
|
510
|
+
};
|
|
511
|
+
})
|
|
512
|
+
.filter(Boolean);
|
|
513
|
+
}, [buildings, centerOffset, highlightLayers, isolationMode, hasActiveHighlights, heightScaling, linearScale, staggerIndices, staggerDelay]);
|
|
514
|
+
// Don't render if no progress yet
|
|
515
|
+
if (growProgress < 0.1)
|
|
516
|
+
return null;
|
|
517
|
+
return (_jsx(_Fragment, { children: buildingsWithIcons.map(({ building, config, x, z, targetHeight, shouldDim, staggerDelayMs }) => {
|
|
518
|
+
const icon = config.icon;
|
|
519
|
+
const texture = getIconTexture(icon.name, icon.color || '#ffffff');
|
|
520
|
+
if (!texture)
|
|
521
|
+
return null;
|
|
522
|
+
// Icon size based on building dimensions
|
|
523
|
+
const [width] = building.dimensions;
|
|
524
|
+
const baseSize = Math.max(width * 0.8, 6);
|
|
525
|
+
const heightBoost = Math.min(targetHeight / 20, 3);
|
|
526
|
+
const iconSize = (baseSize + heightBoost) * (icon.size || 1);
|
|
527
|
+
const opacity = shouldDim && isolationMode === 'transparent' ? 0.3 : 1;
|
|
528
|
+
return (_jsx(AnimatedIcon, { x: x, z: z, targetHeight: targetHeight, iconSize: iconSize, texture: texture, opacity: opacity, growProgress: growProgress, staggerDelayMs: staggerDelayMs, springDuration: springDuration }, building.path));
|
|
529
|
+
}) }));
|
|
283
530
|
}
|
|
284
531
|
function DistrictFloor({ district, centerOffset, opacity, }) {
|
|
285
532
|
const { worldBounds } = district;
|
|
@@ -296,10 +543,55 @@ let cameraResetFn = null;
|
|
|
296
543
|
export function resetCamera() {
|
|
297
544
|
cameraResetFn?.();
|
|
298
545
|
}
|
|
299
|
-
function AnimatedCamera({ citySize, isFlat }) {
|
|
546
|
+
function AnimatedCamera({ citySize, isFlat, focusTarget }) {
|
|
300
547
|
const { camera } = useThree();
|
|
301
548
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
302
549
|
const controlsRef = useRef(null);
|
|
550
|
+
// Animated camera position and target
|
|
551
|
+
const targetPos = useMemo(() => {
|
|
552
|
+
if (focusTarget) {
|
|
553
|
+
// Position camera to look at focus target
|
|
554
|
+
const distance = Math.max(focusTarget.size * 2, 50);
|
|
555
|
+
const height = Math.max(focusTarget.size * 1.5, 40);
|
|
556
|
+
return {
|
|
557
|
+
x: focusTarget.x,
|
|
558
|
+
y: height,
|
|
559
|
+
z: focusTarget.z + distance,
|
|
560
|
+
targetX: focusTarget.x,
|
|
561
|
+
targetY: 0,
|
|
562
|
+
targetZ: focusTarget.z,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
// Default: overview of entire city
|
|
566
|
+
const targetHeight = isFlat ? citySize * 1.5 : citySize * 1.1;
|
|
567
|
+
const targetZ = isFlat ? 0 : citySize * 1.3;
|
|
568
|
+
return {
|
|
569
|
+
x: 0,
|
|
570
|
+
y: targetHeight,
|
|
571
|
+
z: targetZ,
|
|
572
|
+
targetX: 0,
|
|
573
|
+
targetY: 0,
|
|
574
|
+
targetZ: 0,
|
|
575
|
+
};
|
|
576
|
+
}, [focusTarget, isFlat, citySize]);
|
|
577
|
+
// Spring animation for camera movement
|
|
578
|
+
const { camX, camY, camZ, lookX, lookY, lookZ } = useSpring({
|
|
579
|
+
camX: targetPos.x,
|
|
580
|
+
camY: targetPos.y,
|
|
581
|
+
camZ: targetPos.z,
|
|
582
|
+
lookX: targetPos.targetX,
|
|
583
|
+
lookY: targetPos.targetY,
|
|
584
|
+
lookZ: targetPos.targetZ,
|
|
585
|
+
config: { tension: 60, friction: 20 },
|
|
586
|
+
});
|
|
587
|
+
// Update camera each frame based on spring values
|
|
588
|
+
useFrame(() => {
|
|
589
|
+
if (!controlsRef.current)
|
|
590
|
+
return;
|
|
591
|
+
camera.position.set(camX.get(), camY.get(), camZ.get());
|
|
592
|
+
controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
|
|
593
|
+
controlsRef.current.update();
|
|
594
|
+
});
|
|
303
595
|
const resetToInitial = useCallback(() => {
|
|
304
596
|
const targetHeight = isFlat ? citySize * 1.5 : citySize * 1.1;
|
|
305
597
|
const targetZ = isFlat ? 0 : citySize * 1.3;
|
|
@@ -311,8 +603,10 @@ function AnimatedCamera({ citySize, isFlat }) {
|
|
|
311
603
|
}
|
|
312
604
|
}, [isFlat, citySize, camera]);
|
|
313
605
|
useEffect(() => {
|
|
314
|
-
|
|
315
|
-
|
|
606
|
+
if (!focusTarget) {
|
|
607
|
+
resetToInitial();
|
|
608
|
+
}
|
|
609
|
+
}, [resetToInitial, focusTarget]);
|
|
316
610
|
useEffect(() => {
|
|
317
611
|
cameraResetFn = resetToInitial;
|
|
318
612
|
return () => {
|
|
@@ -371,13 +665,132 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera, }) {
|
|
|
371
665
|
gap: 8,
|
|
372
666
|
}, children: [_jsx("button", { onClick: onResetCamera, style: buttonStyle, children: "Reset View" }), _jsx("button", { onClick: onToggle, style: buttonStyle, children: isFlat ? 'Grow to 3D' : 'Flatten to 2D' })] }));
|
|
373
667
|
}
|
|
374
|
-
function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, growProgress, animationConfig, highlightLayers, isolationMode,
|
|
668
|
+
function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, }) {
|
|
375
669
|
const centerOffset = useMemo(() => ({
|
|
376
670
|
x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
|
|
377
671
|
z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
|
|
378
672
|
}), [cityData.bounds]);
|
|
379
673
|
const citySize = Math.max(cityData.bounds.maxX - cityData.bounds.minX, cityData.bounds.maxZ - cityData.bounds.minZ);
|
|
380
674
|
const activeHighlights = useMemo(() => hasActiveHighlights(highlightLayers), [highlightLayers]);
|
|
675
|
+
// Helper to check if a path is inside a directory
|
|
676
|
+
const isPathInDirectory = useCallback((path, directory) => {
|
|
677
|
+
if (!directory)
|
|
678
|
+
return true;
|
|
679
|
+
return path === directory || path.startsWith(directory + '/');
|
|
680
|
+
}, []);
|
|
681
|
+
// Three-phase animation when switching directories:
|
|
682
|
+
// Phase 1: Camera zooms out to overview
|
|
683
|
+
// Phase 2: Buildings collapse/expand
|
|
684
|
+
// Phase 3: Camera zooms into new directory
|
|
685
|
+
//
|
|
686
|
+
// We track two separate states:
|
|
687
|
+
// - buildingFocusDirectory: controls which buildings are collapsed (passed to InstancedBuildings)
|
|
688
|
+
// - cameraFocusDirectory: controls camera position (used for focusTarget calculation)
|
|
689
|
+
const [buildingFocusDirectory, setBuildingFocusDirectory] = useState(null);
|
|
690
|
+
const [cameraFocusDirectory, setCameraFocusDirectory] = useState(null);
|
|
691
|
+
const prevFocusDirectoryRef = useRef(null);
|
|
692
|
+
const animationTimersRef = useRef([]);
|
|
693
|
+
useEffect(() => {
|
|
694
|
+
// Clear any pending timers
|
|
695
|
+
animationTimersRef.current.forEach(clearTimeout);
|
|
696
|
+
animationTimersRef.current = [];
|
|
697
|
+
const prevFocus = prevFocusDirectoryRef.current;
|
|
698
|
+
prevFocusDirectoryRef.current = focusDirectory;
|
|
699
|
+
// No change
|
|
700
|
+
if (focusDirectory === prevFocus)
|
|
701
|
+
return;
|
|
702
|
+
// Case 1: Going from overview to a directory (null -> dir)
|
|
703
|
+
if (prevFocus === null && focusDirectory !== null) {
|
|
704
|
+
// Phase 1: Collapse buildings immediately
|
|
705
|
+
setBuildingFocusDirectory(focusDirectory);
|
|
706
|
+
// Phase 2: After collapse settles, zoom camera in
|
|
707
|
+
const timer = setTimeout(() => {
|
|
708
|
+
setCameraFocusDirectory(focusDirectory);
|
|
709
|
+
}, 600);
|
|
710
|
+
animationTimersRef.current.push(timer);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
// Case 2: Going from a directory to overview (dir -> null)
|
|
714
|
+
if (prevFocus !== null && focusDirectory === null) {
|
|
715
|
+
// Phase 1: Zoom camera out first
|
|
716
|
+
setCameraFocusDirectory(null);
|
|
717
|
+
// Phase 2: After zoom-out settles, expand buildings
|
|
718
|
+
const timer = setTimeout(() => {
|
|
719
|
+
setBuildingFocusDirectory(null);
|
|
720
|
+
}, 500);
|
|
721
|
+
animationTimersRef.current.push(timer);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
// Case 3: Switching between directories (dirA -> dirB)
|
|
725
|
+
if (prevFocus !== null && focusDirectory !== null) {
|
|
726
|
+
// Phase 1: Zoom camera out
|
|
727
|
+
setCameraFocusDirectory(null);
|
|
728
|
+
// Phase 2: After zoom-out, collapse/expand buildings
|
|
729
|
+
const timer1 = setTimeout(() => {
|
|
730
|
+
setBuildingFocusDirectory(focusDirectory);
|
|
731
|
+
}, 500);
|
|
732
|
+
// Phase 3: After collapse settles, zoom camera into new directory
|
|
733
|
+
const timer2 = setTimeout(() => {
|
|
734
|
+
setCameraFocusDirectory(focusDirectory);
|
|
735
|
+
}, 1100); // 500ms zoom-out + 600ms collapse
|
|
736
|
+
animationTimersRef.current.push(timer1, timer2);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
}, [focusDirectory]);
|
|
740
|
+
// Cleanup timers on unmount
|
|
741
|
+
useEffect(() => {
|
|
742
|
+
return () => {
|
|
743
|
+
animationTimersRef.current.forEach(clearTimeout);
|
|
744
|
+
};
|
|
745
|
+
}, []);
|
|
746
|
+
// Calculate focus target from cameraFocusDirectory (for camera)
|
|
747
|
+
const focusTarget = useMemo(() => {
|
|
748
|
+
// Use camera focus directory for camera movement
|
|
749
|
+
if (cameraFocusDirectory) {
|
|
750
|
+
const focusedBuildings = cityData.buildings.filter((building) => isPathInDirectory(building.path, cameraFocusDirectory));
|
|
751
|
+
if (focusedBuildings.length === 0)
|
|
752
|
+
return null;
|
|
753
|
+
let minX = Infinity, maxX = -Infinity;
|
|
754
|
+
let minZ = Infinity, maxZ = -Infinity;
|
|
755
|
+
for (const building of focusedBuildings) {
|
|
756
|
+
const x = building.position.x - centerOffset.x;
|
|
757
|
+
const z = building.position.z - centerOffset.z;
|
|
758
|
+
const [width, , depth] = building.dimensions;
|
|
759
|
+
minX = Math.min(minX, x - width / 2);
|
|
760
|
+
maxX = Math.max(maxX, x + width / 2);
|
|
761
|
+
minZ = Math.min(minZ, z - depth / 2);
|
|
762
|
+
maxZ = Math.max(maxZ, z + depth / 2);
|
|
763
|
+
}
|
|
764
|
+
const centerX = (minX + maxX) / 2;
|
|
765
|
+
const centerZ = (minZ + maxZ) / 2;
|
|
766
|
+
const size = Math.max(maxX - minX, maxZ - minZ);
|
|
767
|
+
return { x: centerX, z: centerZ, size };
|
|
768
|
+
}
|
|
769
|
+
// Priority 2: highlight layers
|
|
770
|
+
if (!activeHighlights)
|
|
771
|
+
return null;
|
|
772
|
+
const highlightedBuildings = cityData.buildings.filter((building) => {
|
|
773
|
+
const highlight = getHighlightForPath(building.path, highlightLayers);
|
|
774
|
+
return highlight !== null;
|
|
775
|
+
});
|
|
776
|
+
if (highlightedBuildings.length === 0)
|
|
777
|
+
return null;
|
|
778
|
+
let minX = Infinity, maxX = -Infinity;
|
|
779
|
+
let minZ = Infinity, maxZ = -Infinity;
|
|
780
|
+
for (const building of highlightedBuildings) {
|
|
781
|
+
const x = building.position.x - centerOffset.x;
|
|
782
|
+
const z = building.position.z - centerOffset.z;
|
|
783
|
+
const [width, , depth] = building.dimensions;
|
|
784
|
+
minX = Math.min(minX, x - width / 2);
|
|
785
|
+
maxX = Math.max(maxX, x + width / 2);
|
|
786
|
+
minZ = Math.min(minZ, z - depth / 2);
|
|
787
|
+
maxZ = Math.max(maxZ, z + depth / 2);
|
|
788
|
+
}
|
|
789
|
+
const centerX = (minX + maxX) / 2;
|
|
790
|
+
const centerZ = (minZ + maxZ) / 2;
|
|
791
|
+
const size = Math.max(maxX - minX, maxZ - minZ);
|
|
792
|
+
return { x: centerX, z: centerZ, size };
|
|
793
|
+
}, [cameraFocusDirectory, activeHighlights, cityData.buildings, highlightLayers, centerOffset, isPathInDirectory]);
|
|
381
794
|
const staggerIndices = useMemo(() => {
|
|
382
795
|
const centerX = (cityData.bounds.minX + cityData.bounds.maxX) / 2;
|
|
383
796
|
const centerZ = (cityData.bounds.minZ + cityData.bounds.maxZ) / 2;
|
|
@@ -398,7 +811,11 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
398
811
|
return null;
|
|
399
812
|
return cityData.buildings.findIndex((b) => b.path === hoveredBuilding.path);
|
|
400
813
|
}, [hoveredBuilding, cityData.buildings]);
|
|
401
|
-
|
|
814
|
+
// Calculate spring duration for animation sync
|
|
815
|
+
const tension = animationConfig.tension || 120;
|
|
816
|
+
const friction = animationConfig.friction || 14;
|
|
817
|
+
const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
|
|
818
|
+
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 })] }));
|
|
402
819
|
}
|
|
403
820
|
/**
|
|
404
821
|
* FileCity3D - 3D visualization of codebase structure
|
|
@@ -406,7 +823,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
406
823
|
* Renders CityData as an interactive 3D city where buildings represent files
|
|
407
824
|
* and their height corresponds to line count or file size.
|
|
408
825
|
*/
|
|
409
|
-
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, }) {
|
|
826
|
+
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, }) {
|
|
410
827
|
const { theme } = useTheme();
|
|
411
828
|
const [hoveredBuilding, setHoveredBuilding] = useState(null);
|
|
412
829
|
const [internalIsGrown, setInternalIsGrown] = useState(false);
|
|
@@ -477,6 +894,6 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
|
|
|
477
894
|
left: 0,
|
|
478
895
|
width: '100%',
|
|
479
896
|
height: '100%',
|
|
480
|
-
}, children: _jsx(CityScene, { cityData: cityData, onBuildingHover: setHoveredBuilding, onBuildingClick: onBuildingClick, hoveredBuilding: hoveredBuilding, growProgress: growProgress, animationConfig: animationConfig, highlightLayers: highlightLayers, isolationMode: isolationMode,
|
|
897
|
+
}, 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 }))] }));
|
|
481
898
|
}
|
|
482
899
|
export default FileCity3D;
|