@principal-ai/file-city-react 0.5.8 → 0.5.10

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.
@@ -7,22 +7,11 @@
7
7
  * Supports animated transition from 2D (flat) to 3D (grown buildings).
8
8
  */
9
9
 
10
- import React, {
11
- useMemo,
12
- useRef,
13
- useState,
14
- useEffect,
15
- useCallback,
16
- } from 'react';
10
+ import React, { useMemo, useRef, useState, useEffect, useCallback } from 'react';
17
11
  import { Canvas, useFrame, ThreeEvent, useThree } from '@react-three/fiber';
18
- import { useTheme } from '@principal-ade/industry-theme';
12
+
19
13
  import { animated, useSpring, config } from '@react-spring/three';
20
- import {
21
- OrbitControls,
22
- PerspectiveCamera,
23
- Text,
24
- RoundedBox,
25
- } from '@react-three/drei';
14
+ import { OrbitControls, PerspectiveCamera, Text, RoundedBox } from '@react-three/drei';
26
15
  import { getFileConfig } from '@principal-ai/file-city-builder';
27
16
  import type {
28
17
  CityData,
@@ -159,7 +148,7 @@ function isCodeFile(extension: string): boolean {
159
148
  function calculateBuildingHeight(
160
149
  building: CityBuilding,
161
150
  scaling: HeightScaling = 'logarithmic',
162
- linearScale: number = 0.05
151
+ linearScale: number = 0.05,
163
152
  ): number {
164
153
  const minHeight = 2;
165
154
 
@@ -194,15 +183,23 @@ function calculateBuildingHeight(
194
183
  const LUCIDE_ICONS: Record<string, string> = {
195
184
  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"/>',
196
185
  Lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
197
- 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"/>',
186
+ EyeOff:
187
+ '<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"/>',
198
188
  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"/>',
199
- 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"/>',
200
- 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"/>',
201
- 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"/>',
202
- 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"/>',
203
- 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"/>',
204
- 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"/>',
205
- 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"/>',
189
+ GitBranch:
190
+ '<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"/>',
191
+ TestTube:
192
+ '<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"/>',
193
+ FlaskConical:
194
+ '<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"/>',
195
+ BookText:
196
+ '<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"/>',
197
+ BookOpen:
198
+ '<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"/>',
199
+ ScrollText:
200
+ '<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"/>',
201
+ Settings:
202
+ '<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"/>',
206
203
  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"/>',
207
204
  };
208
205
 
@@ -283,7 +280,7 @@ function getColorForFile(building: CityBuilding): string {
283
280
  */
284
281
  function getHighlightForPath(
285
282
  path: string,
286
- layers: HighlightLayer[]
283
+ layers: HighlightLayer[],
287
284
  ): { color: string; opacity: number } | null {
288
285
  for (const layer of layers) {
289
286
  if (!layer.enabled) continue;
@@ -292,10 +289,7 @@ function getHighlightForPath(
292
289
  if (item.type === 'file' && item.path === path) {
293
290
  return { color: layer.color, opacity: layer.opacity ?? 1 };
294
291
  }
295
- if (
296
- item.type === 'directory' &&
297
- (path === item.path || path.startsWith(item.path + '/'))
298
- ) {
292
+ if (item.type === 'directory' && (path === item.path || path.startsWith(item.path + '/'))) {
299
293
  return { color: layer.color, opacity: layer.opacity ?? 1 };
300
294
  }
301
295
  }
@@ -304,12 +298,15 @@ function getHighlightForPath(
304
298
  }
305
299
 
306
300
  function hasActiveHighlights(layers: HighlightLayer[]): boolean {
307
- return layers.some((layer) => layer.enabled && layer.items.length > 0);
301
+ return layers.some(layer => layer.enabled && layer.items.length > 0);
308
302
  }
309
303
 
310
304
  // Animated RoundedBox wrapper
311
305
  const AnimatedRoundedBox = animated(RoundedBox);
312
306
 
307
+ // Animated meshStandardMaterial for opacity transitions
308
+ const AnimatedMeshStandardMaterial = animated('meshStandardMaterial');
309
+
313
310
  // ============================================================================
314
311
  // Building Edges - Batched edge rendering for performance
315
312
  // ============================================================================
@@ -317,10 +314,11 @@ const AnimatedRoundedBox = animated(RoundedBox);
317
314
  interface BuildingEdgeData {
318
315
  width: number;
319
316
  depth: number;
320
- targetHeight: number;
317
+ fullHeight: number;
321
318
  x: number;
322
319
  z: number;
323
320
  staggerDelayMs: number;
321
+ buildingIndex: number; // Index to look up height multiplier
324
322
  }
325
323
 
326
324
  interface BuildingEdgesProps {
@@ -329,9 +327,17 @@ interface BuildingEdgesProps {
329
327
  minHeight: number;
330
328
  baseOffset: number;
331
329
  springDuration: number;
330
+ heightMultipliersRef: React.MutableRefObject<Float32Array | null>;
332
331
  }
333
332
 
334
- function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springDuration }: BuildingEdgesProps) {
333
+ function BuildingEdges({
334
+ buildings,
335
+ growProgress,
336
+ minHeight,
337
+ baseOffset,
338
+ springDuration,
339
+ heightMultipliersRef,
340
+ }: BuildingEdgesProps) {
335
341
  const meshRef = useRef<THREE.InstancedMesh>(null);
336
342
  const startTimeRef = useRef<number | null>(null);
337
343
  const tempObject = useMemo(() => new THREE.Object3D(), []);
@@ -341,16 +347,16 @@ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springD
341
347
 
342
348
  // Pre-compute edge data
343
349
  const edgeData = useMemo(() => {
344
- return buildings.flatMap((data) => {
345
- const { width, depth, x, z, targetHeight, staggerDelayMs } = data;
350
+ return buildings.flatMap(data => {
351
+ const { width, depth, x, z, fullHeight, staggerDelayMs, buildingIndex } = data;
346
352
  const halfW = width / 2;
347
353
  const halfD = depth / 2;
348
354
 
349
355
  return [
350
- { x: x - halfW, z: z - halfD, targetHeight, staggerDelayMs },
351
- { x: x + halfW, z: z - halfD, targetHeight, staggerDelayMs },
352
- { x: x - halfW, z: z + halfD, targetHeight, staggerDelayMs },
353
- { x: x + halfW, z: z + halfD, targetHeight, staggerDelayMs },
356
+ { x: x - halfW, z: z - halfD, fullHeight, staggerDelayMs, buildingIndex },
357
+ { x: x + halfW, z: z - halfD, fullHeight, staggerDelayMs, buildingIndex },
358
+ { x: x - halfW, z: z + halfD, fullHeight, staggerDelayMs, buildingIndex },
359
+ { x: x + halfW, z: z + halfD, fullHeight, staggerDelayMs, buildingIndex },
354
360
  ];
355
361
  });
356
362
  }, [buildings]);
@@ -367,7 +373,10 @@ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springD
367
373
  const animStartTime = startTimeRef.current ?? currentTime;
368
374
 
369
375
  edgeData.forEach((edge, idx) => {
370
- const { x, z, targetHeight, staggerDelayMs } = edge;
376
+ const { x, z, fullHeight, staggerDelayMs, buildingIndex } = edge;
377
+
378
+ // Get height multiplier from shared ref (for collapse animation)
379
+ const heightMultiplier = heightMultipliersRef.current?.[buildingIndex] ?? 1;
371
380
 
372
381
  // Calculate per-building animation progress
373
382
  const elapsed = currentTime - animStartTime - staggerDelayMs;
@@ -381,7 +390,8 @@ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springD
381
390
  animProgress = 0;
382
391
  }
383
392
 
384
- const height = animProgress * targetHeight + minHeight;
393
+ // Apply both grow animation and collapse multiplier
394
+ const height = animProgress * fullHeight * heightMultiplier + minHeight;
385
395
  const yPosition = height / 2 + baseOffset;
386
396
 
387
397
  tempObject.position.set(x, yPosition, z);
@@ -416,13 +426,18 @@ interface InstancedBuildingsProps {
416
426
  hoveredIndex: number | null;
417
427
  growProgress: number;
418
428
  animationConfig: AnimationConfig;
419
- highlightLayers: HighlightLayer[];
420
- isolationMode: IsolationMode;
421
- hasActiveHighlights: boolean;
422
- dimOpacity: number;
423
429
  heightScaling: HeightScaling;
424
430
  linearScale: number;
425
431
  staggerIndices: number[];
432
+ focusDirectory: string | null;
433
+ highlightLayers: HighlightLayer[];
434
+ isolationMode: IsolationMode;
435
+ }
436
+
437
+ // Helper to check if a path is inside a directory
438
+ function isPathInDirectory(path: string, directory: string | null): boolean {
439
+ if (!directory) return true;
440
+ return path === directory || path.startsWith(directory + '/');
426
441
  }
427
442
 
428
443
  function InstancedBuildings({
@@ -433,91 +448,116 @@ function InstancedBuildings({
433
448
  hoveredIndex,
434
449
  growProgress,
435
450
  animationConfig,
436
- highlightLayers,
437
- isolationMode,
438
- hasActiveHighlights,
439
- dimOpacity,
440
451
  heightScaling,
441
452
  linearScale,
442
453
  staggerIndices,
454
+ focusDirectory,
455
+ highlightLayers,
456
+ isolationMode,
443
457
  }: InstancedBuildingsProps) {
444
458
  const meshRef = useRef<THREE.InstancedMesh>(null);
445
459
  const startTimeRef = useRef<number | null>(null);
446
460
  const tempObject = useMemo(() => new THREE.Object3D(), []);
447
461
  const tempColor = useMemo(() => new THREE.Color(), []);
448
462
 
463
+ // Track animated height multipliers for each building (for collapse animation)
464
+ const heightMultipliersRef = useRef<Float32Array | null>(null);
465
+ const targetMultipliersRef = useRef<Float32Array | null>(null);
466
+
467
+ // Check if highlight layers have any active items
468
+ const hasActiveHighlightLayers = useMemo(() => {
469
+ return highlightLayers.some(layer => layer.enabled && layer.items.length > 0);
470
+ }, [highlightLayers]);
471
+
472
+ // Initialize height multiplier arrays
473
+ useEffect(() => {
474
+ if (buildings.length > 0) {
475
+ if (
476
+ !heightMultipliersRef.current ||
477
+ heightMultipliersRef.current.length !== buildings.length
478
+ ) {
479
+ heightMultipliersRef.current = new Float32Array(buildings.length).fill(1);
480
+ targetMultipliersRef.current = new Float32Array(buildings.length).fill(1);
481
+ }
482
+ }
483
+ }, [buildings.length]);
484
+
485
+ // Update target multipliers when focusDirectory or highlightLayers change
486
+ useEffect(() => {
487
+ if (!targetMultipliersRef.current) return;
488
+
489
+ buildings.forEach((building, index) => {
490
+ let shouldCollapse = false;
491
+
492
+ // Priority 1: focusDirectory - collapse buildings outside
493
+ if (focusDirectory) {
494
+ const isInFocus = isPathInDirectory(building.path, focusDirectory);
495
+ shouldCollapse = !isInFocus;
496
+ }
497
+ // Priority 2: highlightLayers with collapse isolation mode
498
+ else if (hasActiveHighlightLayers && isolationMode === 'collapse') {
499
+ const highlight = getHighlightForPath(building.path, highlightLayers);
500
+ shouldCollapse = highlight === null;
501
+ }
502
+
503
+ targetMultipliersRef.current![index] = shouldCollapse ? 0.05 : 1;
504
+ });
505
+ }, [focusDirectory, buildings, highlightLayers, isolationMode, hasActiveHighlightLayers]);
506
+
449
507
  // Pre-compute building data
450
508
  const buildingData = useMemo(() => {
451
509
  return buildings.map((building, index) => {
452
510
  const [width, , depth] = building.dimensions;
453
- const highlight = getHighlightForPath(building.path, highlightLayers);
454
- const isHighlighted = highlight !== null;
455
- const shouldDim = hasActiveHighlights && !isHighlighted;
456
- const shouldCollapse = shouldDim && isolationMode === 'collapse';
457
- const shouldHide = shouldDim && isolationMode === 'hide';
458
-
459
- const fullHeight = calculateBuildingHeight(
460
- building,
461
- heightScaling,
462
- linearScale
463
- );
464
- const targetHeight = shouldCollapse ? 0.5 : fullHeight;
465
-
466
- const baseColor = getColorForFile(building);
467
- const color = isHighlighted ? highlight.color : baseColor;
511
+ const fullHeight = calculateBuildingHeight(building, heightScaling, linearScale);
512
+ const color = getColorForFile(building);
468
513
 
469
514
  const x = building.position.x - centerOffset.x;
470
515
  const z = building.position.z - centerOffset.z;
471
516
 
472
517
  const staggerIndex = staggerIndices[index] ?? index;
473
- const staggerDelayMs =
474
- (animationConfig.staggerDelay || 15) * staggerIndex;
518
+ const staggerDelayMs = (animationConfig.staggerDelay || 15) * staggerIndex;
475
519
 
476
520
  return {
477
521
  building,
478
522
  index,
479
523
  width,
480
524
  depth,
481
- targetHeight,
525
+ fullHeight,
482
526
  color,
483
527
  x,
484
528
  z,
485
- shouldHide,
486
- shouldDim,
487
529
  staggerDelayMs,
488
- isHighlighted,
489
530
  };
490
531
  });
491
532
  }, [
492
533
  buildings,
493
534
  centerOffset,
494
- highlightLayers,
495
- hasActiveHighlights,
496
- isolationMode,
497
535
  heightScaling,
498
536
  linearScale,
499
537
  staggerIndices,
500
538
  animationConfig.staggerDelay,
501
539
  ]);
502
540
 
503
- const visibleBuildings = useMemo(
504
- () => buildingData.filter((b) => !b.shouldHide),
505
- [buildingData]
506
- );
507
-
508
541
  const minHeight = 0.3;
509
542
  const baseOffset = 0.2;
510
543
  const tension = animationConfig.tension || 120;
511
544
  const friction = animationConfig.friction || 14;
512
545
  const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
513
546
 
547
+ // Initialize all buildings (only on first render or when building data changes)
548
+ // DO NOT include focusDirectory here - that would bypass the animation
549
+ const initializedRef = useRef(false);
550
+
514
551
  useEffect(() => {
515
- if (!meshRef.current) return;
552
+ if (!meshRef.current || buildingData.length === 0) return;
553
+
554
+ buildingData.forEach((data, instanceIndex) => {
555
+ const { width, depth, x, z, color, fullHeight } = data;
516
556
 
517
- visibleBuildings.forEach((data, instanceIndex) => {
518
- const { width, depth, x, z, color, targetHeight } = data;
557
+ // Use the current animated multiplier, or default to 1 on first render
558
+ const multiplier = heightMultipliersRef.current?.[instanceIndex] ?? 1;
519
559
 
520
- const height = growProgress * targetHeight + minHeight;
560
+ const height = growProgress * fullHeight * multiplier + minHeight;
521
561
  const yPosition = height / 2 + baseOffset;
522
562
 
523
563
  tempObject.position.set(x, yPosition, z);
@@ -534,17 +574,14 @@ function InstancedBuildings({
534
574
  if (meshRef.current.instanceColor) {
535
575
  meshRef.current.instanceColor.needsUpdate = true;
536
576
  }
537
- }, [
538
- visibleBuildings,
539
- growProgress,
540
- tempObject,
541
- tempColor,
542
- minHeight,
543
- baseOffset,
544
- ]);
545
577
 
578
+ initializedRef.current = true;
579
+ }, [buildingData, growProgress, tempObject, tempColor, minHeight, baseOffset]);
580
+
581
+ // Animate buildings each frame
546
582
  useFrame(({ clock }) => {
547
- if (!meshRef.current) return;
583
+ if (!meshRef.current || buildingData.length === 0) return;
584
+ if (!heightMultipliersRef.current || !targetMultipliersRef.current) return;
548
585
 
549
586
  if (startTimeRef.current === null && growProgress > 0) {
550
587
  startTimeRef.current = clock.elapsedTime * 1000;
@@ -553,10 +590,20 @@ function InstancedBuildings({
553
590
  const currentTime = clock.elapsedTime * 1000;
554
591
  const animStartTime = startTimeRef.current ?? currentTime;
555
592
 
556
- visibleBuildings.forEach((data, instanceIndex) => {
557
- const { width, depth, targetHeight, x, z, staggerDelayMs, shouldDim } =
558
- data;
593
+ // Animation speed for collapse/expand (lerp factor per frame)
594
+ const collapseSpeed = 0.08;
559
595
 
596
+ buildingData.forEach((data, instanceIndex) => {
597
+ const { width, depth, fullHeight, x, z, staggerDelayMs } = data;
598
+
599
+ // Animate height multiplier towards target
600
+ const currentMultiplier = heightMultipliersRef.current![instanceIndex];
601
+ const targetMultiplier = targetMultipliersRef.current![instanceIndex];
602
+ const newMultiplier =
603
+ currentMultiplier + (targetMultiplier - currentMultiplier) * collapseSpeed;
604
+ heightMultipliersRef.current![instanceIndex] = newMultiplier;
605
+
606
+ // Calculate grow animation progress
560
607
  const elapsed = currentTime - animStartTime - staggerDelayMs;
561
608
  let animProgress = growProgress;
562
609
 
@@ -568,7 +615,8 @@ function InstancedBuildings({
568
615
  animProgress = 0;
569
616
  }
570
617
 
571
- const height = animProgress * targetHeight + minHeight;
618
+ // Apply both grow animation and collapse multiplier
619
+ const height = animProgress * fullHeight * newMultiplier + minHeight;
572
620
  const yPosition = height / 2 + baseOffset;
573
621
 
574
622
  const isHovered = hoveredIndex === data.index;
@@ -580,11 +628,15 @@ function InstancedBuildings({
580
628
 
581
629
  meshRef.current!.setMatrixAt(instanceIndex, tempObject.matrix);
582
630
 
583
- const opacity =
584
- shouldDim && isolationMode === 'transparent' ? dimOpacity : 1;
631
+ // Desaturate collapsed buildings
585
632
  tempColor.set(data.color);
586
- if (opacity < 1) {
587
- tempColor.multiplyScalar(opacity + 0.3);
633
+ if (newMultiplier < 0.5) {
634
+ // Lerp towards gray based on collapse amount
635
+ const grayAmount = 1 - newMultiplier * 2; // 0 at multiplier=0.5, 1 at multiplier=0
636
+ const gray = 0.3;
637
+ tempColor.r = tempColor.r * (1 - grayAmount) + gray * grayAmount;
638
+ tempColor.g = tempColor.g * (1 - grayAmount) + gray * grayAmount;
639
+ tempColor.b = tempColor.b * (1 - grayAmount) + gray * grayAmount;
588
640
  }
589
641
  if (isHovered) {
590
642
  tempColor.multiplyScalar(1.2);
@@ -601,15 +653,12 @@ function InstancedBuildings({
601
653
  const handlePointerMove = useCallback(
602
654
  (e: ThreeEvent<PointerEvent>) => {
603
655
  e.stopPropagation();
604
- if (
605
- e.instanceId !== undefined &&
606
- e.instanceId < visibleBuildings.length
607
- ) {
608
- const data = visibleBuildings[e.instanceId];
656
+ if (e.instanceId !== undefined && e.instanceId < buildingData.length) {
657
+ const data = buildingData[e.instanceId];
609
658
  onHover?.(data.building);
610
659
  }
611
660
  },
612
- [visibleBuildings, onHover]
661
+ [buildingData, onHover],
613
662
  );
614
663
 
615
664
  const handlePointerOut = useCallback(() => {
@@ -619,25 +668,22 @@ function InstancedBuildings({
619
668
  const handleClick = useCallback(
620
669
  (e: ThreeEvent<MouseEvent>) => {
621
670
  e.stopPropagation();
622
- if (
623
- e.instanceId !== undefined &&
624
- e.instanceId < visibleBuildings.length
625
- ) {
626
- const data = visibleBuildings[e.instanceId];
671
+ if (e.instanceId !== undefined && e.instanceId < buildingData.length) {
672
+ const data = buildingData[e.instanceId];
627
673
  onClick?.(data.building);
628
674
  }
629
675
  },
630
- [visibleBuildings, onClick]
676
+ [buildingData, onClick],
631
677
  );
632
678
 
633
- if (visibleBuildings.length === 0) return null;
679
+ if (buildingData.length === 0) return null;
634
680
 
635
681
  return (
636
682
  <group>
637
- {/* Main building meshes */}
683
+ {/* All buildings - single mesh, original colors */}
638
684
  <instancedMesh
639
685
  ref={meshRef}
640
- args={[undefined, undefined, visibleBuildings.length]}
686
+ args={[undefined, undefined, buildingData.length]}
641
687
  onPointerMove={handlePointerMove}
642
688
  onPointerOut={handlePointerOut}
643
689
  onClick={handleClick}
@@ -647,13 +693,22 @@ function InstancedBuildings({
647
693
  <meshStandardMaterial metalness={0.1} roughness={0.35} />
648
694
  </instancedMesh>
649
695
 
650
- {/* Building edge outlines - batched into single geometry for performance */}
696
+ {/* Building edge outlines */}
651
697
  <BuildingEdges
652
- buildings={visibleBuildings}
698
+ buildings={buildingData.map(d => ({
699
+ width: d.width,
700
+ depth: d.depth,
701
+ fullHeight: d.fullHeight,
702
+ x: d.x,
703
+ z: d.z,
704
+ staggerDelayMs: d.staggerDelayMs,
705
+ buildingIndex: d.index,
706
+ }))}
653
707
  growProgress={growProgress}
654
708
  minHeight={minHeight}
655
709
  baseOffset={baseOffset}
656
710
  springDuration={springDuration}
711
+ heightMultipliersRef={heightMultipliersRef}
657
712
  />
658
713
  </group>
659
714
  );
@@ -741,11 +796,7 @@ function AnimatedIcon({
741
796
  });
742
797
 
743
798
  return (
744
- <sprite
745
- ref={spriteRef}
746
- position={[x, 0, z]}
747
- scale={[iconSize, iconSize, 1]}
748
- >
799
+ <sprite ref={spriteRef} position={[x, 0, z]} scale={[iconSize, iconSize, 1]}>
749
800
  <spriteMaterial
750
801
  ref={materialRef}
751
802
  map={texture}
@@ -806,49 +857,61 @@ function BuildingIcons({
806
857
  };
807
858
  })
808
859
  .filter(Boolean) as Array<{
809
- building: CityBuilding;
810
- config: FileConfigResult;
811
- x: number;
812
- z: number;
813
- targetHeight: number;
814
- shouldDim: boolean;
815
- staggerDelayMs: number;
816
- }>;
817
- }, [buildings, centerOffset, highlightLayers, isolationMode, hasActiveHighlights, heightScaling, linearScale, staggerIndices, staggerDelay]);
860
+ building: CityBuilding;
861
+ config: FileConfigResult;
862
+ x: number;
863
+ z: number;
864
+ targetHeight: number;
865
+ shouldDim: boolean;
866
+ staggerDelayMs: number;
867
+ }>;
868
+ }, [
869
+ buildings,
870
+ centerOffset,
871
+ highlightLayers,
872
+ isolationMode,
873
+ hasActiveHighlights,
874
+ heightScaling,
875
+ linearScale,
876
+ staggerIndices,
877
+ staggerDelay,
878
+ ]);
818
879
 
819
880
  // Don't render if no progress yet
820
881
  if (growProgress < 0.1) return null;
821
882
 
822
883
  return (
823
884
  <>
824
- {buildingsWithIcons.map(({ building, config, x, z, targetHeight, shouldDim, staggerDelayMs }) => {
825
- const icon = config.icon!;
826
- const texture = getIconTexture(icon.name, icon.color || '#ffffff');
827
- if (!texture) return null;
828
-
829
- // Icon size based on building dimensions
830
- const [width] = building.dimensions;
831
- const baseSize = Math.max(width * 0.8, 6);
832
- const heightBoost = Math.min(targetHeight / 20, 3);
833
- const iconSize = (baseSize + heightBoost) * (icon.size || 1);
834
-
835
- const opacity = shouldDim && isolationMode === 'transparent' ? 0.3 : 1;
836
-
837
- return (
838
- <AnimatedIcon
839
- key={building.path}
840
- x={x}
841
- z={z}
842
- targetHeight={targetHeight}
843
- iconSize={iconSize}
844
- texture={texture}
845
- opacity={opacity}
846
- growProgress={growProgress}
847
- staggerDelayMs={staggerDelayMs}
848
- springDuration={springDuration}
849
- />
850
- );
851
- })}
885
+ {buildingsWithIcons.map(
886
+ ({ building, config, x, z, targetHeight, shouldDim, staggerDelayMs }) => {
887
+ const icon = config.icon!;
888
+ const texture = getIconTexture(icon.name, icon.color || '#ffffff');
889
+ if (!texture) return null;
890
+
891
+ // Icon size based on building dimensions
892
+ const [width] = building.dimensions;
893
+ const baseSize = Math.max(width * 0.8, 6);
894
+ const heightBoost = Math.min(targetHeight / 20, 3);
895
+ const iconSize = (baseSize + heightBoost) * (icon.size || 1);
896
+
897
+ const opacity = shouldDim && isolationMode === 'transparent' ? 0.3 : 1;
898
+
899
+ return (
900
+ <AnimatedIcon
901
+ key={building.path}
902
+ x={x}
903
+ z={z}
904
+ targetHeight={targetHeight}
905
+ iconSize={iconSize}
906
+ texture={texture}
907
+ opacity={opacity}
908
+ growProgress={growProgress}
909
+ staggerDelayMs={staggerDelayMs}
910
+ springDuration={springDuration}
911
+ />
912
+ );
913
+ },
914
+ )}
852
915
  </>
853
916
  );
854
917
  }
@@ -860,11 +923,7 @@ interface DistrictFloorProps {
860
923
  opacity: number;
861
924
  }
862
925
 
863
- function DistrictFloor({
864
- district,
865
- centerOffset,
866
- opacity,
867
- }: DistrictFloorProps) {
926
+ function DistrictFloor({ district, centerOffset, opacity }: DistrictFloorProps) {
868
927
  const { worldBounds } = district;
869
928
  const width = worldBounds.maxX - worldBounds.minX;
870
929
  const depth = worldBounds.maxZ - worldBounds.minZ;
@@ -878,15 +937,8 @@ function DistrictFloor({
878
937
 
879
938
  return (
880
939
  <group position={[centerX, 0, centerZ]}>
881
- <lineSegments
882
- rotation={[-Math.PI / 2, 0, 0]}
883
- position={[0, floorY, 0]}
884
- renderOrder={-1}
885
- >
886
- <edgesGeometry
887
- args={[new THREE.PlaneGeometry(width, depth)]}
888
- attach="geometry"
889
- />
940
+ <lineSegments rotation={[-Math.PI / 2, 0, 0]} position={[0, floorY, 0]} renderOrder={-1}>
941
+ <edgesGeometry args={[new THREE.PlaneGeometry(width, depth)]} attach="geometry" />
890
942
  <lineBasicMaterial color="#475569" depthWrite={false} />
891
943
  </lineSegments>
892
944
 
@@ -909,9 +961,16 @@ function DistrictFloor({
909
961
  }
910
962
 
911
963
  // Camera controller
964
+ interface FocusTarget {
965
+ x: number;
966
+ z: number;
967
+ size: number; // Approximate size of the focused area
968
+ }
969
+
912
970
  interface AnimatedCameraProps {
913
971
  citySize: number;
914
972
  isFlat: boolean;
973
+ focusTarget?: FocusTarget | null;
915
974
  }
916
975
 
917
976
  let cameraResetFn: (() => void) | null = null;
@@ -920,11 +979,59 @@ export function resetCamera() {
920
979
  cameraResetFn?.();
921
980
  }
922
981
 
923
- function AnimatedCamera({ citySize, isFlat }: AnimatedCameraProps) {
982
+ function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps) {
924
983
  const { camera } = useThree();
925
984
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
926
985
  const controlsRef = useRef<any>(null);
927
986
 
987
+ // Animated camera position and target
988
+ const targetPos = useMemo(() => {
989
+ if (focusTarget) {
990
+ // Position camera to look at focus target
991
+ const distance = Math.max(focusTarget.size * 2, 50);
992
+ const height = Math.max(focusTarget.size * 1.5, 40);
993
+ return {
994
+ x: focusTarget.x,
995
+ y: height,
996
+ z: focusTarget.z + distance,
997
+ targetX: focusTarget.x,
998
+ targetY: 0,
999
+ targetZ: focusTarget.z,
1000
+ };
1001
+ }
1002
+ // Default: overview of entire city
1003
+ const targetHeight = isFlat ? citySize * 1.5 : citySize * 1.1;
1004
+ const targetZ = isFlat ? 0 : citySize * 1.3;
1005
+ return {
1006
+ x: 0,
1007
+ y: targetHeight,
1008
+ z: targetZ,
1009
+ targetX: 0,
1010
+ targetY: 0,
1011
+ targetZ: 0,
1012
+ };
1013
+ }, [focusTarget, isFlat, citySize]);
1014
+
1015
+ // Spring animation for camera movement
1016
+ const { camX, camY, camZ, lookX, lookY, lookZ } = useSpring({
1017
+ camX: targetPos.x,
1018
+ camY: targetPos.y,
1019
+ camZ: targetPos.z,
1020
+ lookX: targetPos.targetX,
1021
+ lookY: targetPos.targetY,
1022
+ lookZ: targetPos.targetZ,
1023
+ config: { tension: 60, friction: 20 },
1024
+ });
1025
+
1026
+ // Update camera each frame based on spring values
1027
+ useFrame(() => {
1028
+ if (!controlsRef.current) return;
1029
+
1030
+ camera.position.set(camX.get(), camY.get(), camZ.get());
1031
+ controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
1032
+ controlsRef.current.update();
1033
+ });
1034
+
928
1035
  const resetToInitial = useCallback(() => {
929
1036
  const targetHeight = isFlat ? citySize * 1.5 : citySize * 1.1;
930
1037
  const targetZ = isFlat ? 0 : citySize * 1.3;
@@ -939,8 +1046,10 @@ function AnimatedCamera({ citySize, isFlat }: AnimatedCameraProps) {
939
1046
  }, [isFlat, citySize, camera]);
940
1047
 
941
1048
  useEffect(() => {
942
- resetToInitial();
943
- }, [resetToInitial]);
1049
+ if (!focusTarget) {
1050
+ resetToInitial();
1051
+ }
1052
+ }, [resetToInitial, focusTarget]);
944
1053
 
945
1054
  useEffect(() => {
946
1055
  cameraResetFn = resetToInitial;
@@ -974,8 +1083,7 @@ function InfoPanel({ building }: InfoPanelProps) {
974
1083
 
975
1084
  const fileName = building.path.split('/').pop();
976
1085
  const dirPath = building.path.split('/').slice(0, -1).join('/');
977
- const rawExt =
978
- building.fileExtension || building.path.split('.').pop() || '';
1086
+ const rawExt = building.fileExtension || building.path.split('.').pop() || '';
979
1087
  const ext = rawExt.replace(/^\./, '');
980
1088
  const isCode = isCodeFile(ext);
981
1089
 
@@ -1010,9 +1118,7 @@ function InfoPanel({ building }: InfoPanelProps) {
1010
1118
  {building.lineCount !== undefined && (
1011
1119
  <span>{building.lineCount.toLocaleString()} lines</span>
1012
1120
  )}
1013
- {building.size !== undefined && (
1014
- <span>{(building.size / 1024).toFixed(1)} KB</span>
1015
- )}
1121
+ {building.size !== undefined && <span>{(building.size / 1024).toFixed(1)} KB</span>}
1016
1122
  </div>
1017
1123
  </div>
1018
1124
  );
@@ -1025,11 +1131,7 @@ interface ControlsOverlayProps {
1025
1131
  onResetCamera: () => void;
1026
1132
  }
1027
1133
 
1028
- function ControlsOverlay({
1029
- isFlat,
1030
- onToggle,
1031
- onResetCamera,
1032
- }: ControlsOverlayProps) {
1134
+ function ControlsOverlay({ isFlat, onToggle, onResetCamera }: ControlsOverlayProps) {
1033
1135
  const buttonStyle = {
1034
1136
  background: 'rgba(15, 23, 42, 0.9)',
1035
1137
  border: '1px solid #334155',
@@ -1073,9 +1175,9 @@ interface CitySceneProps {
1073
1175
  animationConfig: AnimationConfig;
1074
1176
  highlightLayers: HighlightLayer[];
1075
1177
  isolationMode: IsolationMode;
1076
- dimOpacity: number;
1077
1178
  heightScaling: HeightScaling;
1078
1179
  linearScale: number;
1180
+ focusDirectory: string | null;
1079
1181
  }
1080
1182
 
1081
1183
  function CityScene({
@@ -1087,27 +1189,177 @@ function CityScene({
1087
1189
  animationConfig,
1088
1190
  highlightLayers,
1089
1191
  isolationMode,
1090
- dimOpacity,
1091
1192
  heightScaling,
1092
1193
  linearScale,
1194
+ focusDirectory,
1093
1195
  }: CitySceneProps) {
1094
1196
  const centerOffset = useMemo(
1095
1197
  () => ({
1096
1198
  x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
1097
1199
  z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
1098
1200
  }),
1099
- [cityData.bounds]
1201
+ [cityData.bounds],
1100
1202
  );
1101
1203
 
1102
1204
  const citySize = Math.max(
1103
1205
  cityData.bounds.maxX - cityData.bounds.minX,
1104
- cityData.bounds.maxZ - cityData.bounds.minZ
1206
+ cityData.bounds.maxZ - cityData.bounds.minZ,
1105
1207
  );
1106
1208
 
1107
- const activeHighlights = useMemo(
1108
- () => hasActiveHighlights(highlightLayers),
1109
- [highlightLayers]
1110
- );
1209
+ const activeHighlights = useMemo(() => hasActiveHighlights(highlightLayers), [highlightLayers]);
1210
+
1211
+ // Helper to check if a path is inside a directory
1212
+ const isPathInDirectory = useCallback((path: string, directory: string) => {
1213
+ if (!directory) return true;
1214
+ return path === directory || path.startsWith(directory + '/');
1215
+ }, []);
1216
+
1217
+ // Three-phase animation when switching directories:
1218
+ // Phase 1: Camera zooms out to overview
1219
+ // Phase 2: Buildings collapse/expand
1220
+ // Phase 3: Camera zooms into new directory
1221
+ //
1222
+ // We track two separate states:
1223
+ // - buildingFocusDirectory: controls which buildings are collapsed (passed to InstancedBuildings)
1224
+ // - cameraFocusDirectory: controls camera position (used for focusTarget calculation)
1225
+ const [buildingFocusDirectory, setBuildingFocusDirectory] = useState<string | null>(null);
1226
+ const [cameraFocusDirectory, setCameraFocusDirectory] = useState<string | null>(null);
1227
+ const prevFocusDirectoryRef = useRef<string | null>(null);
1228
+ const animationTimersRef = useRef<NodeJS.Timeout[]>([]);
1229
+
1230
+ useEffect(() => {
1231
+ // Clear any pending timers
1232
+ animationTimersRef.current.forEach(clearTimeout);
1233
+ animationTimersRef.current = [];
1234
+
1235
+ const prevFocus = prevFocusDirectoryRef.current;
1236
+ prevFocusDirectoryRef.current = focusDirectory;
1237
+
1238
+ // No change
1239
+ if (focusDirectory === prevFocus) return;
1240
+
1241
+ // Case 1: Going from overview to a directory (null -> dir)
1242
+ if (prevFocus === null && focusDirectory !== null) {
1243
+ // Phase 1: Collapse buildings immediately
1244
+ setBuildingFocusDirectory(focusDirectory);
1245
+ // Phase 2: After collapse settles, zoom camera in
1246
+ const timer = setTimeout(() => {
1247
+ setCameraFocusDirectory(focusDirectory);
1248
+ }, 600);
1249
+ animationTimersRef.current.push(timer);
1250
+ return;
1251
+ }
1252
+
1253
+ // Case 2: Going from a directory to overview (dir -> null)
1254
+ if (prevFocus !== null && focusDirectory === null) {
1255
+ // Phase 1: Zoom camera out first
1256
+ setCameraFocusDirectory(null);
1257
+ // Phase 2: After zoom-out settles, expand buildings
1258
+ const timer = setTimeout(() => {
1259
+ setBuildingFocusDirectory(null);
1260
+ }, 500);
1261
+ animationTimersRef.current.push(timer);
1262
+ return;
1263
+ }
1264
+
1265
+ // Case 3: Switching between directories (dirA -> dirB)
1266
+ if (prevFocus !== null && focusDirectory !== null) {
1267
+ // Phase 1: Zoom camera out
1268
+ setCameraFocusDirectory(null);
1269
+ // Phase 2: After zoom-out, collapse/expand buildings
1270
+ const timer1 = setTimeout(() => {
1271
+ setBuildingFocusDirectory(focusDirectory);
1272
+ }, 500);
1273
+ // Phase 3: After collapse settles, zoom camera into new directory
1274
+ const timer2 = setTimeout(() => {
1275
+ setCameraFocusDirectory(focusDirectory);
1276
+ }, 1100); // 500ms zoom-out + 600ms collapse
1277
+ animationTimersRef.current.push(timer1, timer2);
1278
+ return;
1279
+ }
1280
+ }, [focusDirectory]);
1281
+
1282
+ // Cleanup timers on unmount
1283
+ useEffect(() => {
1284
+ return () => {
1285
+ animationTimersRef.current.forEach(clearTimeout);
1286
+ };
1287
+ }, []);
1288
+
1289
+ // Calculate focus target from cameraFocusDirectory (for camera)
1290
+ const focusTarget = useMemo((): FocusTarget | null => {
1291
+ // Use camera focus directory for camera movement
1292
+ if (cameraFocusDirectory) {
1293
+ const focusedBuildings = cityData.buildings.filter(building =>
1294
+ isPathInDirectory(building.path, cameraFocusDirectory),
1295
+ );
1296
+
1297
+ if (focusedBuildings.length === 0) return null;
1298
+
1299
+ let minX = Infinity,
1300
+ maxX = -Infinity;
1301
+ let minZ = Infinity,
1302
+ maxZ = -Infinity;
1303
+
1304
+ for (const building of focusedBuildings) {
1305
+ const x = building.position.x - centerOffset.x;
1306
+ const z = building.position.z - centerOffset.z;
1307
+ const [width, , depth] = building.dimensions;
1308
+
1309
+ minX = Math.min(minX, x - width / 2);
1310
+ maxX = Math.max(maxX, x + width / 2);
1311
+ minZ = Math.min(minZ, z - depth / 2);
1312
+ maxZ = Math.max(maxZ, z + depth / 2);
1313
+ }
1314
+
1315
+ const centerX = (minX + maxX) / 2;
1316
+ const centerZ = (minZ + maxZ) / 2;
1317
+ const size = Math.max(maxX - minX, maxZ - minZ);
1318
+
1319
+ return { x: centerX, z: centerZ, size };
1320
+ }
1321
+
1322
+ // Priority 2: highlight layers (only if no focusDirectory is pending)
1323
+ // Don't focus on highlights if we're waiting for cameraFocusDirectory to catch up
1324
+ if (!activeHighlights || focusDirectory) return null;
1325
+
1326
+ const highlightedBuildings = cityData.buildings.filter(building => {
1327
+ const highlight = getHighlightForPath(building.path, highlightLayers);
1328
+ return highlight !== null;
1329
+ });
1330
+
1331
+ if (highlightedBuildings.length === 0) return null;
1332
+
1333
+ let minX = Infinity,
1334
+ maxX = -Infinity;
1335
+ let minZ = Infinity,
1336
+ maxZ = -Infinity;
1337
+
1338
+ for (const building of highlightedBuildings) {
1339
+ const x = building.position.x - centerOffset.x;
1340
+ const z = building.position.z - centerOffset.z;
1341
+ const [width, , depth] = building.dimensions;
1342
+
1343
+ minX = Math.min(minX, x - width / 2);
1344
+ maxX = Math.max(maxX, x + width / 2);
1345
+ minZ = Math.min(minZ, z - depth / 2);
1346
+ maxZ = Math.max(maxZ, z + depth / 2);
1347
+ }
1348
+
1349
+ const centerX = (minX + maxX) / 2;
1350
+ const centerZ = (minZ + maxZ) / 2;
1351
+ const size = Math.max(maxX - minX, maxZ - minZ);
1352
+
1353
+ return { x: centerX, z: centerZ, size };
1354
+ }, [
1355
+ cameraFocusDirectory,
1356
+ focusDirectory,
1357
+ activeHighlights,
1358
+ cityData.buildings,
1359
+ highlightLayers,
1360
+ centerOffset,
1361
+ isPathInDirectory,
1362
+ ]);
1111
1363
 
1112
1364
  const staggerIndices = useMemo(() => {
1113
1365
  const centerX = (cityData.bounds.minX + cityData.bounds.maxX) / 2;
@@ -1116,8 +1368,7 @@ function CityScene({
1116
1368
  const withDistance = cityData.buildings.map((b, originalIndex) => ({
1117
1369
  originalIndex,
1118
1370
  distance: Math.sqrt(
1119
- Math.pow(b.position.x - centerX, 2) +
1120
- Math.pow(b.position.z - centerZ, 2)
1371
+ Math.pow(b.position.x - centerX, 2) + Math.pow(b.position.z - centerZ, 2),
1121
1372
  ),
1122
1373
  }));
1123
1374
 
@@ -1133,7 +1384,7 @@ function CityScene({
1133
1384
 
1134
1385
  const hoveredIndex = useMemo(() => {
1135
1386
  if (!hoveredBuilding) return null;
1136
- return cityData.buildings.findIndex((b) => b.path === hoveredBuilding.path);
1387
+ return cityData.buildings.findIndex(b => b.path === hoveredBuilding.path);
1137
1388
  }, [hoveredBuilding, cityData.buildings]);
1138
1389
 
1139
1390
  // Calculate spring duration for animation sync
@@ -1143,13 +1394,10 @@ function CityScene({
1143
1394
 
1144
1395
  return (
1145
1396
  <>
1146
- <AnimatedCamera citySize={citySize} isFlat={growProgress === 0} />
1397
+ <AnimatedCamera citySize={citySize} isFlat={growProgress === 0} focusTarget={focusTarget} />
1147
1398
 
1148
1399
  <ambientLight intensity={1.2} />
1149
- <hemisphereLight
1150
- args={['#ddeeff', '#667788', 0.8]}
1151
- position={[0, citySize, 0]}
1152
- />
1400
+ <hemisphereLight args={['#ddeeff', '#667788', 0.8]} position={[0, citySize, 0]} />
1153
1401
  <directionalLight
1154
1402
  position={[citySize, citySize * 1.5, citySize * 0.5]}
1155
1403
  intensity={2}
@@ -1160,12 +1408,9 @@ function CityScene({
1160
1408
  position={[-citySize * 0.5, citySize * 0.8, -citySize * 0.5]}
1161
1409
  intensity={1}
1162
1410
  />
1163
- <directionalLight
1164
- position={[citySize * 0.3, citySize, citySize]}
1165
- intensity={0.6}
1166
- />
1411
+ <directionalLight position={[citySize * 0.3, citySize, citySize]} intensity={0.6} />
1167
1412
 
1168
- {cityData.districts.map((district) => (
1413
+ {cityData.districts.map(district => (
1169
1414
  <DistrictFloor
1170
1415
  key={district.path}
1171
1416
  district={district}
@@ -1182,13 +1427,12 @@ function CityScene({
1182
1427
  hoveredIndex={hoveredIndex}
1183
1428
  growProgress={growProgress}
1184
1429
  animationConfig={animationConfig}
1185
- highlightLayers={highlightLayers}
1186
- isolationMode={isolationMode}
1187
- hasActiveHighlights={activeHighlights}
1188
- dimOpacity={dimOpacity}
1189
1430
  heightScaling={heightScaling}
1190
1431
  linearScale={linearScale}
1191
1432
  staggerIndices={staggerIndices}
1433
+ focusDirectory={buildingFocusDirectory}
1434
+ highlightLayers={highlightLayers}
1435
+ isolationMode={isolationMode}
1192
1436
  />
1193
1437
 
1194
1438
  <BuildingIcons
@@ -1249,6 +1493,14 @@ export interface FileCity3DProps {
1249
1493
  heightScaling?: HeightScaling;
1250
1494
  /** Scale factor for linear mode (height per line, default 0.05) */
1251
1495
  linearScale?: number;
1496
+ /** Directory path to focus on - buildings outside will collapse */
1497
+ focusDirectory?: string | null;
1498
+ /** Callback when user clicks on a district to navigate */
1499
+ onDirectorySelect?: (directory: string | null) => void;
1500
+ /** Background color for the canvas container */
1501
+ backgroundColor?: string;
1502
+ /** Text color for secondary/placeholder text */
1503
+ textColor?: string;
1252
1504
  }
1253
1505
 
1254
1506
  /**
@@ -1276,20 +1528,17 @@ export function FileCity3D({
1276
1528
  emptyMessage = 'No file tree data available',
1277
1529
  heightScaling = 'logarithmic',
1278
1530
  linearScale = 0.05,
1531
+ focusDirectory = null,
1532
+ onDirectorySelect,
1533
+ backgroundColor = '#0f172a',
1534
+ textColor = '#94a3b8',
1279
1535
  }: FileCity3DProps) {
1280
- const { theme } = useTheme();
1281
- const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(
1282
- null
1283
- );
1536
+ const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(null);
1284
1537
  const [internalIsGrown, setInternalIsGrown] = useState(false);
1285
1538
 
1286
- const animationConfig = useMemo(
1287
- () => ({ ...DEFAULT_ANIMATION, ...animation }),
1288
- [animation]
1289
- );
1539
+ const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
1290
1540
 
1291
- const isGrown =
1292
- externalIsGrown !== undefined ? externalIsGrown : internalIsGrown;
1541
+ const isGrown = externalIsGrown !== undefined ? externalIsGrown : internalIsGrown;
1293
1542
  const setIsGrown = (value: boolean) => {
1294
1543
  setInternalIsGrown(value);
1295
1544
  onGrowChange?.(value);
@@ -1321,12 +1570,12 @@ export function FileCity3D({
1321
1570
  width,
1322
1571
  height,
1323
1572
  position: 'relative',
1324
- background: theme.colors.background,
1573
+ background: backgroundColor,
1325
1574
  overflow: 'hidden',
1326
1575
  display: 'flex',
1327
1576
  alignItems: 'center',
1328
1577
  justifyContent: 'center',
1329
- color: theme.colors.textSecondary,
1578
+ color: textColor,
1330
1579
  fontFamily: 'system-ui, sans-serif',
1331
1580
  fontSize: 14,
1332
1581
  ...style,
@@ -1345,12 +1594,12 @@ export function FileCity3D({
1345
1594
  width,
1346
1595
  height,
1347
1596
  position: 'relative',
1348
- background: theme.colors.background,
1597
+ background: backgroundColor,
1349
1598
  overflow: 'hidden',
1350
1599
  display: 'flex',
1351
1600
  alignItems: 'center',
1352
1601
  justifyContent: 'center',
1353
- color: theme.colors.textSecondary,
1602
+ color: textColor,
1354
1603
  fontFamily: 'system-ui, sans-serif',
1355
1604
  fontSize: 14,
1356
1605
  ...style,
@@ -1368,7 +1617,7 @@ export function FileCity3D({
1368
1617
  width,
1369
1618
  height,
1370
1619
  position: 'relative',
1371
- background: theme.colors.background,
1620
+ background: backgroundColor,
1372
1621
  overflow: 'hidden',
1373
1622
  ...style,
1374
1623
  }}
@@ -1392,18 +1641,14 @@ export function FileCity3D({
1392
1641
  animationConfig={animationConfig}
1393
1642
  highlightLayers={highlightLayers}
1394
1643
  isolationMode={isolationMode}
1395
- dimOpacity={dimOpacity}
1396
1644
  heightScaling={heightScaling}
1397
1645
  linearScale={linearScale}
1646
+ focusDirectory={focusDirectory}
1398
1647
  />
1399
1648
  </Canvas>
1400
1649
  <InfoPanel building={hoveredBuilding} />
1401
1650
  {showControls && (
1402
- <ControlsOverlay
1403
- isFlat={!isGrown}
1404
- onToggle={handleToggle}
1405
- onResetCamera={resetCamera}
1406
- />
1651
+ <ControlsOverlay isFlat={!isGrown} onToggle={handleToggle} onResetCamera={resetCamera} />
1407
1652
  )}
1408
1653
  </div>
1409
1654
  );