@principal-ai/file-city-react 0.5.15 → 0.5.17
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 +69 -1
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +499 -97
- 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 +650 -109
- package/src/components/FileCity3D/index.ts +2 -1
- package/src/stories/FileCity3D.stories.tsx +1149 -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,20 +542,97 @@ 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();
|
|
553
|
+
}
|
|
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);
|
|
568
608
|
}
|
|
569
|
-
function
|
|
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, focusTarget, maxBuildingHeight = 0 }) {
|
|
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
|
|
635
|
+
// When flat, use a more top-down view; when grown, use an angled view
|
|
577
636
|
const targetPos = useMemo(() => {
|
|
578
637
|
if (focusTarget) {
|
|
579
638
|
const distance = Math.max(focusTarget.size * 2, 50);
|
|
@@ -587,9 +646,15 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
|
|
|
587
646
|
targetZ: focusTarget.z,
|
|
588
647
|
};
|
|
589
648
|
}
|
|
590
|
-
// Default overview
|
|
591
|
-
|
|
592
|
-
|
|
649
|
+
// Default overview - adjust angle based on flat/grown state
|
|
650
|
+
// Flat: directly overhead (90 degrees, looking straight down)
|
|
651
|
+
// Grown: angled view to see building heights (optionally based on max building height)
|
|
652
|
+
const baseHeight = citySize * 1.1;
|
|
653
|
+
const buildingAwareHeight = maxBuildingHeight > 0
|
|
654
|
+
? Math.max(baseHeight, maxBuildingHeight * 2.5)
|
|
655
|
+
: baseHeight;
|
|
656
|
+
const targetHeight = isFlat ? citySize * 2.0 : buildingAwareHeight;
|
|
657
|
+
const targetZ = isFlat ? 0.001 : citySize * 1.3; // Near-zero for top-down (tiny offset to avoid gimbal lock)
|
|
593
658
|
return {
|
|
594
659
|
x: 0,
|
|
595
660
|
y: targetHeight,
|
|
@@ -598,7 +663,7 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
|
|
|
598
663
|
targetY: 0,
|
|
599
664
|
targetZ: 0,
|
|
600
665
|
};
|
|
601
|
-
}, [focusTarget, citySize]);
|
|
666
|
+
}, [focusTarget, citySize, isFlat, maxBuildingHeight]);
|
|
602
667
|
// Spring animation for camera movement
|
|
603
668
|
const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => ({
|
|
604
669
|
camX: targetPos.x,
|
|
@@ -615,6 +680,33 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
|
|
|
615
680
|
isAnimatingRef.current = false;
|
|
616
681
|
},
|
|
617
682
|
}));
|
|
683
|
+
// Separate spring for orbit angle animation (animates along horizontal arc)
|
|
684
|
+
const [{ orbitAngle }, orbitApi] = useSpring(() => ({
|
|
685
|
+
orbitAngle: 0,
|
|
686
|
+
config: { tension: 80, friction: 18 },
|
|
687
|
+
onStart: () => {
|
|
688
|
+
isOrbitingRef.current = true;
|
|
689
|
+
},
|
|
690
|
+
onRest: () => {
|
|
691
|
+
isOrbitingRef.current = false;
|
|
692
|
+
},
|
|
693
|
+
}));
|
|
694
|
+
// Separate spring for tilt angle animation (animates along vertical arc)
|
|
695
|
+
const isTiltingRef = useRef(false);
|
|
696
|
+
const [{ tiltAngle }, tiltApi] = useSpring(() => ({
|
|
697
|
+
tiltAngle: 0,
|
|
698
|
+
config: { tension: 80, friction: 18 },
|
|
699
|
+
onStart: () => {
|
|
700
|
+
isTiltingRef.current = true;
|
|
701
|
+
},
|
|
702
|
+
onRest: () => {
|
|
703
|
+
isTiltingRef.current = false;
|
|
704
|
+
},
|
|
705
|
+
}));
|
|
706
|
+
// Track orbit parameters during horizontal rotation
|
|
707
|
+
const orbitParamsRef = useRef(null);
|
|
708
|
+
// Track tilt parameters during vertical rotation
|
|
709
|
+
const tiltParamsRef = useRef(null);
|
|
618
710
|
// When targetPos changes after initial, animate to new position
|
|
619
711
|
useEffect(() => {
|
|
620
712
|
// Skip the first render - we handle that directly in useFrame
|
|
@@ -657,8 +749,55 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
|
|
|
657
749
|
hasAppliedInitial.current = true;
|
|
658
750
|
return;
|
|
659
751
|
}
|
|
660
|
-
//
|
|
661
|
-
if (
|
|
752
|
+
// Handle orbit animation (horizontal rotation along arc)
|
|
753
|
+
if (isOrbitingRef.current && orbitParamsRef.current) {
|
|
754
|
+
const { centerX, centerZ, distance, height } = orbitParamsRef.current;
|
|
755
|
+
const currentAngle = orbitAngle.get();
|
|
756
|
+
const radians = (currentAngle * Math.PI) / 180;
|
|
757
|
+
const newX = centerX + Math.sin(radians) * distance;
|
|
758
|
+
const newZ = centerZ + Math.cos(radians) * distance;
|
|
759
|
+
camera.position.set(newX, height, newZ);
|
|
760
|
+
controlsRef.current.target.set(centerX, 0, centerZ);
|
|
761
|
+
controlsRef.current.update();
|
|
762
|
+
// Sync position spring to current orbit position
|
|
763
|
+
api.set({
|
|
764
|
+
camX: newX,
|
|
765
|
+
camY: height,
|
|
766
|
+
camZ: newZ,
|
|
767
|
+
lookX: centerX,
|
|
768
|
+
lookY: 0,
|
|
769
|
+
lookZ: centerZ,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
// Handle tilt animation (vertical rotation along arc)
|
|
773
|
+
else if (isTiltingRef.current && tiltParamsRef.current) {
|
|
774
|
+
const { centerX, centerY, centerZ, distance, azimuthAngle } = tiltParamsRef.current;
|
|
775
|
+
const currentTilt = tiltAngle.get();
|
|
776
|
+
// Convert tilt angle to polar angle (0° tilt = looking down, 90° tilt = level)
|
|
777
|
+
// Clamp to avoid extreme angles
|
|
778
|
+
const clampedTilt = Math.max(5, Math.min(85, currentTilt));
|
|
779
|
+
const polarRadians = (clampedTilt * Math.PI) / 180;
|
|
780
|
+
const azimuthRadians = (azimuthAngle * Math.PI) / 180;
|
|
781
|
+
// Spherical to Cartesian conversion
|
|
782
|
+
// polarRadians: 0 = top, PI/2 = level
|
|
783
|
+
const newX = centerX + distance * Math.sin(polarRadians) * Math.sin(azimuthRadians);
|
|
784
|
+
const newY = centerY + distance * Math.cos(polarRadians);
|
|
785
|
+
const newZ = centerZ + distance * Math.sin(polarRadians) * Math.cos(azimuthRadians);
|
|
786
|
+
camera.position.set(newX, newY, newZ);
|
|
787
|
+
controlsRef.current.target.set(centerX, centerY, centerZ);
|
|
788
|
+
controlsRef.current.update();
|
|
789
|
+
// Sync position spring to current tilt position
|
|
790
|
+
api.set({
|
|
791
|
+
camX: newX,
|
|
792
|
+
camY: newY,
|
|
793
|
+
camZ: newZ,
|
|
794
|
+
lookX: centerX,
|
|
795
|
+
lookY: centerY,
|
|
796
|
+
lookZ: centerZ,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
// Handle position animation
|
|
800
|
+
else if (isAnimatingRef.current) {
|
|
662
801
|
camera.position.set(camX.get(), camY.get(), camZ.get());
|
|
663
802
|
controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
|
|
664
803
|
controlsRef.current.update();
|
|
@@ -676,12 +815,223 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }) {
|
|
|
676
815
|
lookZ: 0,
|
|
677
816
|
});
|
|
678
817
|
}, [citySize, api]);
|
|
818
|
+
const moveTo = useCallback((x, z, size) => {
|
|
819
|
+
const effectiveSize = size ?? citySize * 0.3;
|
|
820
|
+
const distance = Math.max(effectiveSize * 2, 50);
|
|
821
|
+
const height = Math.max(effectiveSize * 1.5, 40);
|
|
822
|
+
api.start({
|
|
823
|
+
camX: x,
|
|
824
|
+
camY: height,
|
|
825
|
+
camZ: z + distance,
|
|
826
|
+
lookX: x,
|
|
827
|
+
lookY: 0,
|
|
828
|
+
lookZ: z,
|
|
829
|
+
});
|
|
830
|
+
}, [citySize, api]);
|
|
831
|
+
// Set camera target (look-at point), maintaining current distance and angles
|
|
832
|
+
const setTarget = useCallback((x, y, z, options) => {
|
|
833
|
+
// Get current offset from target
|
|
834
|
+
const currentTargetX = controlsRef.current?.target.x ?? 0;
|
|
835
|
+
const currentTargetY = controlsRef.current?.target.y ?? 0;
|
|
836
|
+
const currentTargetZ = controlsRef.current?.target.z ?? 0;
|
|
837
|
+
const offsetX = camera.position.x - currentTargetX;
|
|
838
|
+
const offsetY = camera.position.y - currentTargetY;
|
|
839
|
+
const offsetZ = camera.position.z - currentTargetZ;
|
|
840
|
+
// New camera position maintains same offset from new target
|
|
841
|
+
const newCamX = x + offsetX;
|
|
842
|
+
const newCamY = y + offsetY;
|
|
843
|
+
const newCamZ = z + offsetZ;
|
|
844
|
+
// Build animation config
|
|
845
|
+
const animConfig = options?.duration
|
|
846
|
+
? { duration: options.duration, easing: (t) => t }
|
|
847
|
+
: { tension: 60, friction: 20 };
|
|
848
|
+
api.start({
|
|
849
|
+
camX: newCamX,
|
|
850
|
+
camY: newCamY,
|
|
851
|
+
camZ: newCamZ,
|
|
852
|
+
lookX: x,
|
|
853
|
+
lookY: y,
|
|
854
|
+
lookZ: z,
|
|
855
|
+
config: animConfig,
|
|
856
|
+
});
|
|
857
|
+
}, [camera, api]);
|
|
858
|
+
// Convert cardinal direction to angle in degrees
|
|
859
|
+
const directionToAngle = (dir) => {
|
|
860
|
+
switch (dir) {
|
|
861
|
+
case 'north': return 180;
|
|
862
|
+
case 'south': return 0;
|
|
863
|
+
case 'east': return 270;
|
|
864
|
+
case 'west': return 90;
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
// Get current angle (helper)
|
|
868
|
+
const computeCurrentAngle = useCallback(() => {
|
|
869
|
+
const targetX = controlsRef.current?.target.x ?? 0;
|
|
870
|
+
const targetZ = controlsRef.current?.target.z ?? 0;
|
|
871
|
+
const dx = camera.position.x - targetX;
|
|
872
|
+
const dz = camera.position.z - targetZ;
|
|
873
|
+
let angle = Math.atan2(dx, dz) * (180 / Math.PI);
|
|
874
|
+
if (angle < 0)
|
|
875
|
+
angle += 360;
|
|
876
|
+
return angle;
|
|
877
|
+
}, [camera]);
|
|
878
|
+
// Rotate to absolute angle using shortest path
|
|
879
|
+
const rotateTo = useCallback((angleOrDirection, options) => {
|
|
880
|
+
const targetAngle = typeof angleOrDirection === 'number'
|
|
881
|
+
? angleOrDirection
|
|
882
|
+
: directionToAngle(angleOrDirection);
|
|
883
|
+
// Get current state
|
|
884
|
+
const centerX = controlsRef.current?.target.x ?? 0;
|
|
885
|
+
const centerZ = controlsRef.current?.target.z ?? 0;
|
|
886
|
+
const currentAngle = computeCurrentAngle();
|
|
887
|
+
const distance = Math.sqrt(Math.pow(camera.position.x - centerX, 2) +
|
|
888
|
+
Math.pow(camera.position.z - centerZ, 2));
|
|
889
|
+
const height = camera.position.y;
|
|
890
|
+
// Store orbit parameters
|
|
891
|
+
orbitParamsRef.current = { centerX, centerZ, distance, height };
|
|
892
|
+
// Calculate shortest path
|
|
893
|
+
let delta = targetAngle - currentAngle;
|
|
894
|
+
// Normalize to -180 to 180
|
|
895
|
+
while (delta > 180)
|
|
896
|
+
delta -= 360;
|
|
897
|
+
while (delta < -180)
|
|
898
|
+
delta += 360;
|
|
899
|
+
// Build animation config
|
|
900
|
+
const animConfig = options?.duration
|
|
901
|
+
? { duration: options.duration }
|
|
902
|
+
: { tension: 80, friction: 18 };
|
|
903
|
+
// Animate from current angle to target using shortest path
|
|
904
|
+
orbitApi.set({ orbitAngle: currentAngle });
|
|
905
|
+
orbitApi.start({ orbitAngle: currentAngle + delta, config: animConfig });
|
|
906
|
+
}, [camera, computeCurrentAngle, orbitApi]);
|
|
907
|
+
// Rotate by relative degrees (positive = clockwise, negative = counter-clockwise)
|
|
908
|
+
const rotateBy = useCallback((degrees, options) => {
|
|
909
|
+
// Get current state
|
|
910
|
+
const centerX = controlsRef.current?.target.x ?? 0;
|
|
911
|
+
const centerZ = controlsRef.current?.target.z ?? 0;
|
|
912
|
+
const currentAngle = computeCurrentAngle();
|
|
913
|
+
const distance = Math.sqrt(Math.pow(camera.position.x - centerX, 2) +
|
|
914
|
+
Math.pow(camera.position.z - centerZ, 2));
|
|
915
|
+
const height = camera.position.y;
|
|
916
|
+
// Store orbit parameters
|
|
917
|
+
orbitParamsRef.current = { centerX, centerZ, distance, height };
|
|
918
|
+
// Build animation config
|
|
919
|
+
const animConfig = options?.duration
|
|
920
|
+
? { duration: options.duration }
|
|
921
|
+
: { tension: 80, friction: 18 };
|
|
922
|
+
// Animate from current angle by the specified degrees
|
|
923
|
+
orbitApi.set({ orbitAngle: currentAngle });
|
|
924
|
+
orbitApi.start({ orbitAngle: currentAngle + degrees, config: animConfig });
|
|
925
|
+
}, [camera, computeCurrentAngle, orbitApi]);
|
|
926
|
+
// Convert tilt preset to angle
|
|
927
|
+
const tiltPresetToAngle = (preset) => {
|
|
928
|
+
switch (preset) {
|
|
929
|
+
case 'top': return 15; // Near top-down
|
|
930
|
+
case 'high': return 35; // High angle
|
|
931
|
+
case 'low': return 60; // Low angle
|
|
932
|
+
case 'level': return 80; // Near horizontal
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
// Compute current tilt angle (polar angle in degrees)
|
|
936
|
+
const computeCurrentTilt = useCallback(() => {
|
|
937
|
+
const centerX = controlsRef.current?.target.x ?? 0;
|
|
938
|
+
const centerY = controlsRef.current?.target.y ?? 0;
|
|
939
|
+
const centerZ = controlsRef.current?.target.z ?? 0;
|
|
940
|
+
const dx = camera.position.x - centerX;
|
|
941
|
+
const dy = camera.position.y - centerY;
|
|
942
|
+
const dz = camera.position.z - centerZ;
|
|
943
|
+
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
944
|
+
if (distance === 0)
|
|
945
|
+
return 45; // Default
|
|
946
|
+
// Polar angle: arccos(dy / distance)
|
|
947
|
+
const polarRadians = Math.acos(dy / distance);
|
|
948
|
+
return (polarRadians * 180) / Math.PI;
|
|
949
|
+
}, [camera]);
|
|
950
|
+
// Tilt to absolute angle or preset
|
|
951
|
+
const tiltTo = useCallback((angleOrPreset, options) => {
|
|
952
|
+
const targetTilt = typeof angleOrPreset === 'number'
|
|
953
|
+
? angleOrPreset
|
|
954
|
+
: tiltPresetToAngle(angleOrPreset);
|
|
955
|
+
// Get current state
|
|
956
|
+
const centerX = controlsRef.current?.target.x ?? 0;
|
|
957
|
+
const centerY = controlsRef.current?.target.y ?? 0;
|
|
958
|
+
const centerZ = controlsRef.current?.target.z ?? 0;
|
|
959
|
+
const dx = camera.position.x - centerX;
|
|
960
|
+
const dy = camera.position.y - centerY;
|
|
961
|
+
const dz = camera.position.z - centerZ;
|
|
962
|
+
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
963
|
+
const currentTilt = computeCurrentTilt();
|
|
964
|
+
const azimuthAngle = computeCurrentAngle();
|
|
965
|
+
// Store tilt parameters
|
|
966
|
+
tiltParamsRef.current = { centerX, centerY, centerZ, distance, azimuthAngle };
|
|
967
|
+
// Build animation config
|
|
968
|
+
const animConfig = options?.duration
|
|
969
|
+
? { duration: options.duration }
|
|
970
|
+
: { tension: 80, friction: 18 };
|
|
971
|
+
// Animate from current tilt to target
|
|
972
|
+
tiltApi.set({ tiltAngle: currentTilt });
|
|
973
|
+
tiltApi.start({ tiltAngle: targetTilt, config: animConfig });
|
|
974
|
+
}, [camera, computeCurrentTilt, computeCurrentAngle, tiltApi]);
|
|
975
|
+
// Tilt by relative degrees (positive = down towards top-down, negative = up towards level)
|
|
976
|
+
const tiltBy = useCallback((degrees, options) => {
|
|
977
|
+
// Get current state
|
|
978
|
+
const centerX = controlsRef.current?.target.x ?? 0;
|
|
979
|
+
const centerY = controlsRef.current?.target.y ?? 0;
|
|
980
|
+
const centerZ = controlsRef.current?.target.z ?? 0;
|
|
981
|
+
const dx = camera.position.x - centerX;
|
|
982
|
+
const dy = camera.position.y - centerY;
|
|
983
|
+
const dz = camera.position.z - centerZ;
|
|
984
|
+
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
985
|
+
const currentTilt = computeCurrentTilt();
|
|
986
|
+
const azimuthAngle = computeCurrentAngle();
|
|
987
|
+
// Store tilt parameters
|
|
988
|
+
tiltParamsRef.current = { centerX, centerY, centerZ, distance, azimuthAngle };
|
|
989
|
+
// Build animation config
|
|
990
|
+
const animConfig = options?.duration
|
|
991
|
+
? { duration: options.duration }
|
|
992
|
+
: { tension: 80, friction: 18 };
|
|
993
|
+
// Animate from current tilt by the specified degrees
|
|
994
|
+
tiltApi.set({ tiltAngle: currentTilt });
|
|
995
|
+
tiltApi.start({ tiltAngle: currentTilt + degrees, config: animConfig });
|
|
996
|
+
}, [camera, computeCurrentTilt, computeCurrentAngle, tiltApi]);
|
|
997
|
+
const getCurrentPosition = useCallback(() => {
|
|
998
|
+
return {
|
|
999
|
+
x: camera.position.x,
|
|
1000
|
+
y: camera.position.y,
|
|
1001
|
+
z: camera.position.z,
|
|
1002
|
+
};
|
|
1003
|
+
}, [camera]);
|
|
1004
|
+
const getCurrentTarget = useCallback(() => {
|
|
1005
|
+
return {
|
|
1006
|
+
x: controlsRef.current?.target.x ?? 0,
|
|
1007
|
+
y: controlsRef.current?.target.y ?? 0,
|
|
1008
|
+
z: controlsRef.current?.target.z ?? 0,
|
|
1009
|
+
};
|
|
1010
|
+
}, []);
|
|
1011
|
+
const getCurrentAngle = useCallback(() => {
|
|
1012
|
+
return computeCurrentAngle();
|
|
1013
|
+
}, [computeCurrentAngle]);
|
|
1014
|
+
const getCurrentTilt = useCallback(() => {
|
|
1015
|
+
return computeCurrentTilt();
|
|
1016
|
+
}, [computeCurrentTilt]);
|
|
679
1017
|
useEffect(() => {
|
|
680
|
-
|
|
1018
|
+
cameraApi = {
|
|
1019
|
+
reset: resetToInitial,
|
|
1020
|
+
moveTo,
|
|
1021
|
+
setTarget,
|
|
1022
|
+
rotateTo,
|
|
1023
|
+
rotateBy,
|
|
1024
|
+
tiltTo,
|
|
1025
|
+
tiltBy,
|
|
1026
|
+
getCurrentPosition,
|
|
1027
|
+
getCurrentTarget,
|
|
1028
|
+
getCurrentAngle,
|
|
1029
|
+
getCurrentTilt,
|
|
1030
|
+
};
|
|
681
1031
|
return () => {
|
|
682
|
-
|
|
1032
|
+
cameraApi = null;
|
|
683
1033
|
};
|
|
684
|
-
}, [resetToInitial]);
|
|
1034
|
+
}, [resetToInitial, moveTo, setTarget, rotateTo, rotateBy, tiltTo, tiltBy, getCurrentPosition, getCurrentTarget, getCurrentAngle, getCurrentTilt]);
|
|
685
1035
|
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
1036
|
}
|
|
687
1037
|
function InfoPanel({ building }) {
|
|
@@ -689,9 +1039,6 @@ function InfoPanel({ building }) {
|
|
|
689
1039
|
return null;
|
|
690
1040
|
const fileName = building.path.split('/').pop();
|
|
691
1041
|
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
1042
|
return (_jsxs("div", { style: {
|
|
696
1043
|
position: 'absolute',
|
|
697
1044
|
bottom: 16,
|
|
@@ -734,12 +1081,18 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera }) {
|
|
|
734
1081
|
gap: 8,
|
|
735
1082
|
}, 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
1083
|
}
|
|
737
|
-
function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, }) {
|
|
1084
|
+
function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, focusColor, adaptCameraToBuildings = false, }) {
|
|
738
1085
|
const centerOffset = useMemo(() => ({
|
|
739
1086
|
x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
|
|
740
1087
|
z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
|
|
741
1088
|
}), [cityData.bounds]);
|
|
742
1089
|
const citySize = Math.max(cityData.bounds.maxX - cityData.bounds.minX, cityData.bounds.maxZ - cityData.bounds.minZ);
|
|
1090
|
+
// Calculate max building height for camera positioning (when adaptCameraToBuildings is true)
|
|
1091
|
+
const maxBuildingHeight = useMemo(() => {
|
|
1092
|
+
if (!adaptCameraToBuildings)
|
|
1093
|
+
return 0;
|
|
1094
|
+
return Math.max(...cityData.buildings.map(b => b.dimensions[1]), 0);
|
|
1095
|
+
}, [adaptCameraToBuildings, cityData.buildings]);
|
|
743
1096
|
const activeHighlights = useMemo(() => hasActiveHighlights(highlightLayers), [highlightLayers]);
|
|
744
1097
|
// Helper to check if a path is inside a directory
|
|
745
1098
|
const isPathInDirectory = useCallback((path, directory) => {
|
|
@@ -752,10 +1105,12 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
752
1105
|
// Phase 2: Buildings collapse/expand
|
|
753
1106
|
// Phase 3: Camera zooms into new directory
|
|
754
1107
|
//
|
|
755
|
-
// We track
|
|
1108
|
+
// We track three separate states for smooth transitions:
|
|
756
1109
|
// - buildingFocusDirectory: controls which buildings are collapsed (passed to InstancedBuildings)
|
|
1110
|
+
// - buildingFocusColor: the color for the focused district (synced with buildingFocusDirectory)
|
|
757
1111
|
// - cameraFocusDirectory: controls camera position (used for focusTarget calculation)
|
|
758
1112
|
const [buildingFocusDirectory, setBuildingFocusDirectory] = useState(null);
|
|
1113
|
+
const [buildingFocusColor, setBuildingFocusColor] = useState(null);
|
|
759
1114
|
const [cameraFocusDirectory, setCameraFocusDirectory] = useState(null);
|
|
760
1115
|
const prevFocusDirectoryRef = useRef(null);
|
|
761
1116
|
const animationTimersRef = useRef([]);
|
|
@@ -770,33 +1125,56 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
770
1125
|
return;
|
|
771
1126
|
// Case 1: Going from overview to a directory (null -> dir)
|
|
772
1127
|
if (prevFocus === null && focusDirectory !== null) {
|
|
773
|
-
//
|
|
1128
|
+
// Check if camera is already focused on this area via highlight layers
|
|
1129
|
+
const highlightMatchesFocus = highlightLayers.some(layer => layer.enabled && layer.items.some(item => item.type === 'directory' && (item.path === focusDirectory ||
|
|
1130
|
+
focusDirectory.startsWith(item.path + '/'))));
|
|
1131
|
+
// Phase 1: Collapse buildings immediately with the new color
|
|
774
1132
|
setBuildingFocusDirectory(focusDirectory);
|
|
775
|
-
|
|
776
|
-
|
|
1133
|
+
setBuildingFocusColor(focusColor ?? null);
|
|
1134
|
+
if (highlightMatchesFocus) {
|
|
1135
|
+
// Camera is already there, set immediately
|
|
777
1136
|
setCameraFocusDirectory(focusDirectory);
|
|
778
|
-
}
|
|
779
|
-
|
|
1137
|
+
}
|
|
1138
|
+
else {
|
|
1139
|
+
// Phase 2: After collapse settles, zoom camera in
|
|
1140
|
+
const timer = setTimeout(() => {
|
|
1141
|
+
setCameraFocusDirectory(focusDirectory);
|
|
1142
|
+
}, 600);
|
|
1143
|
+
animationTimersRef.current.push(timer);
|
|
1144
|
+
}
|
|
780
1145
|
return;
|
|
781
1146
|
}
|
|
782
1147
|
// Case 2: Going from a directory to overview (dir -> null)
|
|
783
1148
|
if (prevFocus !== null && focusDirectory === null) {
|
|
784
|
-
//
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1149
|
+
// Check if highlight layers will keep camera focused on same area
|
|
1150
|
+
const highlightMatchesPrevFocus = highlightLayers.some(layer => layer.enabled && layer.items.some(item => item.type === 'directory' && (item.path === prevFocus ||
|
|
1151
|
+
prevFocus.startsWith(item.path + '/'))));
|
|
1152
|
+
if (highlightMatchesPrevFocus) {
|
|
1153
|
+
// Camera will stay focused via highlights, just clear focus state
|
|
1154
|
+
setCameraFocusDirectory(null);
|
|
788
1155
|
setBuildingFocusDirectory(null);
|
|
789
|
-
|
|
790
|
-
|
|
1156
|
+
setBuildingFocusColor(null);
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
// Phase 1: Zoom camera out first
|
|
1160
|
+
setCameraFocusDirectory(null);
|
|
1161
|
+
// Phase 2: After zoom-out settles, expand buildings and clear color
|
|
1162
|
+
const timer = setTimeout(() => {
|
|
1163
|
+
setBuildingFocusDirectory(null);
|
|
1164
|
+
setBuildingFocusColor(null);
|
|
1165
|
+
}, 500);
|
|
1166
|
+
animationTimersRef.current.push(timer);
|
|
1167
|
+
}
|
|
791
1168
|
return;
|
|
792
1169
|
}
|
|
793
1170
|
// Case 3: Switching between directories (dirA -> dirB)
|
|
794
1171
|
if (prevFocus !== null && focusDirectory !== null) {
|
|
795
1172
|
// Phase 1: Zoom camera out
|
|
796
1173
|
setCameraFocusDirectory(null);
|
|
797
|
-
// Phase 2: After zoom-out, collapse/expand buildings
|
|
1174
|
+
// Phase 2: After zoom-out, collapse/expand buildings with new color
|
|
798
1175
|
const timer1 = setTimeout(() => {
|
|
799
1176
|
setBuildingFocusDirectory(focusDirectory);
|
|
1177
|
+
setBuildingFocusColor(focusColor ?? null);
|
|
800
1178
|
}, 500);
|
|
801
1179
|
// Phase 3: After collapse settles, zoom camera into new directory
|
|
802
1180
|
const timer2 = setTimeout(() => {
|
|
@@ -897,7 +1275,30 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
897
1275
|
const tension = animationConfig.tension || 120;
|
|
898
1276
|
const friction = animationConfig.friction || 14;
|
|
899
1277
|
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 =>
|
|
1278
|
+
return (_jsxs(_Fragment, { children: [_jsx(AnimatedCamera, { citySize: citySize, isFlat: growProgress === 0, focusTarget: focusTarget, maxBuildingHeight: maxBuildingHeight }), _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 => {
|
|
1279
|
+
// Check if district matches focusDirectory
|
|
1280
|
+
const isFocused = buildingFocusDirectory
|
|
1281
|
+
? district.path === buildingFocusDirectory
|
|
1282
|
+
: false;
|
|
1283
|
+
// Check if district matches any highlight layer
|
|
1284
|
+
let highlightLayerColor = null;
|
|
1285
|
+
for (const layer of highlightLayers) {
|
|
1286
|
+
if (!layer.enabled)
|
|
1287
|
+
continue;
|
|
1288
|
+
for (const item of layer.items) {
|
|
1289
|
+
if (item.type === 'directory' && item.path === district.path) {
|
|
1290
|
+
highlightLayerColor = layer.color;
|
|
1291
|
+
break;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
if (highlightLayerColor)
|
|
1295
|
+
break;
|
|
1296
|
+
}
|
|
1297
|
+
// Use buildingFocusColor (synced with animation) instead of focusColor prop
|
|
1298
|
+
// Focus color takes priority, then highlight layer color
|
|
1299
|
+
const districtColor = (isFocused && buildingFocusColor) ? buildingFocusColor : highlightLayerColor;
|
|
1300
|
+
return (_jsx(DistrictFloor, { district: district, centerOffset: centerOffset, opacity: 1, highlightColor: districtColor }, district.path));
|
|
1301
|
+
}), _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
1302
|
}
|
|
902
1303
|
/**
|
|
903
1304
|
* FileCity3D - 3D visualization of codebase structure
|
|
@@ -905,7 +1306,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
|
|
|
905
1306
|
* Renders CityData as an interactive 3D city where buildings represent files
|
|
906
1307
|
* and their height corresponds to line count or file size.
|
|
907
1308
|
*/
|
|
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, }) {
|
|
1309
|
+
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, adaptCameraToBuildings = false, }) {
|
|
909
1310
|
const [hoveredBuilding, setHoveredBuilding] = useState(null);
|
|
910
1311
|
const [internalIsGrown, setInternalIsGrown] = useState(false);
|
|
911
1312
|
const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
|
|
@@ -924,6 +1325,7 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
|
|
|
924
1325
|
else if (!animationConfig.startFlat) {
|
|
925
1326
|
setIsGrown(true);
|
|
926
1327
|
}
|
|
1328
|
+
return undefined;
|
|
927
1329
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
928
1330
|
}, [animationConfig.startFlat, animationConfig.autoStartDelay]);
|
|
929
1331
|
const growProgress = isGrown ? 1 : 0;
|
|
@@ -975,6 +1377,6 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
|
|
|
975
1377
|
left: 0,
|
|
976
1378
|
width: '100%',
|
|
977
1379
|
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 }))] }));
|
|
1380
|
+
}, 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, adaptCameraToBuildings: adaptCameraToBuildings }) }), _jsx(InfoPanel, { building: selectedBuilding || hoveredBuilding }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
|
|
979
1381
|
}
|
|
980
1382
|
export default FileCity3D;
|