@principal-ai/file-city-react 0.5.15 → 0.5.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/FileCity3D/FileCity3D.d.ts +67 -1
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +482 -93
- package/dist/components/FileCity3D/index.d.ts +2 -2
- package/dist/components/FileCity3D/index.d.ts.map +1 -1
- package/dist/components/FileCity3D/index.js +1 -1
- package/package.json +1 -1
- package/src/components/FileCity3D/FileCity3D.tsx +625 -104
- package/src/components/FileCity3D/index.ts +2 -1
- package/src/stories/FileCity3D.stories.tsx +1018 -2
|
@@ -9,8 +9,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
9
9
|
*/
|
|
10
10
|
import { useMemo, useRef, useState, useEffect, useCallback } from 'react';
|
|
11
11
|
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
|
12
|
-
import {
|
|
13
|
-
import { OrbitControls, PerspectiveCamera, Text
|
|
12
|
+
import { useSpring } from '@react-spring/three';
|
|
13
|
+
import { OrbitControls, PerspectiveCamera, Text } from '@react-three/drei';
|
|
14
14
|
import { getFileConfig } from '@principal-ai/file-city-builder';
|
|
15
15
|
import * as THREE from 'three';
|
|
16
16
|
const DEFAULT_ANIMATION = {
|
|
@@ -21,52 +21,6 @@ const DEFAULT_ANIMATION = {
|
|
|
21
21
|
tension: 120,
|
|
22
22
|
friction: 14,
|
|
23
23
|
};
|
|
24
|
-
// Code file extensions - height based on line count
|
|
25
|
-
const CODE_EXTENSIONS = new Set([
|
|
26
|
-
'ts',
|
|
27
|
-
'tsx',
|
|
28
|
-
'js',
|
|
29
|
-
'jsx',
|
|
30
|
-
'mjs',
|
|
31
|
-
'cjs',
|
|
32
|
-
'py',
|
|
33
|
-
'pyw',
|
|
34
|
-
'rs',
|
|
35
|
-
'go',
|
|
36
|
-
'java',
|
|
37
|
-
'kt',
|
|
38
|
-
'scala',
|
|
39
|
-
'c',
|
|
40
|
-
'cpp',
|
|
41
|
-
'cc',
|
|
42
|
-
'cxx',
|
|
43
|
-
'h',
|
|
44
|
-
'hpp',
|
|
45
|
-
'cs',
|
|
46
|
-
'rb',
|
|
47
|
-
'php',
|
|
48
|
-
'swift',
|
|
49
|
-
'vue',
|
|
50
|
-
'svelte',
|
|
51
|
-
'lua',
|
|
52
|
-
'sh',
|
|
53
|
-
'bash',
|
|
54
|
-
'zsh',
|
|
55
|
-
'sql',
|
|
56
|
-
'r',
|
|
57
|
-
'dart',
|
|
58
|
-
'elm',
|
|
59
|
-
'ex',
|
|
60
|
-
'exs',
|
|
61
|
-
'clj',
|
|
62
|
-
'cljs',
|
|
63
|
-
'hs',
|
|
64
|
-
'ml',
|
|
65
|
-
'mli',
|
|
66
|
-
]);
|
|
67
|
-
function isCodeFile(extension) {
|
|
68
|
-
return CODE_EXTENSIONS.has(extension.toLowerCase());
|
|
69
|
-
}
|
|
70
24
|
/**
|
|
71
25
|
* Calculate building height based on file metrics.
|
|
72
26
|
* - logarithmic: Compresses large values (default, good for mixed codebases)
|
|
@@ -191,10 +145,6 @@ function getHighlightForPath(path, layers) {
|
|
|
191
145
|
function hasActiveHighlights(layers) {
|
|
192
146
|
return layers.some(layer => layer.enabled && layer.items.length > 0);
|
|
193
147
|
}
|
|
194
|
-
// Animated RoundedBox wrapper
|
|
195
|
-
const AnimatedRoundedBox = animated(RoundedBox);
|
|
196
|
-
// Animated meshStandardMaterial for opacity transitions
|
|
197
|
-
const AnimatedMeshStandardMaterial = animated('meshStandardMaterial');
|
|
198
148
|
function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springDuration, heightMultipliersRef, }) {
|
|
199
149
|
const meshRef = useRef(null);
|
|
200
150
|
const startTimeRef = useRef(null);
|
|
@@ -267,38 +217,65 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
|
|
|
267
217
|
// Track animated height multipliers for each building (for collapse animation)
|
|
268
218
|
const heightMultipliersRef = useRef(null);
|
|
269
219
|
const targetMultipliersRef = useRef(null);
|
|
220
|
+
// Track dim state for buildings in focus but not highlighted (0 = dimmed, 1 = full)
|
|
221
|
+
const dimMultipliersRef = useRef(null);
|
|
222
|
+
const targetDimRef = useRef(null);
|
|
270
223
|
// Check if highlight layers have any active items
|
|
271
224
|
const hasActiveHighlightLayers = useMemo(() => {
|
|
272
225
|
return highlightLayers.some(layer => layer.enabled && layer.items.length > 0);
|
|
273
226
|
}, [highlightLayers]);
|
|
274
|
-
// Initialize height multiplier arrays
|
|
227
|
+
// Initialize height and dim multiplier arrays
|
|
275
228
|
useEffect(() => {
|
|
276
229
|
if (buildings.length > 0) {
|
|
277
230
|
if (!heightMultipliersRef.current ||
|
|
278
231
|
heightMultipliersRef.current.length !== buildings.length) {
|
|
279
232
|
heightMultipliersRef.current = new Float32Array(buildings.length).fill(1);
|
|
280
233
|
targetMultipliersRef.current = new Float32Array(buildings.length).fill(1);
|
|
234
|
+
dimMultipliersRef.current = new Float32Array(buildings.length).fill(1);
|
|
235
|
+
targetDimRef.current = new Float32Array(buildings.length).fill(1);
|
|
281
236
|
}
|
|
282
237
|
}
|
|
283
238
|
}, [buildings.length]);
|
|
284
239
|
// Update target multipliers when focusDirectory or highlightLayers change
|
|
285
240
|
useEffect(() => {
|
|
286
|
-
if (!targetMultipliersRef.current)
|
|
241
|
+
if (!targetMultipliersRef.current || !targetDimRef.current)
|
|
287
242
|
return;
|
|
288
243
|
buildings.forEach((building, index) => {
|
|
289
244
|
let shouldCollapse = false;
|
|
245
|
+
let shouldDim = false;
|
|
290
246
|
const isInFocusDirectory = focusDirectory
|
|
291
247
|
? isPathInDirectory(building.path, focusDirectory)
|
|
292
248
|
: true; // No focusDirectory means all are "in focus"
|
|
293
249
|
const isHighlighted = hasActiveHighlightLayers
|
|
294
250
|
? getHighlightForPath(building.path, highlightLayers) !== null
|
|
295
251
|
: true; // No highlights means all are "highlighted"
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
252
|
+
// Determine collapse and dim behavior based on what's active:
|
|
253
|
+
// - focusDirectory only: collapse if outside focus
|
|
254
|
+
// - highlightLayers only (with collapse mode): collapse if not highlighted
|
|
255
|
+
// - both: collapse if outside focus, dim if in focus but not highlighted
|
|
256
|
+
if (focusDirectory && hasActiveHighlightLayers && isolationMode === 'collapse') {
|
|
257
|
+
// Both active: collapse if outside focus, dim if in focus but not highlighted
|
|
258
|
+
shouldCollapse = !isInFocusDirectory;
|
|
259
|
+
shouldDim = isInFocusDirectory && !isHighlighted;
|
|
260
|
+
}
|
|
261
|
+
else if (focusDirectory) {
|
|
262
|
+
// Focus only: collapse if outside focus directory
|
|
263
|
+
shouldCollapse = !isInFocusDirectory;
|
|
264
|
+
}
|
|
265
|
+
else if (hasActiveHighlightLayers && isolationMode === 'collapse') {
|
|
266
|
+
// Highlight only with collapse: collapse if not highlighted
|
|
267
|
+
shouldCollapse = !isHighlighted;
|
|
268
|
+
}
|
|
269
|
+
// Height: 1.0 = full, 0.05 = flat (collapsed or dimmed)
|
|
270
|
+
if (shouldCollapse || shouldDim) {
|
|
271
|
+
targetMultipliersRef.current[index] = 0.05;
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
targetMultipliersRef.current[index] = 1;
|
|
300
275
|
}
|
|
301
|
-
|
|
276
|
+
// Dim ref controls graying: 0 = gray out, 1 = keep color
|
|
277
|
+
// Collapsed buildings go gray, dimmed buildings keep their color
|
|
278
|
+
targetDimRef.current[index] = shouldCollapse ? 0 : 1;
|
|
302
279
|
});
|
|
303
280
|
}, [focusDirectory, buildings, highlightLayers, isolationMode, hasActiveHighlightLayers]);
|
|
304
281
|
// Pre-compute building data
|
|
@@ -381,6 +358,11 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
|
|
|
381
358
|
const targetMultiplier = targetMultipliersRef.current[instanceIndex];
|
|
382
359
|
const newMultiplier = currentMultiplier + (targetMultiplier - currentMultiplier) * collapseSpeed;
|
|
383
360
|
heightMultipliersRef.current[instanceIndex] = newMultiplier;
|
|
361
|
+
// Animate dim multiplier towards target
|
|
362
|
+
const currentDim = dimMultipliersRef.current[instanceIndex];
|
|
363
|
+
const targetDim = targetDimRef.current[instanceIndex];
|
|
364
|
+
const newDim = currentDim + (targetDim - currentDim) * collapseSpeed;
|
|
365
|
+
dimMultipliersRef.current[instanceIndex] = newDim;
|
|
384
366
|
// Calculate grow animation progress
|
|
385
367
|
const elapsed = currentTime - animStartTime - staggerDelayMs;
|
|
386
368
|
let animProgress = growProgress;
|
|
@@ -402,11 +384,11 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
|
|
|
402
384
|
tempObject.scale.set(width * scale, height, depth * scale);
|
|
403
385
|
tempObject.updateMatrix();
|
|
404
386
|
meshRef.current.setMatrixAt(instanceIndex, tempObject.matrix);
|
|
405
|
-
//
|
|
387
|
+
// Apply color effects
|
|
406
388
|
tempColor.set(data.color);
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const grayAmount = 1 -
|
|
389
|
+
// Gray out collapsed buildings (newDim < 0.5 means should be gray)
|
|
390
|
+
if (newDim < 0.5) {
|
|
391
|
+
const grayAmount = 1 - newDim * 2; // 0 at dim=0.5, 1 at dim=0
|
|
410
392
|
const gray = 0.3;
|
|
411
393
|
tempColor.r = tempColor.r * (1 - grayAmount) + gray * grayAmount;
|
|
412
394
|
tempColor.g = tempColor.g * (1 - grayAmount) + gray * grayAmount;
|
|
@@ -551,7 +533,7 @@ function BuildingIcons({ buildings, centerOffset, growProgress, heightScaling, l
|
|
|
551
533
|
return (_jsx(AnimatedIcon, { x: x, z: z, targetHeight: targetHeight, iconSize: iconSize, texture: texture, opacity: opacity, growProgress: growProgress, staggerDelayMs: staggerDelayMs, springDuration: springDuration }, building.path));
|
|
552
534
|
}) }));
|
|
553
535
|
}
|
|
554
|
-
function DistrictFloor({ district, centerOffset,
|
|
536
|
+
function DistrictFloor({ district, centerOffset, highlightColor }) {
|
|
555
537
|
const { worldBounds } = district;
|
|
556
538
|
const width = worldBounds.maxX - worldBounds.minX;
|
|
557
539
|
const depth = worldBounds.maxZ - worldBounds.minZ;
|
|
@@ -560,17 +542,93 @@ function DistrictFloor({ district, centerOffset, opacity }) {
|
|
|
560
542
|
const dirName = district.path.split('/').pop() || district.path;
|
|
561
543
|
const pathDepth = district.path.split('/').length;
|
|
562
544
|
const floorY = -5 - pathDepth * 0.1;
|
|
563
|
-
|
|
545
|
+
const borderColor = highlightColor || '#475569';
|
|
546
|
+
const lineWidth = highlightColor ? 3 : 1;
|
|
547
|
+
const labelColor = highlightColor || '#cbd5e1';
|
|
548
|
+
return (_jsxs("group", { position: [centerX, 0, centerZ], children: [_jsxs("lineSegments", { rotation: [-Math.PI / 2, 0, 0], position: [0, floorY, 0], renderOrder: -1, children: [_jsx("edgesGeometry", { args: [new THREE.PlaneGeometry(width, depth)], attach: "geometry" }), _jsx("lineBasicMaterial", { color: borderColor, linewidth: lineWidth, depthWrite: false })] }), highlightColor && (_jsxs("mesh", { rotation: [-Math.PI / 2, 0, 0], position: [0, floorY - 0.1, 0], renderOrder: -2, children: [_jsx("planeGeometry", { args: [width, depth] }), _jsx("meshBasicMaterial", { color: highlightColor, transparent: true, opacity: 0.15, depthWrite: false })] })), district.label && (_jsx(Text, { position: [0, 1.5, depth / 2 + 2], rotation: [-Math.PI / 6, 0, 0], fontSize: Math.min(3, width / 6), color: labelColor, anchorX: "center", anchorY: "middle", outlineWidth: 0.1, outlineColor: "#0f172a", children: dirName }))] }));
|
|
564
549
|
}
|
|
565
|
-
let
|
|
550
|
+
let cameraApi = null;
|
|
566
551
|
export function resetCamera() {
|
|
567
|
-
|
|
552
|
+
cameraApi?.reset();
|
|
568
553
|
}
|
|
569
|
-
function
|
|
554
|
+
export function moveCameraTo(x, z, size) {
|
|
555
|
+
cameraApi?.moveTo(x, z, size);
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Set the camera's look-at target (center point for orbiting).
|
|
559
|
+
* Camera maintains its current distance and angles relative to the new target.
|
|
560
|
+
* @param x - Target X coordinate
|
|
561
|
+
* @param y - Target Y coordinate (usually 0 for ground level)
|
|
562
|
+
* @param z - Target Z coordinate
|
|
563
|
+
* @param options - Optional settings including duration in ms
|
|
564
|
+
*/
|
|
565
|
+
export function setCameraTarget(x, y, z, options) {
|
|
566
|
+
cameraApi?.setTarget(x, y, z, options);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Get the current camera target (look-at point).
|
|
570
|
+
*/
|
|
571
|
+
export function getCameraTarget() {
|
|
572
|
+
return cameraApi?.getCurrentTarget() ?? null;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Rotate the camera to view the city from a specific angle or cardinal direction.
|
|
576
|
+
* Uses the shortest path (e.g., 350° to 10° goes through 0°, not 180°).
|
|
577
|
+
* @param angleOrDirection - Angle in degrees (0 = south, 90 = west, 180 = north, 270 = east)
|
|
578
|
+
* or a cardinal direction string ('north', 'south', 'east', 'west')
|
|
579
|
+
* @param options - Optional settings including duration in ms
|
|
580
|
+
*/
|
|
581
|
+
export function rotateCameraTo(angleOrDirection, options) {
|
|
582
|
+
cameraApi?.rotateTo(angleOrDirection, options);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Rotate the camera by a relative amount.
|
|
586
|
+
* @param degrees - Degrees to rotate. Positive = clockwise, negative = counter-clockwise.
|
|
587
|
+
* @param options - Optional settings including duration in ms
|
|
588
|
+
*/
|
|
589
|
+
export function rotateCameraBy(degrees, options) {
|
|
590
|
+
cameraApi?.rotateBy(degrees, options);
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Tilt the camera to a specific vertical angle or preset.
|
|
594
|
+
* @param angle - Angle in degrees (0 = top-down, 90 = level/horizontal)
|
|
595
|
+
* or a preset: 'top' (15°), 'high' (35°), 'low' (60°), 'level' (80°)
|
|
596
|
+
* @param options - Optional settings including duration in ms
|
|
597
|
+
*/
|
|
598
|
+
export function tiltCameraTo(angle, options) {
|
|
599
|
+
cameraApi?.tiltTo(angle, options);
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Tilt the camera by a relative amount.
|
|
603
|
+
* @param degrees - Degrees to tilt. Positive = tilt down (towards top-down), negative = tilt up (towards level).
|
|
604
|
+
* @param options - Optional settings including duration in ms
|
|
605
|
+
*/
|
|
606
|
+
export function tiltCameraBy(degrees, options) {
|
|
607
|
+
cameraApi?.tiltBy(degrees, options);
|
|
608
|
+
}
|
|
609
|
+
export function getCameraPosition() {
|
|
610
|
+
return cameraApi?.getCurrentPosition() ?? null;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Get the current camera angle in degrees (0-360).
|
|
614
|
+
* 0 = south, 90 = west, 180 = north, 270 = east
|
|
615
|
+
*/
|
|
616
|
+
export function getCameraAngle() {
|
|
617
|
+
return cameraApi?.getCurrentAngle() ?? null;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Get the current camera tilt in degrees (0-90).
|
|
621
|
+
* 0 = top-down view, 90 = level/horizontal view
|
|
622
|
+
*/
|
|
623
|
+
export function getCameraTilt() {
|
|
624
|
+
return cameraApi?.getCurrentTilt() ?? null;
|
|
625
|
+
}
|
|
626
|
+
function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }) {
|
|
570
627
|
const { camera } = useThree();
|
|
571
628
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
572
629
|
const controlsRef = useRef(null);
|
|
573
630
|
const isAnimatingRef = useRef(false);
|
|
631
|
+
const isOrbitingRef = useRef(false);
|
|
574
632
|
const hasAppliedInitial = useRef(false);
|
|
575
633
|
const frameCount = useRef(0);
|
|
576
634
|
// Compute target camera position
|
|
@@ -615,6 +673,33 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
|
|
|
615
673
|
isAnimatingRef.current = false;
|
|
616
674
|
},
|
|
617
675
|
}));
|
|
676
|
+
// Separate spring for orbit angle animation (animates along horizontal arc)
|
|
677
|
+
const [{ orbitAngle }, orbitApi] = useSpring(() => ({
|
|
678
|
+
orbitAngle: 0,
|
|
679
|
+
config: { tension: 80, friction: 18 },
|
|
680
|
+
onStart: () => {
|
|
681
|
+
isOrbitingRef.current = true;
|
|
682
|
+
},
|
|
683
|
+
onRest: () => {
|
|
684
|
+
isOrbitingRef.current = false;
|
|
685
|
+
},
|
|
686
|
+
}));
|
|
687
|
+
// Separate spring for tilt angle animation (animates along vertical arc)
|
|
688
|
+
const isTiltingRef = useRef(false);
|
|
689
|
+
const [{ tiltAngle }, tiltApi] = useSpring(() => ({
|
|
690
|
+
tiltAngle: 0,
|
|
691
|
+
config: { tension: 80, friction: 18 },
|
|
692
|
+
onStart: () => {
|
|
693
|
+
isTiltingRef.current = true;
|
|
694
|
+
},
|
|
695
|
+
onRest: () => {
|
|
696
|
+
isTiltingRef.current = false;
|
|
697
|
+
},
|
|
698
|
+
}));
|
|
699
|
+
// Track orbit parameters during horizontal rotation
|
|
700
|
+
const orbitParamsRef = useRef(null);
|
|
701
|
+
// Track tilt parameters during vertical rotation
|
|
702
|
+
const tiltParamsRef = useRef(null);
|
|
618
703
|
// When targetPos changes after initial, animate to new position
|
|
619
704
|
useEffect(() => {
|
|
620
705
|
// Skip the first render - we handle that directly in useFrame
|
|
@@ -657,8 +742,55 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
|
|
|
657
742
|
hasAppliedInitial.current = true;
|
|
658
743
|
return;
|
|
659
744
|
}
|
|
660
|
-
//
|
|
661
|
-
if (
|
|
745
|
+
// Handle orbit animation (horizontal rotation along arc)
|
|
746
|
+
if (isOrbitingRef.current && orbitParamsRef.current) {
|
|
747
|
+
const { centerX, centerZ, distance, height } = orbitParamsRef.current;
|
|
748
|
+
const currentAngle = orbitAngle.get();
|
|
749
|
+
const radians = (currentAngle * Math.PI) / 180;
|
|
750
|
+
const newX = centerX + Math.sin(radians) * distance;
|
|
751
|
+
const newZ = centerZ + Math.cos(radians) * distance;
|
|
752
|
+
camera.position.set(newX, height, newZ);
|
|
753
|
+
controlsRef.current.target.set(centerX, 0, centerZ);
|
|
754
|
+
controlsRef.current.update();
|
|
755
|
+
// Sync position spring to current orbit position
|
|
756
|
+
api.set({
|
|
757
|
+
camX: newX,
|
|
758
|
+
camY: height,
|
|
759
|
+
camZ: newZ,
|
|
760
|
+
lookX: centerX,
|
|
761
|
+
lookY: 0,
|
|
762
|
+
lookZ: centerZ,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
// Handle tilt animation (vertical rotation along arc)
|
|
766
|
+
else if (isTiltingRef.current && tiltParamsRef.current) {
|
|
767
|
+
const { centerX, centerY, centerZ, distance, azimuthAngle } = tiltParamsRef.current;
|
|
768
|
+
const currentTilt = tiltAngle.get();
|
|
769
|
+
// Convert tilt angle to polar angle (0° tilt = looking down, 90° tilt = level)
|
|
770
|
+
// Clamp to avoid extreme angles
|
|
771
|
+
const clampedTilt = Math.max(5, Math.min(85, currentTilt));
|
|
772
|
+
const polarRadians = (clampedTilt * Math.PI) / 180;
|
|
773
|
+
const azimuthRadians = (azimuthAngle * Math.PI) / 180;
|
|
774
|
+
// Spherical to Cartesian conversion
|
|
775
|
+
// polarRadians: 0 = top, PI/2 = level
|
|
776
|
+
const newX = centerX + distance * Math.sin(polarRadians) * Math.sin(azimuthRadians);
|
|
777
|
+
const newY = centerY + distance * Math.cos(polarRadians);
|
|
778
|
+
const newZ = centerZ + distance * Math.sin(polarRadians) * Math.cos(azimuthRadians);
|
|
779
|
+
camera.position.set(newX, newY, newZ);
|
|
780
|
+
controlsRef.current.target.set(centerX, centerY, centerZ);
|
|
781
|
+
controlsRef.current.update();
|
|
782
|
+
// Sync position spring to current tilt position
|
|
783
|
+
api.set({
|
|
784
|
+
camX: newX,
|
|
785
|
+
camY: newY,
|
|
786
|
+
camZ: newZ,
|
|
787
|
+
lookX: centerX,
|
|
788
|
+
lookY: centerY,
|
|
789
|
+
lookZ: centerZ,
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
// Handle position animation
|
|
793
|
+
else if (isAnimatingRef.current) {
|
|
662
794
|
camera.position.set(camX.get(), camY.get(), camZ.get());
|
|
663
795
|
controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
|
|
664
796
|
controlsRef.current.update();
|
|
@@ -676,12 +808,223 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
|
|
|
676
808
|
lookZ: 0,
|
|
677
809
|
});
|
|
678
810
|
}, [citySize, api]);
|
|
811
|
+
const moveTo = useCallback((x, z, size) => {
|
|
812
|
+
const effectiveSize = size ?? citySize * 0.3;
|
|
813
|
+
const distance = Math.max(effectiveSize * 2, 50);
|
|
814
|
+
const height = Math.max(effectiveSize * 1.5, 40);
|
|
815
|
+
api.start({
|
|
816
|
+
camX: x,
|
|
817
|
+
camY: height,
|
|
818
|
+
camZ: z + distance,
|
|
819
|
+
lookX: x,
|
|
820
|
+
lookY: 0,
|
|
821
|
+
lookZ: z,
|
|
822
|
+
});
|
|
823
|
+
}, [citySize, api]);
|
|
824
|
+
// Set camera target (look-at point), maintaining current distance and angles
|
|
825
|
+
const setTarget = useCallback((x, y, z, options) => {
|
|
826
|
+
// Get current offset from target
|
|
827
|
+
const currentTargetX = controlsRef.current?.target.x ?? 0;
|
|
828
|
+
const currentTargetY = controlsRef.current?.target.y ?? 0;
|
|
829
|
+
const currentTargetZ = controlsRef.current?.target.z ?? 0;
|
|
830
|
+
const offsetX = camera.position.x - currentTargetX;
|
|
831
|
+
const offsetY = camera.position.y - currentTargetY;
|
|
832
|
+
const offsetZ = camera.position.z - currentTargetZ;
|
|
833
|
+
// New camera position maintains same offset from new target
|
|
834
|
+
const newCamX = x + offsetX;
|
|
835
|
+
const newCamY = y + offsetY;
|
|
836
|
+
const newCamZ = z + offsetZ;
|
|
837
|
+
// Build animation config
|
|
838
|
+
const animConfig = options?.duration
|
|
839
|
+
? { duration: options.duration, easing: (t) => t }
|
|
840
|
+
: { tension: 60, friction: 20 };
|
|
841
|
+
api.start({
|
|
842
|
+
camX: newCamX,
|
|
843
|
+
camY: newCamY,
|
|
844
|
+
camZ: newCamZ,
|
|
845
|
+
lookX: x,
|
|
846
|
+
lookY: y,
|
|
847
|
+
lookZ: z,
|
|
848
|
+
config: animConfig,
|
|
849
|
+
});
|
|
850
|
+
}, [camera, api]);
|
|
851
|
+
// Convert cardinal direction to angle in degrees
|
|
852
|
+
const directionToAngle = (dir) => {
|
|
853
|
+
switch (dir) {
|
|
854
|
+
case 'north': return 180;
|
|
855
|
+
case 'south': return 0;
|
|
856
|
+
case 'east': return 270;
|
|
857
|
+
case 'west': return 90;
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
// Get current angle (helper)
|
|
861
|
+
const computeCurrentAngle = useCallback(() => {
|
|
862
|
+
const targetX = controlsRef.current?.target.x ?? 0;
|
|
863
|
+
const targetZ = controlsRef.current?.target.z ?? 0;
|
|
864
|
+
const dx = camera.position.x - targetX;
|
|
865
|
+
const dz = camera.position.z - targetZ;
|
|
866
|
+
let angle = Math.atan2(dx, dz) * (180 / Math.PI);
|
|
867
|
+
if (angle < 0)
|
|
868
|
+
angle += 360;
|
|
869
|
+
return angle;
|
|
870
|
+
}, [camera]);
|
|
871
|
+
// Rotate to absolute angle using shortest path
|
|
872
|
+
const rotateTo = useCallback((angleOrDirection, options) => {
|
|
873
|
+
const targetAngle = typeof angleOrDirection === 'number'
|
|
874
|
+
? angleOrDirection
|
|
875
|
+
: directionToAngle(angleOrDirection);
|
|
876
|
+
// Get current state
|
|
877
|
+
const centerX = controlsRef.current?.target.x ?? 0;
|
|
878
|
+
const centerZ = controlsRef.current?.target.z ?? 0;
|
|
879
|
+
const currentAngle = computeCurrentAngle();
|
|
880
|
+
const distance = Math.sqrt(Math.pow(camera.position.x - centerX, 2) +
|
|
881
|
+
Math.pow(camera.position.z - centerZ, 2));
|
|
882
|
+
const height = camera.position.y;
|
|
883
|
+
// Store orbit parameters
|
|
884
|
+
orbitParamsRef.current = { centerX, centerZ, distance, height };
|
|
885
|
+
// Calculate shortest path
|
|
886
|
+
let delta = targetAngle - currentAngle;
|
|
887
|
+
// Normalize to -180 to 180
|
|
888
|
+
while (delta > 180)
|
|
889
|
+
delta -= 360;
|
|
890
|
+
while (delta < -180)
|
|
891
|
+
delta += 360;
|
|
892
|
+
// Build animation config
|
|
893
|
+
const animConfig = options?.duration
|
|
894
|
+
? { duration: options.duration }
|
|
895
|
+
: { tension: 80, friction: 18 };
|
|
896
|
+
// Animate from current angle to target using shortest path
|
|
897
|
+
orbitApi.set({ orbitAngle: currentAngle });
|
|
898
|
+
orbitApi.start({ orbitAngle: currentAngle + delta, config: animConfig });
|
|
899
|
+
}, [camera, computeCurrentAngle, orbitApi]);
|
|
900
|
+
// Rotate by relative degrees (positive = clockwise, negative = counter-clockwise)
|
|
901
|
+
const rotateBy = useCallback((degrees, options) => {
|
|
902
|
+
// Get current state
|
|
903
|
+
const centerX = controlsRef.current?.target.x ?? 0;
|
|
904
|
+
const centerZ = controlsRef.current?.target.z ?? 0;
|
|
905
|
+
const currentAngle = computeCurrentAngle();
|
|
906
|
+
const distance = Math.sqrt(Math.pow(camera.position.x - centerX, 2) +
|
|
907
|
+
Math.pow(camera.position.z - centerZ, 2));
|
|
908
|
+
const height = camera.position.y;
|
|
909
|
+
// Store orbit parameters
|
|
910
|
+
orbitParamsRef.current = { centerX, centerZ, distance, height };
|
|
911
|
+
// Build animation config
|
|
912
|
+
const animConfig = options?.duration
|
|
913
|
+
? { duration: options.duration }
|
|
914
|
+
: { tension: 80, friction: 18 };
|
|
915
|
+
// Animate from current angle by the specified degrees
|
|
916
|
+
orbitApi.set({ orbitAngle: currentAngle });
|
|
917
|
+
orbitApi.start({ orbitAngle: currentAngle + degrees, config: animConfig });
|
|
918
|
+
}, [camera, computeCurrentAngle, orbitApi]);
|
|
919
|
+
// Convert tilt preset to angle
|
|
920
|
+
const tiltPresetToAngle = (preset) => {
|
|
921
|
+
switch (preset) {
|
|
922
|
+
case 'top': return 15; // Near top-down
|
|
923
|
+
case 'high': return 35; // High angle
|
|
924
|
+
case 'low': return 60; // Low angle
|
|
925
|
+
case 'level': return 80; // Near horizontal
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
// Compute current tilt angle (polar angle in degrees)
|
|
929
|
+
const computeCurrentTilt = useCallback(() => {
|
|
930
|
+
const centerX = controlsRef.current?.target.x ?? 0;
|
|
931
|
+
const centerY = controlsRef.current?.target.y ?? 0;
|
|
932
|
+
const centerZ = controlsRef.current?.target.z ?? 0;
|
|
933
|
+
const dx = camera.position.x - centerX;
|
|
934
|
+
const dy = camera.position.y - centerY;
|
|
935
|
+
const dz = camera.position.z - centerZ;
|
|
936
|
+
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
937
|
+
if (distance === 0)
|
|
938
|
+
return 45; // Default
|
|
939
|
+
// Polar angle: arccos(dy / distance)
|
|
940
|
+
const polarRadians = Math.acos(dy / distance);
|
|
941
|
+
return (polarRadians * 180) / Math.PI;
|
|
942
|
+
}, [camera]);
|
|
943
|
+
// Tilt to absolute angle or preset
|
|
944
|
+
const tiltTo = useCallback((angleOrPreset, options) => {
|
|
945
|
+
const targetTilt = typeof angleOrPreset === 'number'
|
|
946
|
+
? angleOrPreset
|
|
947
|
+
: tiltPresetToAngle(angleOrPreset);
|
|
948
|
+
// Get current state
|
|
949
|
+
const centerX = controlsRef.current?.target.x ?? 0;
|
|
950
|
+
const centerY = controlsRef.current?.target.y ?? 0;
|
|
951
|
+
const centerZ = controlsRef.current?.target.z ?? 0;
|
|
952
|
+
const dx = camera.position.x - centerX;
|
|
953
|
+
const dy = camera.position.y - centerY;
|
|
954
|
+
const dz = camera.position.z - centerZ;
|
|
955
|
+
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
956
|
+
const currentTilt = computeCurrentTilt();
|
|
957
|
+
const azimuthAngle = computeCurrentAngle();
|
|
958
|
+
// Store tilt parameters
|
|
959
|
+
tiltParamsRef.current = { centerX, centerY, centerZ, distance, azimuthAngle };
|
|
960
|
+
// Build animation config
|
|
961
|
+
const animConfig = options?.duration
|
|
962
|
+
? { duration: options.duration }
|
|
963
|
+
: { tension: 80, friction: 18 };
|
|
964
|
+
// Animate from current tilt to target
|
|
965
|
+
tiltApi.set({ tiltAngle: currentTilt });
|
|
966
|
+
tiltApi.start({ tiltAngle: targetTilt, config: animConfig });
|
|
967
|
+
}, [camera, computeCurrentTilt, computeCurrentAngle, tiltApi]);
|
|
968
|
+
// Tilt by relative degrees (positive = down towards top-down, negative = up towards level)
|
|
969
|
+
const tiltBy = useCallback((degrees, options) => {
|
|
970
|
+
// Get current state
|
|
971
|
+
const centerX = controlsRef.current?.target.x ?? 0;
|
|
972
|
+
const centerY = controlsRef.current?.target.y ?? 0;
|
|
973
|
+
const centerZ = controlsRef.current?.target.z ?? 0;
|
|
974
|
+
const dx = camera.position.x - centerX;
|
|
975
|
+
const dy = camera.position.y - centerY;
|
|
976
|
+
const dz = camera.position.z - centerZ;
|
|
977
|
+
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
978
|
+
const currentTilt = computeCurrentTilt();
|
|
979
|
+
const azimuthAngle = computeCurrentAngle();
|
|
980
|
+
// Store tilt parameters
|
|
981
|
+
tiltParamsRef.current = { centerX, centerY, centerZ, distance, azimuthAngle };
|
|
982
|
+
// Build animation config
|
|
983
|
+
const animConfig = options?.duration
|
|
984
|
+
? { duration: options.duration }
|
|
985
|
+
: { tension: 80, friction: 18 };
|
|
986
|
+
// Animate from current tilt by the specified degrees
|
|
987
|
+
tiltApi.set({ tiltAngle: currentTilt });
|
|
988
|
+
tiltApi.start({ tiltAngle: currentTilt + degrees, config: animConfig });
|
|
989
|
+
}, [camera, computeCurrentTilt, computeCurrentAngle, tiltApi]);
|
|
990
|
+
const getCurrentPosition = useCallback(() => {
|
|
991
|
+
return {
|
|
992
|
+
x: camera.position.x,
|
|
993
|
+
y: camera.position.y,
|
|
994
|
+
z: camera.position.z,
|
|
995
|
+
};
|
|
996
|
+
}, [camera]);
|
|
997
|
+
const getCurrentTarget = useCallback(() => {
|
|
998
|
+
return {
|
|
999
|
+
x: controlsRef.current?.target.x ?? 0,
|
|
1000
|
+
y: controlsRef.current?.target.y ?? 0,
|
|
1001
|
+
z: controlsRef.current?.target.z ?? 0,
|
|
1002
|
+
};
|
|
1003
|
+
}, []);
|
|
1004
|
+
const getCurrentAngle = useCallback(() => {
|
|
1005
|
+
return computeCurrentAngle();
|
|
1006
|
+
}, [computeCurrentAngle]);
|
|
1007
|
+
const getCurrentTilt = useCallback(() => {
|
|
1008
|
+
return computeCurrentTilt();
|
|
1009
|
+
}, [computeCurrentTilt]);
|
|
679
1010
|
useEffect(() => {
|
|
680
|
-
|
|
1011
|
+
cameraApi = {
|
|
1012
|
+
reset: resetToInitial,
|
|
1013
|
+
moveTo,
|
|
1014
|
+
setTarget,
|
|
1015
|
+
rotateTo,
|
|
1016
|
+
rotateBy,
|
|
1017
|
+
tiltTo,
|
|
1018
|
+
tiltBy,
|
|
1019
|
+
getCurrentPosition,
|
|
1020
|
+
getCurrentTarget,
|
|
1021
|
+
getCurrentAngle,
|
|
1022
|
+
getCurrentTilt,
|
|
1023
|
+
};
|
|
681
1024
|
return () => {
|
|
682
|
-
|
|
1025
|
+
cameraApi = null;
|
|
683
1026
|
};
|
|
684
|
-
}, [resetToInitial]);
|
|
1027
|
+
}, [resetToInitial, moveTo, setTarget, rotateTo, rotateBy, tiltTo, tiltBy, getCurrentPosition, getCurrentTarget, getCurrentAngle, getCurrentTilt]);
|
|
685
1028
|
return (_jsxs(_Fragment, { children: [_jsx(PerspectiveCamera, { makeDefault: true, fov: 50, near: 1, far: citySize * 10 }), _jsx(OrbitControls, { ref: controlsRef, enableDamping: true, dampingFactor: 0.05, minDistance: 10, maxDistance: citySize * 3, maxPolarAngle: Math.PI / 2.1 })] }));
|
|
686
1029
|
}
|
|
687
1030
|
function InfoPanel({ building }) {
|
|
@@ -689,9 +1032,6 @@ function InfoPanel({ building }) {
|
|
|
689
1032
|
return null;
|
|
690
1033
|
const fileName = building.path.split('/').pop();
|
|
691
1034
|
const dirPath = building.path.split('/').slice(0, -1).join('/');
|
|
692
|
-
const rawExt = building.fileExtension || building.path.split('.').pop() || '';
|
|
693
|
-
const ext = rawExt.replace(/^\./, '');
|
|
694
|
-
const isCode = isCodeFile(ext);
|
|
695
1035
|
return (_jsxs("div", { style: {
|
|
696
1036
|
position: 'absolute',
|
|
697
1037
|
bottom: 16,
|
|
@@ -734,7 +1074,7 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera }) {
|
|
|
734
1074
|
gap: 8,
|
|
735
1075
|
}, children: [_jsx("button", { onClick: onResetCamera, style: buttonStyle, children: "Reset View" }), _jsx("button", { onClick: onToggle, style: buttonStyle, children: isFlat ? 'Grow to 3D' : 'Flatten to 2D' })] }));
|
|
736
1076
|
}
|
|
737
|
-
function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, }) {
|
|
1077
|
+
function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, focusColor, }) {
|
|
738
1078
|
const centerOffset = useMemo(() => ({
|
|
739
1079
|
x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
|
|
740
1080
|
z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
|
|
@@ -752,10 +1092,12 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
752
1092
|
// Phase 2: Buildings collapse/expand
|
|
753
1093
|
// Phase 3: Camera zooms into new directory
|
|
754
1094
|
//
|
|
755
|
-
// We track
|
|
1095
|
+
// We track three separate states for smooth transitions:
|
|
756
1096
|
// - buildingFocusDirectory: controls which buildings are collapsed (passed to InstancedBuildings)
|
|
1097
|
+
// - buildingFocusColor: the color for the focused district (synced with buildingFocusDirectory)
|
|
757
1098
|
// - cameraFocusDirectory: controls camera position (used for focusTarget calculation)
|
|
758
1099
|
const [buildingFocusDirectory, setBuildingFocusDirectory] = useState(null);
|
|
1100
|
+
const [buildingFocusColor, setBuildingFocusColor] = useState(null);
|
|
759
1101
|
const [cameraFocusDirectory, setCameraFocusDirectory] = useState(null);
|
|
760
1102
|
const prevFocusDirectoryRef = useRef(null);
|
|
761
1103
|
const animationTimersRef = useRef([]);
|
|
@@ -770,33 +1112,56 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
770
1112
|
return;
|
|
771
1113
|
// Case 1: Going from overview to a directory (null -> dir)
|
|
772
1114
|
if (prevFocus === null && focusDirectory !== null) {
|
|
773
|
-
//
|
|
1115
|
+
// Check if camera is already focused on this area via highlight layers
|
|
1116
|
+
const highlightMatchesFocus = highlightLayers.some(layer => layer.enabled && layer.items.some(item => item.type === 'directory' && (item.path === focusDirectory ||
|
|
1117
|
+
focusDirectory.startsWith(item.path + '/'))));
|
|
1118
|
+
// Phase 1: Collapse buildings immediately with the new color
|
|
774
1119
|
setBuildingFocusDirectory(focusDirectory);
|
|
775
|
-
|
|
776
|
-
|
|
1120
|
+
setBuildingFocusColor(focusColor ?? null);
|
|
1121
|
+
if (highlightMatchesFocus) {
|
|
1122
|
+
// Camera is already there, set immediately
|
|
777
1123
|
setCameraFocusDirectory(focusDirectory);
|
|
778
|
-
}
|
|
779
|
-
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
// Phase 2: After collapse settles, zoom camera in
|
|
1127
|
+
const timer = setTimeout(() => {
|
|
1128
|
+
setCameraFocusDirectory(focusDirectory);
|
|
1129
|
+
}, 600);
|
|
1130
|
+
animationTimersRef.current.push(timer);
|
|
1131
|
+
}
|
|
780
1132
|
return;
|
|
781
1133
|
}
|
|
782
1134
|
// Case 2: Going from a directory to overview (dir -> null)
|
|
783
1135
|
if (prevFocus !== null && focusDirectory === null) {
|
|
784
|
-
//
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1136
|
+
// Check if highlight layers will keep camera focused on same area
|
|
1137
|
+
const highlightMatchesPrevFocus = highlightLayers.some(layer => layer.enabled && layer.items.some(item => item.type === 'directory' && (item.path === prevFocus ||
|
|
1138
|
+
prevFocus.startsWith(item.path + '/'))));
|
|
1139
|
+
if (highlightMatchesPrevFocus) {
|
|
1140
|
+
// Camera will stay focused via highlights, just clear focus state
|
|
1141
|
+
setCameraFocusDirectory(null);
|
|
788
1142
|
setBuildingFocusDirectory(null);
|
|
789
|
-
|
|
790
|
-
|
|
1143
|
+
setBuildingFocusColor(null);
|
|
1144
|
+
}
|
|
1145
|
+
else {
|
|
1146
|
+
// Phase 1: Zoom camera out first
|
|
1147
|
+
setCameraFocusDirectory(null);
|
|
1148
|
+
// Phase 2: After zoom-out settles, expand buildings and clear color
|
|
1149
|
+
const timer = setTimeout(() => {
|
|
1150
|
+
setBuildingFocusDirectory(null);
|
|
1151
|
+
setBuildingFocusColor(null);
|
|
1152
|
+
}, 500);
|
|
1153
|
+
animationTimersRef.current.push(timer);
|
|
1154
|
+
}
|
|
791
1155
|
return;
|
|
792
1156
|
}
|
|
793
1157
|
// Case 3: Switching between directories (dirA -> dirB)
|
|
794
1158
|
if (prevFocus !== null && focusDirectory !== null) {
|
|
795
1159
|
// Phase 1: Zoom camera out
|
|
796
1160
|
setCameraFocusDirectory(null);
|
|
797
|
-
// Phase 2: After zoom-out, collapse/expand buildings
|
|
1161
|
+
// Phase 2: After zoom-out, collapse/expand buildings with new color
|
|
798
1162
|
const timer1 = setTimeout(() => {
|
|
799
1163
|
setBuildingFocusDirectory(focusDirectory);
|
|
1164
|
+
setBuildingFocusColor(focusColor ?? null);
|
|
800
1165
|
}, 500);
|
|
801
1166
|
// Phase 3: After collapse settles, zoom camera into new directory
|
|
802
1167
|
const timer2 = setTimeout(() => {
|
|
@@ -897,7 +1262,30 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
897
1262
|
const tension = animationConfig.tension || 120;
|
|
898
1263
|
const friction = animationConfig.friction || 14;
|
|
899
1264
|
const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
|
|
900
|
-
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 =>
|
|
1265
|
+
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 => {
|
|
1266
|
+
// Check if district matches focusDirectory
|
|
1267
|
+
const isFocused = buildingFocusDirectory
|
|
1268
|
+
? district.path === buildingFocusDirectory
|
|
1269
|
+
: false;
|
|
1270
|
+
// Check if district matches any highlight layer
|
|
1271
|
+
let highlightLayerColor = null;
|
|
1272
|
+
for (const layer of highlightLayers) {
|
|
1273
|
+
if (!layer.enabled)
|
|
1274
|
+
continue;
|
|
1275
|
+
for (const item of layer.items) {
|
|
1276
|
+
if (item.type === 'directory' && item.path === district.path) {
|
|
1277
|
+
highlightLayerColor = layer.color;
|
|
1278
|
+
break;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
if (highlightLayerColor)
|
|
1282
|
+
break;
|
|
1283
|
+
}
|
|
1284
|
+
// Use buildingFocusColor (synced with animation) instead of focusColor prop
|
|
1285
|
+
// Focus color takes priority, then highlight layer color
|
|
1286
|
+
const districtColor = (isFocused && buildingFocusColor) ? buildingFocusColor : highlightLayerColor;
|
|
1287
|
+
return (_jsx(DistrictFloor, { district: district, centerOffset: centerOffset, opacity: 1, highlightColor: districtColor }, district.path));
|
|
1288
|
+
}), _jsx(InstancedBuildings, { buildings: cityData.buildings, centerOffset: centerOffset, onHover: onBuildingHover, onClick: onBuildingClick, hoveredIndex: hoveredIndex, selectedIndex: selectedIndex, 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 })] }));
|
|
901
1289
|
}
|
|
902
1290
|
/**
|
|
903
1291
|
* FileCity3D - 3D visualization of codebase structure
|
|
@@ -905,7 +1293,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
905
1293
|
* Renders CityData as an interactive 3D city where buildings represent files
|
|
906
1294
|
* and their height corresponds to line count or file size.
|
|
907
1295
|
*/
|
|
908
|
-
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, backgroundColor = '#0f172a', textColor = '#94a3b8', selectedBuilding = null, }) {
|
|
1296
|
+
export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls = true, highlightLayers = [], isolationMode = 'transparent', dimOpacity: _dimOpacity = 0.15, isLoading = false, loadingMessage = 'Loading file city...', emptyMessage = 'No file tree data available', heightScaling = 'logarithmic', linearScale = 0.05, focusDirectory = null, focusColor = null, onDirectorySelect: _onDirectorySelect, backgroundColor = '#0f172a', textColor = '#94a3b8', selectedBuilding = null, }) {
|
|
909
1297
|
const [hoveredBuilding, setHoveredBuilding] = useState(null);
|
|
910
1298
|
const [internalIsGrown, setInternalIsGrown] = useState(false);
|
|
911
1299
|
const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
|
|
@@ -924,6 +1312,7 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
|
|
|
924
1312
|
else if (!animationConfig.startFlat) {
|
|
925
1313
|
setIsGrown(true);
|
|
926
1314
|
}
|
|
1315
|
+
return undefined;
|
|
927
1316
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
928
1317
|
}, [animationConfig.startFlat, animationConfig.autoStartDelay]);
|
|
929
1318
|
const growProgress = isGrown ? 1 : 0;
|
|
@@ -975,6 +1364,6 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
|
|
|
975
1364
|
left: 0,
|
|
976
1365
|
width: '100%',
|
|
977
1366
|
height: '100%',
|
|
978
|
-
}, children: _jsx(CityScene, { cityData: cityData, onBuildingHover: setHoveredBuilding, onBuildingClick: onBuildingClick, hoveredBuilding: hoveredBuilding, selectedBuilding: selectedBuilding, growProgress: growProgress, animationConfig: animationConfig, highlightLayers: highlightLayers, isolationMode: isolationMode, heightScaling: heightScaling, linearScale: linearScale, focusDirectory: focusDirectory }) }), _jsx(InfoPanel, { building: selectedBuilding || hoveredBuilding }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
|
|
1367
|
+
}, children: _jsx(CityScene, { cityData: cityData, onBuildingHover: setHoveredBuilding, onBuildingClick: onBuildingClick, hoveredBuilding: hoveredBuilding, selectedBuilding: selectedBuilding, growProgress: growProgress, animationConfig: animationConfig, highlightLayers: highlightLayers, isolationMode: isolationMode, heightScaling: heightScaling, linearScale: linearScale, focusDirectory: focusDirectory, focusColor: focusColor }) }), _jsx(InfoPanel, { building: selectedBuilding || hoveredBuilding }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
|
|
979
1368
|
}
|
|
980
1369
|
export default FileCity3D;
|