@principal-ai/file-city-react 0.5.8 → 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.
@@ -92,6 +92,10 @@ export interface FileCity3DProps {
92
92
  heightScaling?: HeightScaling;
93
93
  /** Scale factor for linear mode (height per line, default 0.05) */
94
94
  linearScale?: number;
95
+ /** Directory path to focus on - buildings outside will collapse */
96
+ focusDirectory?: string | null;
97
+ /** Callback when user clicks on a district to navigate */
98
+ onDirectorySelect?: (directory: string | null) => void;
95
99
  }
96
100
  /**
97
101
  * FileCity3D - 3D visualization of codebase structure
@@ -99,6 +103,6 @@ export interface FileCity3DProps {
99
103
  * Renders CityData as an interactive 3D city where buildings represent files
100
104
  * and their height corresponds to line count or file size.
101
105
  */
102
- export declare function FileCity3D({ cityData, width, height, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls, highlightLayers, isolationMode, dimOpacity, isLoading, loadingMessage, emptyMessage, heightScaling, linearScale, }: FileCity3DProps): import("react/jsx-runtime").JSX.Element;
106
+ export declare function FileCity3D({ cityData, width, height, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls, highlightLayers, isolationMode, dimOpacity, isLoading, loadingMessage, emptyMessage, heightScaling, linearScale, focusDirectory, onDirectorySelect, }: FileCity3DProps): import("react/jsx-runtime").JSX.Element;
103
107
  export default FileCity3D;
104
108
  //# sourceMappingURL=FileCity3D.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"FileCity3D.d.ts","sourceRoot":"","sources":["../../../src/components/FileCity3D/FileCity3D.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"FileCity3D.d.ts","sourceRoot":"","sources":["../../../src/components/FileCity3D/FileCity3D.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAMN,MAAM,OAAO,CAAC;AAWf,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;AA02BrD,wBAAgB,WAAW,SAE1B;AAmeD,MAAM,WAAW,eAAe;IAC9B,uCAAuC;IACvC,QAAQ,EAAE,QAAQ,CAAC;IACnB,6BAA6B;IAC7B,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,8BAA8B;IAC9B,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,0CAA0C;IAC1C,eAAe,CAAC,EAAE,CAAC,QAAQ,EAAE,YAAY,KAAK,IAAI,CAAC;IACnD,qBAAqB;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oBAAoB;IACpB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAC5B,8BAA8B;IAC9B,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,wEAAwE;IACxE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,uCAAuC;IACvC,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IAC1C,2BAA2B;IAC3B,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,kEAAkE;IAClE,eAAe,CAAC,EAAE,cAAc,EAAE,CAAC;IACnC,yEAAyE;IACzE,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,6DAA6D;IAC7D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wCAAwC;IACxC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,uCAAuC;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,8CAA8C;IAC9C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,+DAA+D;IAC/D,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,mEAAmE;IACnE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mEAAmE;IACnE,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,0DAA0D;IAC1D,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;CACxD;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,KAAc,EACd,MAAY,EACZ,eAAe,EACf,SAAS,EACT,KAAK,EACL,SAAS,EACT,OAAO,EAAE,eAAe,EACxB,YAAY,EACZ,YAAmB,EACnB,eAAoB,EACpB,aAA6B,EAC7B,UAAiB,EACjB,SAAiB,EACjB,cAAuC,EACvC,YAA4C,EAC5C,aAA6B,EAC7B,WAAkB,EAClB,cAAqB,EACrB,iBAAiB,GAClB,EAAE,eAAe,2CAmIjB;AAED,eAAe,UAAU,CAAC"}
@@ -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';
@@ -195,7 +195,9 @@ function hasActiveHighlights(layers) {
195
195
  }
196
196
  // Animated RoundedBox wrapper
197
197
  const AnimatedRoundedBox = animated(RoundedBox);
