@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.
@@ -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
- function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hoveredIndex, growProgress, animationConfig, highlightLayers, isolationMode, hasActiveHighlights, dimOpacity, heightScaling, linearScale, staggerIndices, }) {
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 targetHeight = shouldCollapse ? 0.5 : fullHeight;
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
- targetHeight,
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
- visibleBuildings.forEach((data, instanceIndex) => {
196
- const { width, depth, x, z, color, targetHeight } = data;
197
- const height = growProgress * targetHeight + minHeight;
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
- visibleBuildings,
212
- growProgress,
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
- visibleBuildings.forEach((data, instanceIndex) => {
227
- const { width, depth, targetHeight, x, z, staggerDelayMs, shouldDim } = data;
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
- const height = animProgress * targetHeight + minHeight;
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
- const opacity = shouldDim && isolationMode === 'transparent' ? dimOpacity : 1;
397
+ // Desaturate collapsed buildings
247
398
  tempColor.set(data.color);
248
- if (opacity < 1) {
249
- tempColor.multiplyScalar(opacity + 0.3);
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 < visibleBuildings.length) {
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
- }, [visibleBuildings, onHover]);
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 < visibleBuildings.length) {
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
- }, [visibleBuildings, onClick]);
280
- if (visibleBuildings.length === 0)
433
+ }, [buildingData, onClick]);
434
+ if (buildingData.length === 0)
281
435
  return null;
282
- return (_jsxs("instancedMesh", { ref: meshRef, args: [undefined, undefined, visibleBuildings.length], onPointerMove: handlePointerMove, onPointerOut: handlePointerOut, onClick: handleClick, frustumCulled: false, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx("meshStandardMaterial", { metalness: 0.1, roughness: 0.35 })] }));
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
- resetToInitial();
315
- }, [resetToInitial]);
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, dimOpacity, heightScaling, linearScale, }) {
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
- return (_jsxs(_Fragment, { children: [_jsx(AnimatedCamera, { citySize: citySize, isFlat: growProgress === 0 }), _jsx("ambientLight", { intensity: 0.4 }), _jsx("directionalLight", { position: [citySize, citySize, citySize * 0.5], intensity: 1, castShadow: true, "shadow-mapSize": [2048, 2048] }), _jsx("directionalLight", { position: [-citySize * 0.5, citySize * 0.5, -citySize * 0.5], intensity: 0.3 }), 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, highlightLayers: highlightLayers, isolationMode: isolationMode, hasActiveHighlights: activeHighlights, dimOpacity: dimOpacity, heightScaling: heightScaling, linearScale: linearScale, staggerIndices: staggerIndices })] }));
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, dimOpacity: dimOpacity, heightScaling: heightScaling, linearScale: linearScale }) }), _jsx(InfoPanel, { building: hoveredBuilding }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
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;