@principal-ai/file-city-react 0.5.2 → 0.5.4

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.
@@ -0,0 +1,1011 @@
1
+ /**
2
+ * FileCity3D - 3D visualization of a codebase using React Three Fiber
3
+ *
4
+ * Renders CityData from file-city-builder as actual 3D buildings with
5
+ * camera controls, lighting, and interactivity.
6
+ *
7
+ * Supports animated transition from 2D (flat) to 3D (grown buildings).
8
+ */
9
+
10
+ import React, {
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ useEffect,
15
+ useCallback,
16
+ } from 'react';
17
+ import { Canvas, useFrame, ThreeEvent, useThree } from '@react-three/fiber';
18
+ import { useTheme } from '@principal-ade/industry-theme';
19
+ import { animated, useSpring, config } from '@react-spring/three';
20
+ import {
21
+ OrbitControls,
22
+ PerspectiveCamera,
23
+ Text,
24
+ RoundedBox,
25
+ } from '@react-three/drei';
26
+ import { getFileConfig } from '@principal-ai/file-city-builder';
27
+ import type {
28
+ CityData,
29
+ CityBuilding,
30
+ CityDistrict,
31
+ FileConfigResult,
32
+ } from '@principal-ai/file-city-builder';
33
+ import * as THREE from 'three';
34
+ import type { ThreeElements } from '@react-three/fiber';
35
+
36
+ // Extend JSX with Three.js elements
37
+ declare module 'react' {
38
+ // eslint-disable-next-line @typescript-eslint/no-namespace
39
+ namespace JSX {
40
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
41
+ interface IntrinsicElements extends ThreeElements {}
42
+ }
43
+ }
44
+
45
+ // Re-export types for convenience
46
+ export type { CityData, CityBuilding, CityDistrict };
47
+
48
+ // Highlight layer types
49
+ export interface HighlightLayer {
50
+ /** Unique identifier */
51
+ id: string;
52
+ /** Display name */
53
+ name: string;
54
+ /** Whether layer is active */
55
+ enabled: boolean;
56
+ /** Highlight color (hex) */
57
+ color: string;
58
+ /** Items to highlight */
59
+ items: HighlightItem[];
60
+ /** Opacity for highlighted items (0-1) */
61
+ opacity?: number;
62
+ }
63
+
64
+ export interface HighlightItem {
65
+ /** File or directory path */
66
+ path: string;
67
+ /** Type of item */
68
+ type: 'file' | 'directory';
69
+ }
70
+
71
+ /** What to do with non-highlighted buildings */
72
+ export type IsolationMode =
73
+ | 'none' // Show all buildings normally
74
+ | 'transparent' // Make non-highlighted buildings transparent
75
+ | 'collapse' // Flatten non-highlighted buildings to ground level
76
+ | 'hide'; // Hide non-highlighted buildings entirely
77
+
78
+ // Animation configuration
79
+ export interface AnimationConfig {
80
+ /** Start with buildings flat (2D view) */
81
+ startFlat?: boolean;
82
+ /** Auto-start the grow animation after this delay (ms). Set to null to disable. */
83
+ autoStartDelay?: number | null;
84
+ /** Duration of the grow animation in ms */
85
+ growDuration?: number;
86
+ /** Stagger delay between buildings in ms */
87
+ staggerDelay?: number;
88
+ /** Spring tension (higher = faster/snappier) */
89
+ tension?: number;
90
+ /** Spring friction (higher = less bouncy) */
91
+ friction?: number;
92
+ }
93
+
94
+ /** Height scaling mode for buildings */
95
+ export type HeightScaling = 'logarithmic' | 'linear';
96
+
97
+ const DEFAULT_ANIMATION: AnimationConfig = {
98
+ startFlat: false,
99
+ autoStartDelay: 500,
100
+ growDuration: 1500,
101
+ staggerDelay: 15,
102
+ tension: 120,
103
+ friction: 14,
104
+ };
105
+
106
+ // Code file extensions - height based on line count
107
+ const CODE_EXTENSIONS = new Set([
108
+ 'ts',
109
+ 'tsx',
110
+ 'js',
111
+ 'jsx',
112
+ 'mjs',
113
+ 'cjs',
114
+ 'py',
115
+ 'pyw',
116
+ 'rs',
117
+ 'go',
118
+ 'java',
119
+ 'kt',
120
+ 'scala',
121
+ 'c',
122
+ 'cpp',
123
+ 'cc',
124
+ 'cxx',
125
+ 'h',
126
+ 'hpp',
127
+ 'cs',
128
+ 'rb',
129
+ 'php',
130
+ 'swift',
131
+ 'vue',
132
+ 'svelte',
133
+ 'lua',
134
+ 'sh',
135
+ 'bash',
136
+ 'zsh',
137
+ 'sql',
138
+ 'r',
139
+ 'dart',
140
+ 'elm',
141
+ 'ex',
142
+ 'exs',
143
+ 'clj',
144
+ 'cljs',
145
+ 'hs',
146
+ 'ml',
147
+ 'mli',
148
+ ]);
149
+
150
+ function isCodeFile(extension: string): boolean {
151
+ return CODE_EXTENSIONS.has(extension.toLowerCase());
152
+ }
153
+
154
+ /**
155
+ * Calculate building height based on file metrics.
156
+ * - logarithmic: Compresses large values (default, good for mixed codebases)
157
+ * - linear: Direct scaling (1 line = linearScale units of height)
158
+ */
159
+ function calculateBuildingHeight(
160
+ building: CityBuilding,
161
+ scaling: HeightScaling = 'logarithmic',
162
+ linearScale: number = 0.05
163
+ ): number {
164
+ const minHeight = 2;
165
+
166
+ // Use lineCount if available (any text file), otherwise fall back to size
167
+ if (building.lineCount !== undefined) {
168
+ const lines = Math.max(building.lineCount, 1);
169
+
170
+ if (scaling === 'linear') {
171
+ return minHeight + lines * linearScale;
172
+ }
173
+ // Logarithmic: log10(10) = 1, log10(100) = 2, log10(1000) = 3
174
+ return minHeight + Math.log10(lines) * 12;
175
+ } else if (building.size !== undefined) {
176
+ const bytes = Math.max(building.size, 1);
177
+
178
+ if (scaling === 'linear') {
179
+ return minHeight + (bytes / 1024) * linearScale;
180
+ }
181
+ // Logarithmic scale based on size
182
+ return minHeight + (Math.log10(bytes) - 2) * 12;
183
+ }
184
+
185
+ // Fallback to dimension height if no metrics available
186
+ return building.dimensions[1];
187
+ }
188
+
189
+ // Get full file config from centralized file-city-builder lookup
190
+ function getConfigForFile(building: CityBuilding): FileConfigResult {
191
+ if (building.color) {
192
+ return {
193
+ color: building.color,
194
+ renderStrategy: 'fill',
195
+ opacity: 1,
196
+ matchedPattern: 'preset',
197
+ matchType: 'filename',
198
+ };
199
+ }
200
+ return getFileConfig(building.path);
201
+ }
202
+
203
+ function getColorForFile(building: CityBuilding): string {
204
+ return getConfigForFile(building).color;
205
+ }
206
+
207
+ /**
208
+ * Check if a path is highlighted by any enabled layer.
209
+ */
210
+ function getHighlightForPath(
211
+ path: string,
212
+ layers: HighlightLayer[]
213
+ ): { color: string; opacity: number } | null {
214
+ for (const layer of layers) {
215
+ if (!layer.enabled) continue;
216
+
217
+ for (const item of layer.items) {
218
+ if (item.type === 'file' && item.path === path) {
219
+ return { color: layer.color, opacity: layer.opacity ?? 1 };
220
+ }
221
+ if (
222
+ item.type === 'directory' &&
223
+ (path === item.path || path.startsWith(item.path + '/'))
224
+ ) {
225
+ return { color: layer.color, opacity: layer.opacity ?? 1 };
226
+ }
227
+ }
228
+ }
229
+ return null;
230
+ }
231
+
232
+ function hasActiveHighlights(layers: HighlightLayer[]): boolean {
233
+ return layers.some((layer) => layer.enabled && layer.items.length > 0);
234
+ }
235
+
236
+ // Animated RoundedBox wrapper
237
+ const AnimatedRoundedBox = animated(RoundedBox);
238
+
239
+ // ============================================================================
240
+ // Instanced Buildings - High performance rendering for large scenes
241
+ // ============================================================================
242
+
243
+ interface InstancedBuildingsProps {
244
+ buildings: CityBuilding[];
245
+ centerOffset: { x: number; z: number };
246
+ onHover?: (building: CityBuilding | null) => void;
247
+ onClick?: (building: CityBuilding) => void;
248
+ hoveredIndex: number | null;
249
+ growProgress: number;
250
+ animationConfig: AnimationConfig;
251
+ highlightLayers: HighlightLayer[];
252
+ isolationMode: IsolationMode;
253
+ hasActiveHighlights: boolean;
254
+ dimOpacity: number;
255
+ heightScaling: HeightScaling;
256
+ linearScale: number;
257
+ staggerIndices: number[];
258
+ }
259
+
260
+ function InstancedBuildings({
261
+ buildings,
262
+ centerOffset,
263
+ onHover,
264
+ onClick,
265
+ hoveredIndex,
266
+ growProgress,
267
+ animationConfig,
268
+ highlightLayers,
269
+ isolationMode,
270
+ hasActiveHighlights,
271
+ dimOpacity,
272
+ heightScaling,
273
+ linearScale,
274
+ staggerIndices,
275
+ }: InstancedBuildingsProps) {
276
+ const meshRef = useRef<THREE.InstancedMesh>(null);
277
+ const startTimeRef = useRef<number | null>(null);
278
+ const tempObject = useMemo(() => new THREE.Object3D(), []);
279
+ const tempColor = useMemo(() => new THREE.Color(), []);
280
+
281
+ // Pre-compute building data
282
+ const buildingData = useMemo(() => {
283
+ return buildings.map((building, index) => {
284
+ const [width, , depth] = building.dimensions;
285
+ const highlight = getHighlightForPath(building.path, highlightLayers);
286
+ const isHighlighted = highlight !== null;
287
+ const shouldDim = hasActiveHighlights && !isHighlighted;
288
+ const shouldCollapse = shouldDim && isolationMode === 'collapse';
289
+ const shouldHide = shouldDim && isolationMode === 'hide';
290
+
291
+ const fullHeight = calculateBuildingHeight(
292
+ building,
293
+ heightScaling,
294
+ linearScale
295
+ );
296
+ const targetHeight = shouldCollapse ? 0.5 : fullHeight;
297
+
298
+ const baseColor = getColorForFile(building);
299
+ const color = isHighlighted ? highlight.color : baseColor;
300
+
301
+ const x = building.position.x - centerOffset.x;
302
+ const z = building.position.z - centerOffset.z;
303
+
304
+ const staggerIndex = staggerIndices[index] ?? index;
305
+ const staggerDelayMs =
306
+ (animationConfig.staggerDelay || 15) * staggerIndex;
307
+
308
+ return {
309
+ building,
310
+ index,
311
+ width,
312
+ depth,
313
+ targetHeight,
314
+ color,
315
+ x,
316
+ z,
317
+ shouldHide,
318
+ shouldDim,
319
+ staggerDelayMs,
320
+ isHighlighted,
321
+ };
322
+ });
323
+ }, [
324
+ buildings,
325
+ centerOffset,
326
+ highlightLayers,
327
+ hasActiveHighlights,
328
+ isolationMode,
329
+ heightScaling,
330
+ linearScale,
331
+ staggerIndices,
332
+ animationConfig.staggerDelay,
333
+ ]);
334
+
335
+ const visibleBuildings = useMemo(
336
+ () => buildingData.filter((b) => !b.shouldHide),
337
+ [buildingData]
338
+ );
339
+
340
+ const minHeight = 0.3;
341
+ const baseOffset = 0.2;
342
+ const tension = animationConfig.tension || 120;
343
+ const friction = animationConfig.friction || 14;
344
+ const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
345
+
346
+ useEffect(() => {
347
+ if (!meshRef.current) return;
348
+
349
+ visibleBuildings.forEach((data, instanceIndex) => {
350
+ const { width, depth, x, z, color, targetHeight } = data;
351
+
352
+ const height = growProgress * targetHeight + minHeight;
353
+ const yPosition = height / 2 + baseOffset;
354
+
355
+ tempObject.position.set(x, yPosition, z);
356
+ tempObject.scale.set(width, height, depth);
357
+ tempObject.updateMatrix();
358
+
359
+ meshRef.current!.setMatrixAt(instanceIndex, tempObject.matrix);
360
+
361
+ tempColor.set(color);
362
+ meshRef.current!.setColorAt(instanceIndex, tempColor);
363
+ });
364
+
365
+ meshRef.current.instanceMatrix.needsUpdate = true;
366
+ if (meshRef.current.instanceColor) {
367
+ meshRef.current.instanceColor.needsUpdate = true;
368
+ }
369
+ }, [
370
+ visibleBuildings,
371
+ growProgress,
372
+ tempObject,
373
+ tempColor,
374
+ minHeight,
375
+ baseOffset,
376
+ ]);
377
+
378
+ useFrame(({ clock }) => {
379
+ if (!meshRef.current) return;
380
+
381
+ if (startTimeRef.current === null && growProgress > 0) {
382
+ startTimeRef.current = clock.elapsedTime * 1000;
383
+ }
384
+
385
+ const currentTime = clock.elapsedTime * 1000;
386
+ const animStartTime = startTimeRef.current ?? currentTime;
387
+
388
+ visibleBuildings.forEach((data, instanceIndex) => {
389
+ const { width, depth, targetHeight, x, z, staggerDelayMs, shouldDim } =
390
+ data;
391
+
392
+ const elapsed = currentTime - animStartTime - staggerDelayMs;
393
+ let animProgress = growProgress;
394
+
395
+ if (growProgress > 0 && elapsed >= 0) {
396
+ const t = Math.min(elapsed / springDuration, 1);
397
+ const eased = 1 - Math.pow(1 - t, 3);
398
+ animProgress = eased * growProgress;
399
+ } else if (growProgress > 0 && elapsed < 0) {
400
+ animProgress = 0;
401
+ }
402
+
403
+ const height = animProgress * targetHeight + minHeight;
404
+ const yPosition = height / 2 + baseOffset;
405
+
406
+ const isHovered = hoveredIndex === data.index;
407
+ const scale = isHovered ? 1.05 : 1;
408
+
409
+ tempObject.position.set(x, yPosition, z);
410
+ tempObject.scale.set(width * scale, height, depth * scale);
411
+ tempObject.updateMatrix();
412
+
413
+ meshRef.current!.setMatrixAt(instanceIndex, tempObject.matrix);
414
+
415
+ const opacity =
416
+ shouldDim && isolationMode === 'transparent' ? dimOpacity : 1;
417
+ tempColor.set(data.color);
418
+ if (opacity < 1) {
419
+ tempColor.multiplyScalar(opacity + 0.3);
420
+ }
421
+ if (isHovered) {
422
+ tempColor.multiplyScalar(1.2);
423
+ }
424
+ meshRef.current!.setColorAt(instanceIndex, tempColor);
425
+ });
426
+
427
+ meshRef.current.instanceMatrix.needsUpdate = true;
428
+ if (meshRef.current.instanceColor) {
429
+ meshRef.current.instanceColor.needsUpdate = true;
430
+ }
431
+ });
432
+
433
+ const handlePointerMove = useCallback(
434
+ (e: ThreeEvent<PointerEvent>) => {
435
+ e.stopPropagation();
436
+ if (
437
+ e.instanceId !== undefined &&
438
+ e.instanceId < visibleBuildings.length
439
+ ) {
440
+ const data = visibleBuildings[e.instanceId];
441
+ onHover?.(data.building);
442
+ }
443
+ },
444
+ [visibleBuildings, onHover]
445
+ );
446
+
447
+ const handlePointerOut = useCallback(() => {
448
+ onHover?.(null);
449
+ }, [onHover]);
450
+
451
+ const handleClick = useCallback(
452
+ (e: ThreeEvent<MouseEvent>) => {
453
+ e.stopPropagation();
454
+ if (
455
+ e.instanceId !== undefined &&
456
+ e.instanceId < visibleBuildings.length
457
+ ) {
458
+ const data = visibleBuildings[e.instanceId];
459
+ onClick?.(data.building);
460
+ }
461
+ },
462
+ [visibleBuildings, onClick]
463
+ );
464
+
465
+ if (visibleBuildings.length === 0) return null;
466
+
467
+ return (
468
+ <instancedMesh
469
+ ref={meshRef}
470
+ args={[undefined, undefined, visibleBuildings.length]}
471
+ onPointerMove={handlePointerMove}
472
+ onPointerOut={handlePointerOut}
473
+ onClick={handleClick}
474
+ frustumCulled={false}
475
+ >
476
+ <boxGeometry args={[1, 1, 1]} />
477
+ <meshStandardMaterial metalness={0.1} roughness={0.35} />
478
+ </instancedMesh>
479
+ );
480
+ }
481
+
482
+ // District floor component
483
+ interface DistrictFloorProps {
484
+ district: CityDistrict;
485
+ centerOffset: { x: number; z: number };
486
+ opacity: number;
487
+ }
488
+
489
+ function DistrictFloor({
490
+ district,
491
+ centerOffset,
492
+ opacity,
493
+ }: DistrictFloorProps) {
494
+ const { worldBounds } = district;
495
+ const width = worldBounds.maxX - worldBounds.minX;
496
+ const depth = worldBounds.maxZ - worldBounds.minZ;
497
+ const centerX = (worldBounds.minX + worldBounds.maxX) / 2 - centerOffset.x;
498
+ const centerZ = (worldBounds.minZ + worldBounds.maxZ) / 2 - centerOffset.z;
499
+
500
+ const dirName = district.path.split('/').pop() || district.path;
501
+
502
+ const pathDepth = district.path.split('/').length;
503
+ const floorY = -5 - pathDepth * 0.1;
504
+
505
+ return (
506
+ <group position={[centerX, 0, centerZ]}>
507
+ <lineSegments
508
+ rotation={[-Math.PI / 2, 0, 0]}
509
+ position={[0, floorY, 0]}
510
+ renderOrder={-1}
511
+ >
512
+ <edgesGeometry
513
+ args={[new THREE.PlaneGeometry(width, depth)]}
514
+ attach="geometry"
515
+ />
516
+ <lineBasicMaterial color="#475569" depthWrite={false} />
517
+ </lineSegments>
518
+
519
+ {district.label && (
520
+ <Text
521
+ position={[0, 1.5, depth / 2 + 2]}
522
+ rotation={[-Math.PI / 6, 0, 0]}
523
+ fontSize={Math.min(3, width / 6)}
524
+ color="#cbd5e1"
525
+ anchorX="center"
526
+ anchorY="middle"
527
+ outlineWidth={0.1}
528
+ outlineColor="#0f172a"
529
+ >
530
+ {dirName}
531
+ </Text>
532
+ )}
533
+ </group>
534
+ );
535
+ }
536
+
537
+ // Camera controller
538
+ interface AnimatedCameraProps {
539
+ citySize: number;
540
+ isFlat: boolean;
541
+ }
542
+
543
+ let cameraResetFn: (() => void) | null = null;
544
+
545
+ export function resetCamera() {
546
+ cameraResetFn?.();
547
+ }
548
+
549
+ function AnimatedCamera({ citySize, isFlat }: AnimatedCameraProps) {
550
+ const { camera } = useThree();
551
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
552
+ const controlsRef = useRef<any>(null);
553
+
554
+ const resetToInitial = useCallback(() => {
555
+ const targetHeight = isFlat ? citySize * 1.5 : citySize * 1.1;
556
+ const targetZ = isFlat ? 0 : citySize * 1.3;
557
+
558
+ camera.position.set(0, targetHeight, targetZ);
559
+ camera.lookAt(0, 0, 0);
560
+
561
+ if (controlsRef.current) {
562
+ controlsRef.current.target.set(0, 0, 0);
563
+ controlsRef.current.update();
564
+ }
565
+ }, [isFlat, citySize, camera]);
566
+
567
+ useEffect(() => {
568
+ resetToInitial();
569
+ }, [resetToInitial]);
570
+
571
+ useEffect(() => {
572
+ cameraResetFn = resetToInitial;
573
+ return () => {
574
+ cameraResetFn = null;
575
+ };
576
+ }, [resetToInitial]);
577
+
578
+ return (
579
+ <>
580
+ <PerspectiveCamera makeDefault fov={50} near={1} far={citySize * 10} />
581
+ <OrbitControls
582
+ ref={controlsRef}
583
+ enableDamping
584
+ dampingFactor={0.05}
585
+ minDistance={10}
586
+ maxDistance={citySize * 3}
587
+ maxPolarAngle={Math.PI / 2.1}
588
+ />
589
+ </>
590
+ );
591
+ }
592
+
593
+ // Info panel overlay
594
+ interface InfoPanelProps {
595
+ building: CityBuilding | null;
596
+ }
597
+
598
+ function InfoPanel({ building }: InfoPanelProps) {
599
+ if (!building) return null;
600
+
601
+ const fileName = building.path.split('/').pop();
602
+ const dirPath = building.path.split('/').slice(0, -1).join('/');
603
+ const rawExt =
604
+ building.fileExtension || building.path.split('.').pop() || '';
605
+ const ext = rawExt.replace(/^\./, '');
606
+ const isCode = isCodeFile(ext);
607
+
608
+ return (
609
+ <div
610
+ style={{
611
+ position: 'absolute',
612
+ bottom: 16,
613
+ left: 16,
614
+ background: 'rgba(15, 23, 42, 0.9)',
615
+ border: '1px solid #334155',
616
+ borderRadius: 8,
617
+ padding: '12px 16px',
618
+ color: '#e2e8f0',
619
+ fontSize: 14,
620
+ fontFamily: 'monospace',
621
+ maxWidth: 400,
622
+ pointerEvents: 'none',
623
+ }}
624
+ >
625
+ <div style={{ fontWeight: 600, marginBottom: 4 }}>{fileName}</div>
626
+ <div style={{ color: '#94a3b8', fontSize: 12 }}>{dirPath}</div>
627
+ <div
628
+ style={{
629
+ color: '#64748b',
630
+ fontSize: 11,
631
+ marginTop: 4,
632
+ display: 'flex',
633
+ gap: 12,
634
+ }}
635
+ >
636
+ {building.lineCount !== undefined && (
637
+ <span>{building.lineCount.toLocaleString()} lines</span>
638
+ )}
639
+ {building.size !== undefined && (
640
+ <span>{(building.size / 1024).toFixed(1)} KB</span>
641
+ )}
642
+ </div>
643
+ </div>
644
+ );
645
+ }
646
+
647
+ // Control buttons overlay
648
+ interface ControlsOverlayProps {
649
+ isFlat: boolean;
650
+ onToggle: () => void;
651
+ onResetCamera: () => void;
652
+ }
653
+
654
+ function ControlsOverlay({
655
+ isFlat,
656
+ onToggle,
657
+ onResetCamera,
658
+ }: ControlsOverlayProps) {
659
+ const buttonStyle = {
660
+ background: 'rgba(15, 23, 42, 0.9)',
661
+ border: '1px solid #334155',
662
+ borderRadius: 6,
663
+ padding: '8px 16px',
664
+ color: '#e2e8f0',
665
+ fontSize: 13,
666
+ cursor: 'pointer',
667
+ display: 'flex',
668
+ alignItems: 'center',
669
+ gap: 6,
670
+ };
671
+
672
+ return (
673
+ <div
674
+ style={{
675
+ position: 'absolute',
676
+ top: 16,
677
+ right: 16,
678
+ display: 'flex',
679
+ gap: 8,
680
+ }}
681
+ >
682
+ <button onClick={onResetCamera} style={buttonStyle}>
683
+ Reset View
684
+ </button>
685
+ <button onClick={onToggle} style={buttonStyle}>
686
+ {isFlat ? 'Grow to 3D' : 'Flatten to 2D'}
687
+ </button>
688
+ </div>
689
+ );
690
+ }
691
+
692
+ // Main scene component
693
+ interface CitySceneProps {
694
+ cityData: CityData;
695
+ onBuildingHover?: (building: CityBuilding | null) => void;
696
+ onBuildingClick?: (building: CityBuilding) => void;
697
+ hoveredBuilding: CityBuilding | null;
698
+ growProgress: number;
699
+ animationConfig: AnimationConfig;
700
+ highlightLayers: HighlightLayer[];
701
+ isolationMode: IsolationMode;
702
+ dimOpacity: number;
703
+ heightScaling: HeightScaling;
704
+ linearScale: number;
705
+ }
706
+
707
+ function CityScene({
708
+ cityData,
709
+ onBuildingHover,
710
+ onBuildingClick,
711
+ hoveredBuilding,
712
+ growProgress,
713
+ animationConfig,
714
+ highlightLayers,
715
+ isolationMode,
716
+ dimOpacity,
717
+ heightScaling,
718
+ linearScale,
719
+ }: CitySceneProps) {
720
+ const centerOffset = useMemo(
721
+ () => ({
722
+ x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
723
+ z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
724
+ }),
725
+ [cityData.bounds]
726
+ );
727
+
728
+ const citySize = Math.max(
729
+ cityData.bounds.maxX - cityData.bounds.minX,
730
+ cityData.bounds.maxZ - cityData.bounds.minZ
731
+ );
732
+
733
+ const activeHighlights = useMemo(
734
+ () => hasActiveHighlights(highlightLayers),
735
+ [highlightLayers]
736
+ );
737
+
738
+ const staggerIndices = useMemo(() => {
739
+ const centerX = (cityData.bounds.minX + cityData.bounds.maxX) / 2;
740
+ const centerZ = (cityData.bounds.minZ + cityData.bounds.maxZ) / 2;
741
+
742
+ const withDistance = cityData.buildings.map((b, originalIndex) => ({
743
+ originalIndex,
744
+ distance: Math.sqrt(
745
+ Math.pow(b.position.x - centerX, 2) +
746
+ Math.pow(b.position.z - centerZ, 2)
747
+ ),
748
+ }));
749
+
750
+ withDistance.sort((a, b) => a.distance - b.distance);
751
+
752
+ const indices: number[] = new Array(cityData.buildings.length);
753
+ withDistance.forEach((item, staggerOrder) => {
754
+ indices[item.originalIndex] = staggerOrder;
755
+ });
756
+
757
+ return indices;
758
+ }, [cityData.buildings, cityData.bounds]);
759
+
760
+ const hoveredIndex = useMemo(() => {
761
+ if (!hoveredBuilding) return null;
762
+ return cityData.buildings.findIndex((b) => b.path === hoveredBuilding.path);
763
+ }, [hoveredBuilding, cityData.buildings]);
764
+
765
+ return (
766
+ <>
767
+ <AnimatedCamera citySize={citySize} isFlat={growProgress === 0} />
768
+
769
+ <ambientLight intensity={0.4} />
770
+ <directionalLight
771
+ position={[citySize, citySize, citySize * 0.5]}
772
+ intensity={1}
773
+ castShadow
774
+ shadow-mapSize={[2048, 2048]}
775
+ />
776
+ <directionalLight
777
+ position={[-citySize * 0.5, citySize * 0.5, -citySize * 0.5]}
778
+ intensity={0.3}
779
+ />
780
+
781
+ {cityData.districts.map((district) => (
782
+ <DistrictFloor
783
+ key={district.path}
784
+ district={district}
785
+ centerOffset={centerOffset}
786
+ opacity={1}
787
+ />
788
+ ))}
789
+
790
+ <InstancedBuildings
791
+ buildings={cityData.buildings}
792
+ centerOffset={centerOffset}
793
+ onHover={onBuildingHover}
794
+ onClick={onBuildingClick}
795
+ hoveredIndex={hoveredIndex}
796
+ growProgress={growProgress}
797
+ animationConfig={animationConfig}
798
+ highlightLayers={highlightLayers}
799
+ isolationMode={isolationMode}
800
+ hasActiveHighlights={activeHighlights}
801
+ dimOpacity={dimOpacity}
802
+ heightScaling={heightScaling}
803
+ linearScale={linearScale}
804
+ staggerIndices={staggerIndices}
805
+ />
806
+ </>
807
+ );
808
+ }
809
+
810
+ // ============================================================================
811
+ // Main Component Props and Export
812
+ // ============================================================================
813
+
814
+ export interface FileCity3DProps {
815
+ /** City data from file-city-builder */
816
+ cityData: CityData;
817
+ /** Width of the container */
818
+ width?: number | string;
819
+ /** Height of the container */
820
+ height?: number | string;
821
+ /** Callback when a building is clicked */
822
+ onBuildingClick?: (building: CityBuilding) => void;
823
+ /** CSS class name */
824
+ className?: string;
825
+ /** Inline styles */
826
+ style?: React.CSSProperties;
827
+ /** Animation configuration */
828
+ animation?: AnimationConfig;
829
+ /** External control: set to true to grow buildings, false to flatten */
830
+ isGrown?: boolean;
831
+ /** Callback when grow state changes */
832
+ onGrowChange?: (isGrown: boolean) => void;
833
+ /** Show control buttons */
834
+ showControls?: boolean;
835
+ /** Highlight layers for focusing on specific files/directories */
836
+ highlightLayers?: HighlightLayer[];
837
+ /** How to handle non-highlighted buildings when highlights are active */
838
+ isolationMode?: IsolationMode;
839
+ /** Opacity for dimmed buildings in transparent mode (0-1) */
840
+ dimOpacity?: number;
841
+ /** Whether data is currently loading */
842
+ isLoading?: boolean;
843
+ /** Message to display while loading */
844
+ loadingMessage?: string;
845
+ /** Message to display when there's no data */
846
+ emptyMessage?: string;
847
+ /** Height scaling mode: 'logarithmic' (default) or 'linear' */
848
+ heightScaling?: HeightScaling;
849
+ /** Scale factor for linear mode (height per line, default 0.05) */
850
+ linearScale?: number;
851
+ }
852
+
853
+ /**
854
+ * FileCity3D - 3D visualization of codebase structure
855
+ *
856
+ * Renders CityData as an interactive 3D city where buildings represent files
857
+ * and their height corresponds to line count or file size.
858
+ */
859
+ export function FileCity3D({
860
+ cityData,
861
+ width = '100%',
862
+ height = 600,
863
+ onBuildingClick,
864
+ className,
865
+ style,
866
+ animation,
867
+ isGrown: externalIsGrown,
868
+ onGrowChange,
869
+ showControls = true,
870
+ highlightLayers = [],
871
+ isolationMode = 'transparent',
872
+ dimOpacity = 0.15,
873
+ isLoading = false,
874
+ loadingMessage = 'Loading file city...',
875
+ emptyMessage = 'No file tree data available',
876
+ heightScaling = 'logarithmic',
877
+ linearScale = 0.05,
878
+ }: FileCity3DProps) {
879
+ const { theme } = useTheme();
880
+ const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(
881
+ null
882
+ );
883
+ const [internalIsGrown, setInternalIsGrown] = useState(false);
884
+
885
+ const animationConfig = useMemo(
886
+ () => ({ ...DEFAULT_ANIMATION, ...animation }),
887
+ [animation]
888
+ );
889
+
890
+ const isGrown =
891
+ externalIsGrown !== undefined ? externalIsGrown : internalIsGrown;
892
+ const setIsGrown = (value: boolean) => {
893
+ setInternalIsGrown(value);
894
+ onGrowChange?.(value);
895
+ };
896
+
897
+ useEffect(() => {
898
+ if (animationConfig.startFlat && animationConfig.autoStartDelay !== null) {
899
+ const timer = setTimeout(() => {
900
+ setIsGrown(true);
901
+ }, animationConfig.autoStartDelay);
902
+ return () => clearTimeout(timer);
903
+ } else if (!animationConfig.startFlat) {
904
+ setIsGrown(true);
905
+ }
906
+ // eslint-disable-next-line react-hooks/exhaustive-deps
907
+ }, [animationConfig.startFlat, animationConfig.autoStartDelay]);
908
+
909
+ const growProgress = isGrown ? 1 : 0;
910
+
911
+ const handleToggle = () => {
912
+ setIsGrown(!isGrown);
913
+ };
914
+
915
+ if (isLoading) {
916
+ return (
917
+ <div
918
+ className={className}
919
+ style={{
920
+ width,
921
+ height,
922
+ position: 'relative',
923
+ background: theme.colors.background,
924
+ overflow: 'hidden',
925
+ display: 'flex',
926
+ alignItems: 'center',
927
+ justifyContent: 'center',
928
+ color: theme.colors.textSecondary,
929
+ fontFamily: 'system-ui, sans-serif',
930
+ fontSize: 14,
931
+ ...style,
932
+ }}
933
+ >
934
+ {loadingMessage}
935
+ </div>
936
+ );
937
+ }
938
+
939
+ if (!cityData || cityData.buildings.length === 0) {
940
+ return (
941
+ <div
942
+ className={className}
943
+ style={{
944
+ width,
945
+ height,
946
+ position: 'relative',
947
+ background: theme.colors.background,
948
+ overflow: 'hidden',
949
+ display: 'flex',
950
+ alignItems: 'center',
951
+ justifyContent: 'center',
952
+ color: theme.colors.textSecondary,
953
+ fontFamily: 'system-ui, sans-serif',
954
+ fontSize: 14,
955
+ ...style,
956
+ }}
957
+ >
958
+ {emptyMessage}
959
+ </div>
960
+ );
961
+ }
962
+
963
+ return (
964
+ <div
965
+ className={className}
966
+ style={{
967
+ width,
968
+ height,
969
+ position: 'relative',
970
+ background: theme.colors.background,
971
+ overflow: 'hidden',
972
+ ...style,
973
+ }}
974
+ >
975
+ <Canvas
976
+ shadows
977
+ style={{
978
+ position: 'absolute',
979
+ top: 0,
980
+ left: 0,
981
+ width: '100%',
982
+ height: '100%',
983
+ }}
984
+ >
985
+ <CityScene
986
+ cityData={cityData}
987
+ onBuildingHover={setHoveredBuilding}
988
+ onBuildingClick={onBuildingClick}
989
+ hoveredBuilding={hoveredBuilding}
990
+ growProgress={growProgress}
991
+ animationConfig={animationConfig}
992
+ highlightLayers={highlightLayers}
993
+ isolationMode={isolationMode}
994
+ dimOpacity={dimOpacity}
995
+ heightScaling={heightScaling}
996
+ linearScale={linearScale}
997
+ />
998
+ </Canvas>
999
+ <InfoPanel building={hoveredBuilding} />
1000
+ {showControls && (
1001
+ <ControlsOverlay
1002
+ isFlat={!isGrown}
1003
+ onToggle={handleToggle}
1004
+ onResetCamera={resetCamera}
1005
+ />
1006
+ )}
1007
+ </div>
1008
+ );
1009
+ }
1010
+
1011
+ export default FileCity3D;