198
- function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springDuration }) {
198
+ // Animated meshStandardMaterial for opacity transitions
199
+ const AnimatedMeshStandardMaterial = animated('meshStandardMaterial');
200
+ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springDuration, heightMultipliersRef }) {
199
201
  const meshRef = useRef(null);
200
202
  const startTimeRef = useRef(null);
201
203
  const tempObject = useMemo(() => new THREE.Object3D(), []);
@@ -204,14 +206,14 @@ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springD
204
206
  // Pre-compute edge data
205
207
  const edgeData = useMemo(() => {
206
208
  return buildings.flatMap((data) => {
207
- const { width, depth, x, z, targetHeight, staggerDelayMs } = data;
209
+ const { width, depth, x, z, fullHeight, staggerDelayMs, buildingIndex } = data;
208
210
  const halfW = width / 2;
209
211
  const halfD = depth / 2;
210
212
  return [
211
- { x: x - halfW, z: z - halfD, targetHeight, staggerDelayMs },
212
- { x: x + halfW, z: z - halfD, targetHeight, staggerDelayMs },
213
- { x: x - halfW, z: z + halfD, targetHeight, staggerDelayMs },
214
- { x: x + halfW, z: z + halfD, targetHeight, staggerDelayMs },
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 },
215
217
  ];
216
218
  });
217
219
  }, [buildings]);
@@ -225,7 +227,9 @@ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springD
225
227
  const currentTime = clock.elapsedTime * 1000;
226
228
  const animStartTime = startTimeRef.current ?? currentTime;
227
229
  edgeData.forEach((edge, idx) => {
228
- const { x, z, targetHeight, staggerDelayMs } = edge;
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;
229
233
  // Calculate per-building animation progress
230
234
  const elapsed = currentTime - animStartTime - staggerDelayMs;
231
235
  let animProgress = growProgress;
@@ -237,7 +241,8 @@ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springD
237
241
  else if (growProgress > 0 && elapsed < 0) {
238
242
  animProgress = 0;
239
243
  }
240
- const height = animProgress * targetHeight + minHeight;
244
+ // Apply both grow animation and collapse multiplier
245
+ const height = animProgress * fullHeight * heightMultiplier + minHeight;
241
246
  const yPosition = height / 2 + baseOffset;
242
247
  tempObject.position.set(x, yPosition, z);
243
248
  tempObject.scale.set(0.3, height, 0.3); // Thin box for edge
@@ -250,24 +255,58 @@ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springD
250
255
  return null;
251
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 })] }));
252
257
  }
253
- function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hoveredIndex, growProgress, animationConfig, highlightLayers, isolationMode, hasActiveHighlights, dimOpacity, heightScaling, linearScale, staggerIndices, }) {
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, }) {
254
265
  const meshRef = useRef(null);
255
266
  const startTimeRef = useRef(null);
256
267
  const tempObject = useMemo(() => new THREE.Object3D(), []);
257
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]);
258
304
  // Pre-compute building data
259
305
  const buildingData = useMemo(() => {
260
306
  return buildings.map((building, index) => {
261
307
  const [width, , depth] = building.dimensions;
262
- const highlight = getHighlightForPath(building.path, highlightLayers);
263
- const isHighlighted = highlight !== null;
264
- const shouldDim = hasActiveHighlights && !isHighlighted;
265
- const shouldCollapse = shouldDim && isolationMode === 'collapse';
266
- const shouldHide = shouldDim && isolationMode === 'hide';
267
308
  const fullHeight = calculateBuildingHeight(building, heightScaling, linearScale);
268
- const targetHeight = shouldCollapse ? 0.5 : fullHeight;
269
- const baseColor = getColorForFile(building);
270
- const color = isHighlighted ? highlight.color : baseColor;
309
+ const color = getColorForFile(building);
271
310
  const x = building.position.x - centerOffset.x;
272
311
  const z = building.position.z - centerOffset.z;
273
312
  const staggerIndex = staggerIndices[index] ?? index;
@@ -277,39 +316,30 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
277
316
  index,
278
317
  width,
279
318
  depth,
280
- targetHeight,
319
+ fullHeight,
281
320
  color,
282
321
  x,
283
322
  z,
284
- shouldHide,
285
- shouldDim,
286
323
  staggerDelayMs,
287
- isHighlighted,
288
324
  };
289
325
  });
290
- }, [
291
- buildings,
292
- centerOffset,
293
- highlightLayers,
294
- hasActiveHighlights,
295
- isolationMode,
296
- heightScaling,
297
- linearScale,
298
- staggerIndices,
299
- animationConfig.staggerDelay,
300
- ]);
301
- const visibleBuildings = useMemo(() => buildingData.filter((b) => !b.shouldHide), [buildingData]);
326
+ }, [buildings, centerOffset, heightScaling, linearScale, staggerIndices, animationConfig.staggerDelay]);
302
327
  const minHeight = 0.3;
303
328
  const baseOffset = 0.2;
304
329
  const tension = animationConfig.tension || 120;
305
330
  const friction = animationConfig.friction || 14;
306
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);
307
335
  useEffect(() => {
308
- if (!meshRef.current)
336
+ if (!meshRef.current || buildingData.length === 0)
309
337
  return;
310
- visibleBuildings.forEach((data, instanceIndex) => {
311
- const { width, depth, x, z, color, targetHeight } = data;
312
- 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;
313
343
  const yPosition = height / 2 + baseOffset;
314
344
  tempObject.position.set(x, yPosition, z);
315
345
  tempObject.scale.set(width, height, depth);
@@ -322,24 +352,29 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
322
352
  if (meshRef.current.instanceColor) {
323
353
  meshRef.current.instanceColor.needsUpdate = true;
324
354
  }
325
- }, [
326
- visibleBuildings,
327
- growProgress,
328
- tempObject,
329
- tempColor,
330
- minHeight,
331
- baseOffset,
332
- ]);
355
+ initializedRef.current = true;
356
+ }, [buildingData, growProgress, tempObject, tempColor, minHeight, baseOffset]);
357
+ // Animate buildings each frame
333
358
  useFrame(({ clock }) => {
334
- if (!meshRef.current)
359
+ if (!meshRef.current || buildingData.length === 0)
360
+ return;
361
+ if (!heightMultipliersRef.current || !targetMultipliersRef.current)
335
362
  return;
336
363
  if (startTimeRef.current === null && growProgress > 0) {
337
364
  startTimeRef.current = clock.elapsedTime * 1000;
338
365
  }
339
366
  const currentTime = clock.elapsedTime * 1000;
340
367
  const animStartTime = startTimeRef.current ?? currentTime;
341
- visibleBuildings.forEach((data, instanceIndex) => {
342
- 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
343
378
  const elapsed = currentTime - animStartTime - staggerDelayMs;
344
379
  let animProgress = growProgress;
345
380
  if (growProgress > 0 && elapsed >= 0) {
@@ -350,7 +385,8 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
350
385
  else if (growProgress > 0 && elapsed < 0) {
351
386
  animProgress = 0;
352
387
  }
353
- const height = animProgress * targetHeight + minHeight;
388
+ // Apply both grow animation and collapse multiplier
389
+ const height = animProgress * fullHeight * newMultiplier + minHeight;
354
390
  const yPosition = height / 2 + baseOffset;
355
391
  const isHovered = hoveredIndex === data.index;
356
392
  const scale = isHovered ? 1.05 : 1;
@@ -358,10 +394,15 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
358
394
  tempObject.scale.set(width * scale, height, depth * scale);
359
395
  tempObject.updateMatrix();
360
396
  meshRef.current.setMatrixAt(instanceIndex, tempObject.matrix);
361
- const opacity = shouldDim && isolationMode === 'transparent' ? dimOpacity : 1;
397
+ // Desaturate collapsed buildings
362
398
  tempColor.set(data.color);
363
- if (opacity < 1) {
364
- 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;
365
406
  }
366
407
  if (isHovered) {
367
408
  tempColor.multiplyScalar(1.2);
@@ -375,26 +416,32 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
375
416
  });
376
417
  const handlePointerMove = useCallback((e) => {
377
418
  e.stopPropagation();
378
- if (e.instanceId !== undefined &&
379
- e.instanceId < visibleBuildings.length) {
380
- const data = visibleBuildings[e.instanceId];
419
+ if (e.instanceId !== undefined && e.instanceId < buildingData.length) {
420
+ const data = buildingData[e.instanceId];
381
421
  onHover?.(data.building);
382
422
  }
383
- }, [visibleBuildings, onHover]);
423
+ }, [buildingData, onHover]);
384
424
  const handlePointerOut = useCallback(() => {
385
425
  onHover?.(null);
386
426
  }, [onHover]);
387
427
  const handleClick = useCallback((e) => {
388
428
  e.stopPropagation();
389
- if (e.instanceId !== undefined &&
390
- e.instanceId < visibleBuildings.length) {
391
- const data = visibleBuildings[e.instanceId];
429
+ if (e.instanceId !== undefined && e.instanceId < buildingData.length) {
430
+ const data = buildingData[e.instanceId];
392
431
  onClick?.(data.building);
393
432
  }
394
- }, [visibleBuildings, onClick]);
395
- if (visibleBuildings.length === 0)
433
+ }, [buildingData, onClick]);
434
+ if (buildingData.length === 0)
396
435
  return null;
397
- return (_jsxs("group", { children: [_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 })] }), _jsx(BuildingEdges, { buildings: visibleBuildings, growProgress: growProgress, minHeight: minHeight, baseOffset: baseOffset, springDuration: springDuration })] }));
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 })] }));
398
445
  }
399
446
  function AnimatedIcon({ x, z, targetHeight, iconSize, texture, opacity, growProgress, staggerDelayMs, springDuration, }) {
400
447
  const spriteRef = useRef(null);
@@ -496,10 +543,55 @@ let cameraResetFn = null;
496
543
  export function resetCamera() {
497
544
  cameraResetFn?.();
498
545
  }
499
- function AnimatedCamera({ citySize, isFlat }) {
546
+ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
500
547
  const { camera } = useThree();
501
548
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
502
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
+ });
503
595
  const resetToInitial = useCallback(() => {
504
596
  const targetHeight = isFlat ? citySize * 1.5 : citySize * 1.1;
505
597
  const targetZ = isFlat ? 0 : citySize * 1.3;
@@ -511,8 +603,10 @@ function AnimatedCamera({ citySize, isFlat }) {
511
603
  }
512
604
  }, [isFlat, citySize, camera]);
513
605
  useEffect(() => {
514
- resetToInitial();
515
- }, [resetToInitial]);
606
+ if (!focusTarget) {
607
+ resetToInitial();
608
+ }
609
+ }, [resetToInitial, focusTarget]);
516
610
  useEffect(() => {
517
611
  cameraResetFn = resetToInitial;
518
612
  return () => {
@@ -571,13 +665,132 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera, }) {
571
665
  gap: 8,
572
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' })] }));
573
667
  }
574
- 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, }) {
575
669
  const centerOffset = useMemo(() => ({
576
670
  x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
577
671
  z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
578
672
  }), [cityData.bounds]);
579
673
  const citySize = Math.max(cityData.bounds.maxX - cityData.bounds.minX, cityData.bounds.maxZ - cityData.bounds.minZ);
580
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]);
581
794
  const staggerIndices = useMemo(() => {
582
795
  const centerX = (cityData.bounds.minX + cityData.bounds.maxX) / 2;
583
796
  const centerZ = (cityData.bounds.minZ + cityData.bounds.maxZ) / 2;
@@ -602,7 +815,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
602
815
  const tension = animationConfig.tension || 120;
603
816
  const friction = animationConfig.friction || 14;
604
817
  const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
605
- return (_jsxs(_Fragment, { children: [_jsx(AnimatedCamera, { citySize: citySize, isFlat: growProgress === 0 }), _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, highlightLayers: highlightLayers, isolationMode: isolationMode, hasActiveHighlights: activeHighlights, dimOpacity: dimOpacity, heightScaling: heightScaling, linearScale: linearScale, staggerIndices: staggerIndices }), _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 })] }));
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 })] }));
606
819
  }
607
820
  /**
608
821
  * FileCity3D - 3D visualization of codebase structure
@@ -610,7 +823,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
610
823
  * Renders CityData as an interactive 3D city where buildings represent files
611
824
  * and their height corresponds to line count or file size.
612
825
  */
613
- 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, }) {
614
827
  const { theme } = useTheme();
615
828
  const [hoveredBuilding, setHoveredBuilding] = useState(null);
616
829
  const [internalIsGrown, setInternalIsGrown] = useState(false);
@@ -681,6 +894,6 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
681
894
  left: 0,
682
895
  width: '100%',
683
896
  height: '100%',
684
- }, 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 }))] }));
685
898
  }
686
899
  export default FileCity3D;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principal-ai/file-city-react",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "type": "module",
5
5
  "description": "React components for File City visualization",
6
6
  "main": "dist/index.js",