@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
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
import React, { useMemo, useRef, useState, useEffect, useCallback } from 'react';
|
|
11
11
|
import { Canvas, useFrame, ThreeEvent, useThree } from '@react-three/fiber';
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
import { OrbitControls, PerspectiveCamera, Text
|
|
13
|
+
import { useSpring } from '@react-spring/three';
|
|
14
|
+
import { OrbitControls, PerspectiveCamera, Text } from '@react-three/drei';
|
|
15
15
|
import { getFileConfig } from '@principal-ai/file-city-builder';
|
|
16
16
|
import type {
|
|
17
17
|
CityData,
|
|
@@ -92,54 +92,6 @@ const DEFAULT_ANIMATION: AnimationConfig = {
|
|
|
92
92
|
friction: 14,
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
-
// Code file extensions - height based on line count
|
|
96
|
-
const CODE_EXTENSIONS = new Set([
|
|
97
|
-
'ts',
|
|
98
|
-
'tsx',
|
|
99
|
-
'js',
|
|
100
|
-
'jsx',
|
|
101
|
-
'mjs',
|
|
102
|
-
'cjs',
|
|
103
|
-
'py',
|
|
104
|
-
'pyw',
|
|
105
|
-
'rs',
|
|
106
|
-
'go',
|
|
107
|
-
'java',
|
|
108
|
-
'kt',
|
|
109
|
-
'scala',
|
|
110
|
-
'c',
|
|
111
|
-
'cpp',
|
|
112
|
-
'cc',
|
|
113
|
-
'cxx',
|
|
114
|
-
'h',
|
|
115
|
-
'hpp',
|
|
116
|
-
'cs',
|
|
117
|
-
'rb',
|
|
118
|
-
'php',
|
|
119
|
-
'swift',
|
|
120
|
-
'vue',
|
|
121
|
-
'svelte',
|
|
122
|
-
'lua',
|
|
123
|
-
'sh',
|
|
124
|
-
'bash',
|
|
125
|
-
'zsh',
|
|
126
|
-
'sql',
|
|
127
|
-
'r',
|
|
128
|
-
'dart',
|
|
129
|
-
'elm',
|
|
130
|
-
'ex',
|
|
131
|
-
'exs',
|
|
132
|
-
'clj',
|
|
133
|
-
'cljs',
|
|
134
|
-
'hs',
|
|
135
|
-
'ml',
|
|
136
|
-
'mli',
|
|
137
|
-
]);
|
|
138
|
-
|
|
139
|
-
function isCodeFile(extension: string): boolean {
|
|
140
|
-
return CODE_EXTENSIONS.has(extension.toLowerCase());
|
|
141
|
-
}
|
|
142
|
-
|
|
143
95
|
/**
|
|
144
96
|
* Calculate building height based on file metrics.
|
|
145
97
|
* - logarithmic: Compresses large values (default, good for mixed codebases)
|
|
@@ -301,12 +253,6 @@ function hasActiveHighlights(layers: HighlightLayer[]): boolean {
|
|
|
301
253
|
return layers.some(layer => layer.enabled && layer.items.length > 0);
|
|
302
254
|
}
|
|
303
255
|
|
|
304
|
-
// Animated RoundedBox wrapper
|
|
305
|
-
const AnimatedRoundedBox = animated(RoundedBox);
|
|
306
|
-
|
|
307
|
-
// Animated meshStandardMaterial for opacity transitions
|
|
308
|
-
const AnimatedMeshStandardMaterial = animated('meshStandardMaterial');
|
|
309
|
-
|
|
310
256
|
// ============================================================================
|
|
311
257
|
// Building Edges - Batched edge rendering for performance
|
|
312
258
|
// ============================================================================
|
|
@@ -465,13 +411,16 @@ function InstancedBuildings({
|
|
|
465
411
|
// Track animated height multipliers for each building (for collapse animation)
|
|
466
412
|
const heightMultipliersRef = useRef<Float32Array | null>(null);
|
|
467
413
|
const targetMultipliersRef = useRef<Float32Array | null>(null);
|
|
414
|
+
// Track dim state for buildings in focus but not highlighted (0 = dimmed, 1 = full)
|
|
415
|
+
const dimMultipliersRef = useRef<Float32Array | null>(null);
|
|
416
|
+
const targetDimRef = useRef<Float32Array | null>(null);
|
|
468
417
|
|
|
469
418
|
// Check if highlight layers have any active items
|
|
470
419
|
const hasActiveHighlightLayers = useMemo(() => {
|
|
471
420
|
return highlightLayers.some(layer => layer.enabled && layer.items.length > 0);
|
|
472
421
|
}, [highlightLayers]);
|
|
473
422
|
|
|
474
|
-
// Initialize height multiplier arrays
|
|
423
|
+
// Initialize height and dim multiplier arrays
|
|
475
424
|
useEffect(() => {
|
|
476
425
|
if (buildings.length > 0) {
|
|
477
426
|
if (
|
|
@@ -480,16 +429,19 @@ function InstancedBuildings({
|
|
|
480
429
|
) {
|
|
481
430
|
heightMultipliersRef.current = new Float32Array(buildings.length).fill(1);
|
|
482
431
|
targetMultipliersRef.current = new Float32Array(buildings.length).fill(1);
|
|
432
|
+
dimMultipliersRef.current = new Float32Array(buildings.length).fill(1);
|
|
433
|
+
targetDimRef.current = new Float32Array(buildings.length).fill(1);
|
|
483
434
|
}
|
|
484
435
|
}
|
|
485
436
|
}, [buildings.length]);
|
|
486
437
|
|
|
487
438
|
// Update target multipliers when focusDirectory or highlightLayers change
|
|
488
439
|
useEffect(() => {
|
|
489
|
-
if (!targetMultipliersRef.current) return;
|
|
440
|
+
if (!targetMultipliersRef.current || !targetDimRef.current) return;
|
|
490
441
|
|
|
491
442
|
buildings.forEach((building, index) => {
|
|
492
443
|
let shouldCollapse = false;
|
|
444
|
+
let shouldDim = false;
|
|
493
445
|
|
|
494
446
|
const isInFocusDirectory = focusDirectory
|
|
495
447
|
? isPathInDirectory(building.path, focusDirectory)
|
|
@@ -499,13 +451,31 @@ function InstancedBuildings({
|
|
|
499
451
|
? getHighlightForPath(building.path, highlightLayers) !== null
|
|
500
452
|
: true; // No highlights means all are "highlighted"
|
|
501
453
|
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
454
|
+
// Determine collapse and dim behavior based on what's active:
|
|
455
|
+
// - focusDirectory only: collapse if outside focus
|
|
456
|
+
// - highlightLayers only (with collapse mode): collapse if not highlighted
|
|
457
|
+
// - both: collapse if outside focus, dim if in focus but not highlighted
|
|
458
|
+
if (focusDirectory && hasActiveHighlightLayers && isolationMode === 'collapse') {
|
|
459
|
+
// Both active: collapse if outside focus, dim if in focus but not highlighted
|
|
460
|
+
shouldCollapse = !isInFocusDirectory;
|
|
461
|
+
shouldDim = isInFocusDirectory && !isHighlighted;
|
|
462
|
+
} else if (focusDirectory) {
|
|
463
|
+
// Focus only: collapse if outside focus directory
|
|
464
|
+
shouldCollapse = !isInFocusDirectory;
|
|
465
|
+
} else if (hasActiveHighlightLayers && isolationMode === 'collapse') {
|
|
466
|
+
// Highlight only with collapse: collapse if not highlighted
|
|
467
|
+
shouldCollapse = !isHighlighted;
|
|
506
468
|
}
|
|
507
469
|
|
|
508
|
-
|
|
470
|
+
// Height: 1.0 = full, 0.05 = flat (collapsed or dimmed)
|
|
471
|
+
if (shouldCollapse || shouldDim) {
|
|
472
|
+
targetMultipliersRef.current![index] = 0.05;
|
|
473
|
+
} else {
|
|
474
|
+
targetMultipliersRef.current![index] = 1;
|
|
475
|
+
}
|
|
476
|
+
// Dim ref controls graying: 0 = gray out, 1 = keep color
|
|
477
|
+
// Collapsed buildings go gray, dimmed buildings keep their color
|
|
478
|
+
targetDimRef.current![index] = shouldCollapse ? 0 : 1;
|
|
509
479
|
});
|
|
510
480
|
}, [focusDirectory, buildings, highlightLayers, isolationMode, hasActiveHighlightLayers]);
|
|
511
481
|
|
|
@@ -608,6 +578,12 @@ function InstancedBuildings({
|
|
|
608
578
|
currentMultiplier + (targetMultiplier - currentMultiplier) * collapseSpeed;
|
|
609
579
|
heightMultipliersRef.current![instanceIndex] = newMultiplier;
|
|
610
580
|
|
|
581
|
+
// Animate dim multiplier towards target
|
|
582
|
+
const currentDim = dimMultipliersRef.current![instanceIndex];
|
|
583
|
+
const targetDim = targetDimRef.current![instanceIndex];
|
|
584
|
+
const newDim = currentDim + (targetDim - currentDim) * collapseSpeed;
|
|
585
|
+
dimMultipliersRef.current![instanceIndex] = newDim;
|
|
586
|
+
|
|
611
587
|
// Calculate grow animation progress
|
|
612
588
|
const elapsed = currentTime - animStartTime - staggerDelayMs;
|
|
613
589
|
let animProgress = growProgress;
|
|
@@ -634,16 +610,18 @@ function InstancedBuildings({
|
|
|
634
610
|
|
|
635
611
|
meshRef.current!.setMatrixAt(instanceIndex, tempObject.matrix);
|
|
636
612
|
|
|
637
|
-
//
|
|
613
|
+
// Apply color effects
|
|
638
614
|
tempColor.set(data.color);
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
615
|
+
|
|
616
|
+
// Gray out collapsed buildings (newDim < 0.5 means should be gray)
|
|
617
|
+
if (newDim < 0.5) {
|
|
618
|
+
const grayAmount = 1 - newDim * 2; // 0 at dim=0.5, 1 at dim=0
|
|
642
619
|
const gray = 0.3;
|
|
643
620
|
tempColor.r = tempColor.r * (1 - grayAmount) + gray * grayAmount;
|
|
644
621
|
tempColor.g = tempColor.g * (1 - grayAmount) + gray * grayAmount;
|
|
645
622
|
tempColor.b = tempColor.b * (1 - grayAmount) + gray * grayAmount;
|
|
646
623
|
}
|
|
624
|
+
|
|
647
625
|
if (isSelected) {
|
|
648
626
|
tempColor.multiplyScalar(1.4);
|
|
649
627
|
} else if (isHovered) {
|
|
@@ -937,9 +915,10 @@ interface DistrictFloorProps {
|
|
|
937
915
|
district: CityDistrict;
|
|
938
916
|
centerOffset: { x: number; z: number };
|
|
939
917
|
opacity: number;
|
|
918
|
+
highlightColor?: string | null;
|
|
940
919
|
}
|
|
941
920
|
|
|
942
|
-
function DistrictFloor({ district, centerOffset,
|
|
921
|
+
function DistrictFloor({ district, centerOffset, highlightColor }: DistrictFloorProps) {
|
|
943
922
|
const { worldBounds } = district;
|
|
944
923
|
const width = worldBounds.maxX - worldBounds.minX;
|
|
945
924
|
const depth = worldBounds.maxZ - worldBounds.minZ;
|
|
@@ -951,19 +930,32 @@ function DistrictFloor({ district, centerOffset, opacity }: DistrictFloorProps)
|
|
|
951
930
|
const pathDepth = district.path.split('/').length;
|
|
952
931
|
const floorY = -5 - pathDepth * 0.1;
|
|
953
932
|
|
|
933
|
+
const borderColor = highlightColor || '#475569';
|
|
934
|
+
const lineWidth = highlightColor ? 3 : 1;
|
|
935
|
+
const labelColor = highlightColor || '#cbd5e1';
|
|
936
|
+
|
|
954
937
|
return (
|
|
955
938
|
<group position={[centerX, 0, centerZ]}>
|
|
939
|
+
{/* Border outline */}
|
|
956
940
|
<lineSegments rotation={[-Math.PI / 2, 0, 0]} position={[0, floorY, 0]} renderOrder={-1}>
|
|
957
941
|
<edgesGeometry args={[new THREE.PlaneGeometry(width, depth)]} attach="geometry" />
|
|
958
|
-
<lineBasicMaterial color=
|
|
942
|
+
<lineBasicMaterial color={borderColor} linewidth={lineWidth} depthWrite={false} />
|
|
959
943
|
</lineSegments>
|
|
960
944
|
|
|
945
|
+
{/* Highlighted floor fill when focused */}
|
|
946
|
+
{highlightColor && (
|
|
947
|
+
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, floorY - 0.1, 0]} renderOrder={-2}>
|
|
948
|
+
<planeGeometry args={[width, depth]} />
|
|
949
|
+
<meshBasicMaterial color={highlightColor} transparent opacity={0.15} depthWrite={false} />
|
|
950
|
+
</mesh>
|
|
951
|
+
)}
|
|
952
|
+
|
|
961
953
|
{district.label && (
|
|
962
954
|
<Text
|
|
963
955
|
position={[0, 1.5, depth / 2 + 2]}
|
|
964
956
|
rotation={[-Math.PI / 6, 0, 0]}
|
|
965
957
|
fontSize={Math.min(3, width / 6)}
|
|
966
|
-
color=
|
|
958
|
+
color={labelColor}
|
|
967
959
|
anchorX="center"
|
|
968
960
|
anchorY="middle"
|
|
969
961
|
outlineWidth={0.1}
|
|
@@ -987,23 +979,135 @@ interface AnimatedCameraProps {
|
|
|
987
979
|
citySize: number;
|
|
988
980
|
isFlat: boolean;
|
|
989
981
|
focusTarget?: FocusTarget | null;
|
|
982
|
+
maxBuildingHeight?: number;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Camera rotation options
|
|
986
|
+
export interface RotateOptions {
|
|
987
|
+
/** Animation duration in milliseconds. Default uses spring physics (~800ms feel). */
|
|
988
|
+
duration?: number;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Camera control API - populated by AnimatedCamera
|
|
992
|
+
interface CameraApi {
|
|
993
|
+
reset: () => void;
|
|
994
|
+
moveTo: (x: number, z: number, size?: number) => void;
|
|
995
|
+
setTarget: (x: number, y: number, z: number, options?: RotateOptions) => void;
|
|
996
|
+
rotateTo: (angleOrDirection: number | 'north' | 'south' | 'east' | 'west', options?: RotateOptions) => void;
|
|
997
|
+
rotateBy: (degrees: number, options?: RotateOptions) => void;
|
|
998
|
+
tiltTo: (angle: number | 'top' | 'level' | 'high' | 'low', options?: RotateOptions) => void;
|
|
999
|
+
tiltBy: (degrees: number, options?: RotateOptions) => void;
|
|
1000
|
+
getCurrentPosition: () => { x: number; y: number; z: number } | null;
|
|
1001
|
+
getCurrentTarget: () => { x: number; y: number; z: number } | null;
|
|
1002
|
+
getCurrentAngle: () => number | null;
|
|
1003
|
+
getCurrentTilt: () => number | null;
|
|
990
1004
|
}
|
|
991
1005
|
|
|
992
|
-
let
|
|
1006
|
+
let cameraApi: CameraApi | null = null;
|
|
993
1007
|
|
|
994
1008
|
export function resetCamera() {
|
|
995
|
-
|
|
1009
|
+
cameraApi?.reset();
|
|
996
1010
|
}
|
|
997
1011
|
|
|
998
|
-
function
|
|
1012
|
+
export function moveCameraTo(x: number, z: number, size?: number) {
|
|
1013
|
+
cameraApi?.moveTo(x, z, size);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Set the camera's look-at target (center point for orbiting).
|
|
1018
|
+
* Camera maintains its current distance and angles relative to the new target.
|
|
1019
|
+
* @param x - Target X coordinate
|
|
1020
|
+
* @param y - Target Y coordinate (usually 0 for ground level)
|
|
1021
|
+
* @param z - Target Z coordinate
|
|
1022
|
+
* @param options - Optional settings including duration in ms
|
|
1023
|
+
*/
|
|
1024
|
+
export function setCameraTarget(x: number, y: number, z: number, options?: RotateOptions) {
|
|
1025
|
+
cameraApi?.setTarget(x, y, z, options);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Get the current camera target (look-at point).
|
|
1030
|
+
*/
|
|
1031
|
+
export function getCameraTarget() {
|
|
1032
|
+
return cameraApi?.getCurrentTarget() ?? null;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Rotate the camera to view the city from a specific angle or cardinal direction.
|
|
1037
|
+
* Uses the shortest path (e.g., 350° to 10° goes through 0°, not 180°).
|
|
1038
|
+
* @param angleOrDirection - Angle in degrees (0 = south, 90 = west, 180 = north, 270 = east)
|
|
1039
|
+
* or a cardinal direction string ('north', 'south', 'east', 'west')
|
|
1040
|
+
* @param options - Optional settings including duration in ms
|
|
1041
|
+
*/
|
|
1042
|
+
export function rotateCameraTo(
|
|
1043
|
+
angleOrDirection: number | 'north' | 'south' | 'east' | 'west',
|
|
1044
|
+
options?: RotateOptions
|
|
1045
|
+
) {
|
|
1046
|
+
cameraApi?.rotateTo(angleOrDirection, options);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Rotate the camera by a relative amount.
|
|
1051
|
+
* @param degrees - Degrees to rotate. Positive = clockwise, negative = counter-clockwise.
|
|
1052
|
+
* @param options - Optional settings including duration in ms
|
|
1053
|
+
*/
|
|
1054
|
+
export function rotateCameraBy(degrees: number, options?: RotateOptions) {
|
|
1055
|
+
cameraApi?.rotateBy(degrees, options);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Tilt the camera to a specific vertical angle or preset.
|
|
1060
|
+
* @param angle - Angle in degrees (0 = top-down, 90 = level/horizontal)
|
|
1061
|
+
* or a preset: 'top' (15°), 'high' (35°), 'low' (60°), 'level' (80°)
|
|
1062
|
+
* @param options - Optional settings including duration in ms
|
|
1063
|
+
*/
|
|
1064
|
+
export function tiltCameraTo(
|
|
1065
|
+
angle: number | 'top' | 'level' | 'high' | 'low',
|
|
1066
|
+
options?: RotateOptions
|
|
1067
|
+
) {
|
|
1068
|
+
cameraApi?.tiltTo(angle, options);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Tilt the camera by a relative amount.
|
|
1073
|
+
* @param degrees - Degrees to tilt. Positive = tilt down (towards top-down), negative = tilt up (towards level).
|
|
1074
|
+
* @param options - Optional settings including duration in ms
|
|
1075
|
+
*/
|
|
1076
|
+
export function tiltCameraBy(degrees: number, options?: RotateOptions) {
|
|
1077
|
+
cameraApi?.tiltBy(degrees, options);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
export function getCameraPosition() {
|
|
1081
|
+
return cameraApi?.getCurrentPosition() ?? null;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Get the current camera angle in degrees (0-360).
|
|
1086
|
+
* 0 = south, 90 = west, 180 = north, 270 = east
|
|
1087
|
+
*/
|
|
1088
|
+
export function getCameraAngle() {
|
|
1089
|
+
return cameraApi?.getCurrentAngle() ?? null;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Get the current camera tilt in degrees (0-90).
|
|
1094
|
+
* 0 = top-down view, 90 = level/horizontal view
|
|
1095
|
+
*/
|
|
1096
|
+
export function getCameraTilt() {
|
|
1097
|
+
return cameraApi?.getCurrentTilt() ?? null;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function AnimatedCamera({ citySize, isFlat, focusTarget, maxBuildingHeight = 0 }: AnimatedCameraProps) {
|
|
999
1101
|
const { camera } = useThree();
|
|
1000
1102
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1001
1103
|
const controlsRef = useRef<any>(null);
|
|
1002
1104
|
const isAnimatingRef = useRef(false);
|
|
1105
|
+
const isOrbitingRef = useRef(false);
|
|
1003
1106
|
const hasAppliedInitial = useRef(false);
|
|
1004
1107
|
const frameCount = useRef(0);
|
|
1005
1108
|
|
|
1006
1109
|
// Compute target camera position
|
|
1110
|
+
// When flat, use a more top-down view; when grown, use an angled view
|
|
1007
1111
|
const targetPos = useMemo(() => {
|
|
1008
1112
|
if (focusTarget) {
|
|
1009
1113
|
const distance = Math.max(focusTarget.size * 2, 50);
|
|
@@ -1017,9 +1121,15 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps)
|
|
|
1017
1121
|
targetZ: focusTarget.z,
|
|
1018
1122
|
};
|
|
1019
1123
|
}
|
|
1020
|
-
// Default overview
|
|
1021
|
-
|
|
1022
|
-
|
|
1124
|
+
// Default overview - adjust angle based on flat/grown state
|
|
1125
|
+
// Flat: directly overhead (90 degrees, looking straight down)
|
|
1126
|
+
// Grown: angled view to see building heights (optionally based on max building height)
|
|
1127
|
+
const baseHeight = citySize * 1.1;
|
|
1128
|
+
const buildingAwareHeight = maxBuildingHeight > 0
|
|
1129
|
+
? Math.max(baseHeight, maxBuildingHeight * 2.5)
|
|
1130
|
+
: baseHeight;
|
|
1131
|
+
const targetHeight = isFlat ? citySize * 2.0 : buildingAwareHeight;
|
|
1132
|
+
const targetZ = isFlat ? 0.001 : citySize * 1.3; // Near-zero for top-down (tiny offset to avoid gimbal lock)
|
|
1023
1133
|
return {
|
|
1024
1134
|
x: 0,
|
|
1025
1135
|
y: targetHeight,
|
|
@@ -1028,7 +1138,7 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps)
|
|
|
1028
1138
|
targetY: 0,
|
|
1029
1139
|
targetZ: 0,
|
|
1030
1140
|
};
|
|
1031
|
-
}, [focusTarget, citySize]);
|
|
1141
|
+
}, [focusTarget, citySize, isFlat, maxBuildingHeight]);
|
|
1032
1142
|
|
|
1033
1143
|
// Spring animation for camera movement
|
|
1034
1144
|
const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => ({
|
|
@@ -1047,6 +1157,48 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps)
|
|
|
1047
1157
|
},
|
|
1048
1158
|
}));
|
|
1049
1159
|
|
|
1160
|
+
// Separate spring for orbit angle animation (animates along horizontal arc)
|
|
1161
|
+
const [{ orbitAngle }, orbitApi] = useSpring(() => ({
|
|
1162
|
+
orbitAngle: 0,
|
|
1163
|
+
config: { tension: 80, friction: 18 },
|
|
1164
|
+
onStart: () => {
|
|
1165
|
+
isOrbitingRef.current = true;
|
|
1166
|
+
},
|
|
1167
|
+
onRest: () => {
|
|
1168
|
+
isOrbitingRef.current = false;
|
|
1169
|
+
},
|
|
1170
|
+
}));
|
|
1171
|
+
|
|
1172
|
+
// Separate spring for tilt angle animation (animates along vertical arc)
|
|
1173
|
+
const isTiltingRef = useRef(false);
|
|
1174
|
+
const [{ tiltAngle }, tiltApi] = useSpring(() => ({
|
|
1175
|
+
tiltAngle: 0,
|
|
1176
|
+
config: { tension: 80, friction: 18 },
|
|
1177
|
+
onStart: () => {
|
|
1178
|
+
isTiltingRef.current = true;
|
|
1179
|
+
},
|
|
1180
|
+
onRest: () => {
|
|
1181
|
+
isTiltingRef.current = false;
|
|
1182
|
+
},
|
|
1183
|
+
}));
|
|
1184
|
+
|
|
1185
|
+
// Track orbit parameters during horizontal rotation
|
|
1186
|
+
const orbitParamsRef = useRef<{
|
|
1187
|
+
centerX: number;
|
|
1188
|
+
centerZ: number;
|
|
1189
|
+
distance: number;
|
|
1190
|
+
height: number;
|
|
1191
|
+
} | null>(null);
|
|
1192
|
+
|
|
1193
|
+
// Track tilt parameters during vertical rotation
|
|
1194
|
+
const tiltParamsRef = useRef<{
|
|
1195
|
+
centerX: number;
|
|
1196
|
+
centerY: number;
|
|
1197
|
+
centerZ: number;
|
|
1198
|
+
distance: number;
|
|
1199
|
+
azimuthAngle: number; // horizontal angle to maintain
|
|
1200
|
+
} | null>(null);
|
|
1201
|
+
|
|
1050
1202
|
// When targetPos changes after initial, animate to new position
|
|
1051
1203
|
useEffect(() => {
|
|
1052
1204
|
// Skip the first render - we handle that directly in useFrame
|
|
@@ -1093,8 +1245,62 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps)
|
|
|
1093
1245
|
return;
|
|
1094
1246
|
}
|
|
1095
1247
|
|
|
1096
|
-
//
|
|
1097
|
-
if (
|
|
1248
|
+
// Handle orbit animation (horizontal rotation along arc)
|
|
1249
|
+
if (isOrbitingRef.current && orbitParamsRef.current) {
|
|
1250
|
+
const { centerX, centerZ, distance, height } = orbitParamsRef.current;
|
|
1251
|
+
const currentAngle = orbitAngle.get();
|
|
1252
|
+
const radians = (currentAngle * Math.PI) / 180;
|
|
1253
|
+
|
|
1254
|
+
const newX = centerX + Math.sin(radians) * distance;
|
|
1255
|
+
const newZ = centerZ + Math.cos(radians) * distance;
|
|
1256
|
+
|
|
1257
|
+
camera.position.set(newX, height, newZ);
|
|
1258
|
+
controlsRef.current.target.set(centerX, 0, centerZ);
|
|
1259
|
+
controlsRef.current.update();
|
|
1260
|
+
|
|
1261
|
+
// Sync position spring to current orbit position
|
|
1262
|
+
api.set({
|
|
1263
|
+
camX: newX,
|
|
1264
|
+
camY: height,
|
|
1265
|
+
camZ: newZ,
|
|
1266
|
+
lookX: centerX,
|
|
1267
|
+
lookY: 0,
|
|
1268
|
+
lookZ: centerZ,
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
// Handle tilt animation (vertical rotation along arc)
|
|
1272
|
+
else if (isTiltingRef.current && tiltParamsRef.current) {
|
|
1273
|
+
const { centerX, centerY, centerZ, distance, azimuthAngle } = tiltParamsRef.current;
|
|
1274
|
+
const currentTilt = tiltAngle.get();
|
|
1275
|
+
|
|
1276
|
+
// Convert tilt angle to polar angle (0° tilt = looking down, 90° tilt = level)
|
|
1277
|
+
// Clamp to avoid extreme angles
|
|
1278
|
+
const clampedTilt = Math.max(5, Math.min(85, currentTilt));
|
|
1279
|
+
const polarRadians = (clampedTilt * Math.PI) / 180;
|
|
1280
|
+
const azimuthRadians = (azimuthAngle * Math.PI) / 180;
|
|
1281
|
+
|
|
1282
|
+
// Spherical to Cartesian conversion
|
|
1283
|
+
// polarRadians: 0 = top, PI/2 = level
|
|
1284
|
+
const newX = centerX + distance * Math.sin(polarRadians) * Math.sin(azimuthRadians);
|
|
1285
|
+
const newY = centerY + distance * Math.cos(polarRadians);
|
|
1286
|
+
const newZ = centerZ + distance * Math.sin(polarRadians) * Math.cos(azimuthRadians);
|
|
1287
|
+
|
|
1288
|
+
camera.position.set(newX, newY, newZ);
|
|
1289
|
+
controlsRef.current.target.set(centerX, centerY, centerZ);
|
|
1290
|
+
controlsRef.current.update();
|
|
1291
|
+
|
|
1292
|
+
// Sync position spring to current tilt position
|
|
1293
|
+
api.set({
|
|
1294
|
+
camX: newX,
|
|
1295
|
+
camY: newY,
|
|
1296
|
+
camZ: newZ,
|
|
1297
|
+
lookX: centerX,
|
|
1298
|
+
lookY: centerY,
|
|
1299
|
+
lookZ: centerZ,
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
// Handle position animation
|
|
1303
|
+
else if (isAnimatingRef.current) {
|
|
1098
1304
|
camera.position.set(camX.get(), camY.get(), camZ.get());
|
|
1099
1305
|
controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
|
|
1100
1306
|
controlsRef.current.update();
|
|
@@ -1115,12 +1321,268 @@ function AnimatedCamera({ citySize, isFlat, focusTarget }: AnimatedCameraProps)
|
|
|
1115
1321
|
});
|
|
1116
1322
|
}, [citySize, api]);
|
|
1117
1323
|
|
|
1324
|
+
const moveTo = useCallback((x: number, z: number, size?: number) => {
|
|
1325
|
+
const effectiveSize = size ?? citySize * 0.3;
|
|
1326
|
+
const distance = Math.max(effectiveSize * 2, 50);
|
|
1327
|
+
const height = Math.max(effectiveSize * 1.5, 40);
|
|
1328
|
+
|
|
1329
|
+
api.start({
|
|
1330
|
+
camX: x,
|
|
1331
|
+
camY: height,
|
|
1332
|
+
camZ: z + distance,
|
|
1333
|
+
lookX: x,
|
|
1334
|
+
lookY: 0,
|
|
1335
|
+
lookZ: z,
|
|
1336
|
+
});
|
|
1337
|
+
}, [citySize, api]);
|
|
1338
|
+
|
|
1339
|
+
// Set camera target (look-at point), maintaining current distance and angles
|
|
1340
|
+
const setTarget = useCallback((x: number, y: number, z: number, options?: RotateOptions) => {
|
|
1341
|
+
// Get current offset from target
|
|
1342
|
+
const currentTargetX = controlsRef.current?.target.x ?? 0;
|
|
1343
|
+
const currentTargetY = controlsRef.current?.target.y ?? 0;
|
|
1344
|
+
const currentTargetZ = controlsRef.current?.target.z ?? 0;
|
|
1345
|
+
|
|
1346
|
+
const offsetX = camera.position.x - currentTargetX;
|
|
1347
|
+
const offsetY = camera.position.y - currentTargetY;
|
|
1348
|
+
const offsetZ = camera.position.z - currentTargetZ;
|
|
1349
|
+
|
|
1350
|
+
// New camera position maintains same offset from new target
|
|
1351
|
+
const newCamX = x + offsetX;
|
|
1352
|
+
const newCamY = y + offsetY;
|
|
1353
|
+
const newCamZ = z + offsetZ;
|
|
1354
|
+
|
|
1355
|
+
// Build animation config
|
|
1356
|
+
const animConfig = options?.duration
|
|
1357
|
+
? { duration: options.duration, easing: (t: number) => t }
|
|
1358
|
+
: { tension: 60, friction: 20 };
|
|
1359
|
+
|
|
1360
|
+
api.start({
|
|
1361
|
+
camX: newCamX,
|
|
1362
|
+
camY: newCamY,
|
|
1363
|
+
camZ: newCamZ,
|
|
1364
|
+
lookX: x,
|
|
1365
|
+
lookY: y,
|
|
1366
|
+
lookZ: z,
|
|
1367
|
+
config: animConfig,
|
|
1368
|
+
});
|
|
1369
|
+
}, [camera, api]);
|
|
1370
|
+
|
|
1371
|
+
// Convert cardinal direction to angle in degrees
|
|
1372
|
+
const directionToAngle = (dir: 'north' | 'south' | 'east' | 'west'): number => {
|
|
1373
|
+
switch (dir) {
|
|
1374
|
+
case 'north': return 180;
|
|
1375
|
+
case 'south': return 0;
|
|
1376
|
+
case 'east': return 270;
|
|
1377
|
+
case 'west': return 90;
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1381
|
+
// Get current angle (helper)
|
|
1382
|
+
const computeCurrentAngle = useCallback(() => {
|
|
1383
|
+
const targetX = controlsRef.current?.target.x ?? 0;
|
|
1384
|
+
const targetZ = controlsRef.current?.target.z ?? 0;
|
|
1385
|
+
const dx = camera.position.x - targetX;
|
|
1386
|
+
const dz = camera.position.z - targetZ;
|
|
1387
|
+
let angle = Math.atan2(dx, dz) * (180 / Math.PI);
|
|
1388
|
+
if (angle < 0) angle += 360;
|
|
1389
|
+
return angle;
|
|
1390
|
+
}, [camera]);
|
|
1391
|
+
|
|
1392
|
+
// Rotate to absolute angle using shortest path
|
|
1393
|
+
const rotateTo = useCallback((
|
|
1394
|
+
angleOrDirection: number | 'north' | 'south' | 'east' | 'west',
|
|
1395
|
+
options?: RotateOptions
|
|
1396
|
+
) => {
|
|
1397
|
+
const targetAngle = typeof angleOrDirection === 'number'
|
|
1398
|
+
? angleOrDirection
|
|
1399
|
+
: directionToAngle(angleOrDirection);
|
|
1400
|
+
|
|
1401
|
+
// Get current state
|
|
1402
|
+
const centerX = controlsRef.current?.target.x ?? 0;
|
|
1403
|
+
const centerZ = controlsRef.current?.target.z ?? 0;
|
|
1404
|
+
const currentAngle = computeCurrentAngle();
|
|
1405
|
+
const distance = Math.sqrt(
|
|
1406
|
+
Math.pow(camera.position.x - centerX, 2) +
|
|
1407
|
+
Math.pow(camera.position.z - centerZ, 2)
|
|
1408
|
+
);
|
|
1409
|
+
const height = camera.position.y;
|
|
1410
|
+
|
|
1411
|
+
// Store orbit parameters
|
|
1412
|
+
orbitParamsRef.current = { centerX, centerZ, distance, height };
|
|
1413
|
+
|
|
1414
|
+
// Calculate shortest path
|
|
1415
|
+
let delta = targetAngle - currentAngle;
|
|
1416
|
+
// Normalize to -180 to 180
|
|
1417
|
+
while (delta > 180) delta -= 360;
|
|
1418
|
+
while (delta < -180) delta += 360;
|
|
1419
|
+
|
|
1420
|
+
// Build animation config
|
|
1421
|
+
const animConfig = options?.duration
|
|
1422
|
+
? { duration: options.duration }
|
|
1423
|
+
: { tension: 80, friction: 18 };
|
|
1424
|
+
|
|
1425
|
+
// Animate from current angle to target using shortest path
|
|
1426
|
+
orbitApi.set({ orbitAngle: currentAngle });
|
|
1427
|
+
orbitApi.start({ orbitAngle: currentAngle + delta, config: animConfig });
|
|
1428
|
+
}, [camera, computeCurrentAngle, orbitApi]);
|
|
1429
|
+
|
|
1430
|
+
// Rotate by relative degrees (positive = clockwise, negative = counter-clockwise)
|
|
1431
|
+
const rotateBy = useCallback((degrees: number, options?: RotateOptions) => {
|
|
1432
|
+
// Get current state
|
|
1433
|
+
const centerX = controlsRef.current?.target.x ?? 0;
|
|
1434
|
+
const centerZ = controlsRef.current?.target.z ?? 0;
|
|
1435
|
+
const currentAngle = computeCurrentAngle();
|
|
1436
|
+
const distance = Math.sqrt(
|
|
1437
|
+
Math.pow(camera.position.x - centerX, 2) +
|
|
1438
|
+
Math.pow(camera.position.z - centerZ, 2)
|
|
1439
|
+
);
|
|
1440
|
+
const height = camera.position.y;
|
|
1441
|
+
|
|
1442
|
+
// Store orbit parameters
|
|
1443
|
+
orbitParamsRef.current = { centerX, centerZ, distance, height };
|
|
1444
|
+
|
|
1445
|
+
// Build animation config
|
|
1446
|
+
const animConfig = options?.duration
|
|
1447
|
+
? { duration: options.duration }
|
|
1448
|
+
: { tension: 80, friction: 18 };
|
|
1449
|
+
|
|
1450
|
+
// Animate from current angle by the specified degrees
|
|
1451
|
+
orbitApi.set({ orbitAngle: currentAngle });
|
|
1452
|
+
orbitApi.start({ orbitAngle: currentAngle + degrees, config: animConfig });
|
|
1453
|
+
}, [camera, computeCurrentAngle, orbitApi]);
|
|
1454
|
+
|
|
1455
|
+
// Convert tilt preset to angle
|
|
1456
|
+
const tiltPresetToAngle = (preset: 'top' | 'level' | 'high' | 'low'): number => {
|
|
1457
|
+
switch (preset) {
|
|
1458
|
+
case 'top': return 15; // Near top-down
|
|
1459
|
+
case 'high': return 35; // High angle
|
|
1460
|
+
case 'low': return 60; // Low angle
|
|
1461
|
+
case 'level': return 80; // Near horizontal
|
|
1462
|
+
}
|
|
1463
|
+
};
|
|
1464
|
+
|
|
1465
|
+
// Compute current tilt angle (polar angle in degrees)
|
|
1466
|
+
const computeCurrentTilt = useCallback(() => {
|
|
1467
|
+
const centerX = controlsRef.current?.target.x ?? 0;
|
|
1468
|
+
const centerY = controlsRef.current?.target.y ?? 0;
|
|
1469
|
+
const centerZ = controlsRef.current?.target.z ?? 0;
|
|
1470
|
+
|
|
1471
|
+
const dx = camera.position.x - centerX;
|
|
1472
|
+
const dy = camera.position.y - centerY;
|
|
1473
|
+
const dz = camera.position.z - centerZ;
|
|
1474
|
+
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
1475
|
+
|
|
1476
|
+
if (distance === 0) return 45; // Default
|
|
1477
|
+
|
|
1478
|
+
// Polar angle: arccos(dy / distance)
|
|
1479
|
+
const polarRadians = Math.acos(dy / distance);
|
|
1480
|
+
return (polarRadians * 180) / Math.PI;
|
|
1481
|
+
}, [camera]);
|
|
1482
|
+
|
|
1483
|
+
// Tilt to absolute angle or preset
|
|
1484
|
+
const tiltTo = useCallback((
|
|
1485
|
+
angleOrPreset: number | 'top' | 'level' | 'high' | 'low',
|
|
1486
|
+
options?: RotateOptions
|
|
1487
|
+
) => {
|
|
1488
|
+
const targetTilt = typeof angleOrPreset === 'number'
|
|
1489
|
+
? angleOrPreset
|
|
1490
|
+
: tiltPresetToAngle(angleOrPreset);
|
|
1491
|
+
|
|
1492
|
+
// Get current state
|
|
1493
|
+
const centerX = controlsRef.current?.target.x ?? 0;
|
|
1494
|
+
const centerY = controlsRef.current?.target.y ?? 0;
|
|
1495
|
+
const centerZ = controlsRef.current?.target.z ?? 0;
|
|
1496
|
+
|
|
1497
|
+
const dx = camera.position.x - centerX;
|
|
1498
|
+
const dy = camera.position.y - centerY;
|
|
1499
|
+
const dz = camera.position.z - centerZ;
|
|
1500
|
+
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
1501
|
+
const currentTilt = computeCurrentTilt();
|
|
1502
|
+
const azimuthAngle = computeCurrentAngle();
|
|
1503
|
+
|
|
1504
|
+
// Store tilt parameters
|
|
1505
|
+
tiltParamsRef.current = { centerX, centerY, centerZ, distance, azimuthAngle };
|
|
1506
|
+
|
|
1507
|
+
// Build animation config
|
|
1508
|
+
const animConfig = options?.duration
|
|
1509
|
+
? { duration: options.duration }
|
|
1510
|
+
: { tension: 80, friction: 18 };
|
|
1511
|
+
|
|
1512
|
+
// Animate from current tilt to target
|
|
1513
|
+
tiltApi.set({ tiltAngle: currentTilt });
|
|
1514
|
+
tiltApi.start({ tiltAngle: targetTilt, config: animConfig });
|
|
1515
|
+
}, [camera, computeCurrentTilt, computeCurrentAngle, tiltApi]);
|
|
1516
|
+
|
|
1517
|
+
// Tilt by relative degrees (positive = down towards top-down, negative = up towards level)
|
|
1518
|
+
const tiltBy = useCallback((degrees: number, options?: RotateOptions) => {
|
|
1519
|
+
// Get current state
|
|
1520
|
+
const centerX = controlsRef.current?.target.x ?? 0;
|
|
1521
|
+
const centerY = controlsRef.current?.target.y ?? 0;
|
|
1522
|
+
const centerZ = controlsRef.current?.target.z ?? 0;
|
|
1523
|
+
|
|
1524
|
+
const dx = camera.position.x - centerX;
|
|
1525
|
+
const dy = camera.position.y - centerY;
|
|
1526
|
+
const dz = camera.position.z - centerZ;
|
|
1527
|
+
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
1528
|
+
const currentTilt = computeCurrentTilt();
|
|
1529
|
+
const azimuthAngle = computeCurrentAngle();
|
|
1530
|
+
|
|
1531
|
+
// Store tilt parameters
|
|
1532
|
+
tiltParamsRef.current = { centerX, centerY, centerZ, distance, azimuthAngle };
|
|
1533
|
+
|
|
1534
|
+
// Build animation config
|
|
1535
|
+
const animConfig = options?.duration
|
|
1536
|
+
? { duration: options.duration }
|
|
1537
|
+
: { tension: 80, friction: 18 };
|
|
1538
|
+
|
|
1539
|
+
// Animate from current tilt by the specified degrees
|
|
1540
|
+
tiltApi.set({ tiltAngle: currentTilt });
|
|
1541
|
+
tiltApi.start({ tiltAngle: currentTilt + degrees, config: animConfig });
|
|
1542
|
+
}, [camera, computeCurrentTilt, computeCurrentAngle, tiltApi]);
|
|
1543
|
+
|
|
1544
|
+
const getCurrentPosition = useCallback(() => {
|
|
1545
|
+
return {
|
|
1546
|
+
x: camera.position.x,
|
|
1547
|
+
y: camera.position.y,
|
|
1548
|
+
z: camera.position.z,
|
|
1549
|
+
};
|
|
1550
|
+
}, [camera]);
|
|
1551
|
+
|
|
1552
|
+
const getCurrentTarget = useCallback(() => {
|
|
1553
|
+
return {
|
|
1554
|
+
x: controlsRef.current?.target.x ?? 0,
|
|
1555
|
+
y: controlsRef.current?.target.y ?? 0,
|
|
1556
|
+
z: controlsRef.current?.target.z ?? 0,
|
|
1557
|
+
};
|
|
1558
|
+
}, []);
|
|
1559
|
+
|
|
1560
|
+
const getCurrentAngle = useCallback(() => {
|
|
1561
|
+
return computeCurrentAngle();
|
|
1562
|
+
}, [computeCurrentAngle]);
|
|
1563
|
+
|
|
1564
|
+
const getCurrentTilt = useCallback(() => {
|
|
1565
|
+
return computeCurrentTilt();
|
|
1566
|
+
}, [computeCurrentTilt]);
|
|
1567
|
+
|
|
1118
1568
|
useEffect(() => {
|
|
1119
|
-
|
|
1569
|
+
cameraApi = {
|
|
1570
|
+
reset: resetToInitial,
|
|
1571
|
+
moveTo,
|
|
1572
|
+
setTarget,
|
|
1573
|
+
rotateTo,
|
|
1574
|
+
rotateBy,
|
|
1575
|
+
tiltTo,
|
|
1576
|
+
tiltBy,
|
|
1577
|
+
getCurrentPosition,
|
|
1578
|
+
getCurrentTarget,
|
|
1579
|
+
getCurrentAngle,
|
|
1580
|
+
getCurrentTilt,
|
|
1581
|
+
};
|
|
1120
1582
|
return () => {
|
|
1121
|
-
|
|
1583
|
+
cameraApi = null;
|
|
1122
1584
|
};
|
|
1123
|
-
}, [resetToInitial]);
|
|
1585
|
+
}, [resetToInitial, moveTo, setTarget, rotateTo, rotateBy, tiltTo, tiltBy, getCurrentPosition, getCurrentTarget, getCurrentAngle, getCurrentTilt]);
|
|
1124
1586
|
|
|
1125
1587
|
return (
|
|
1126
1588
|
<>
|
|
@@ -1147,9 +1609,6 @@ function InfoPanel({ building }: InfoPanelProps) {
|
|
|
1147
1609
|
|
|
1148
1610
|
const fileName = building.path.split('/').pop();
|
|
1149
1611
|
const dirPath = building.path.split('/').slice(0, -1).join('/');
|
|
1150
|
-
const rawExt = building.fileExtension || building.path.split('.').pop() || '';
|
|
1151
|
-
const ext = rawExt.replace(/^\./, '');
|
|
1152
|
-
const isCode = isCodeFile(ext);
|
|
1153
1612
|
|
|
1154
1613
|
return (
|
|
1155
1614
|
<div
|
|
@@ -1243,6 +1702,8 @@ interface CitySceneProps {
|
|
|
1243
1702
|
heightScaling: HeightScaling;
|
|
1244
1703
|
linearScale: number;
|
|
1245
1704
|
focusDirectory: string | null;
|
|
1705
|
+
focusColor?: string | null;
|
|
1706
|
+
adaptCameraToBuildings?: boolean;
|
|
1246
1707
|
}
|
|
1247
1708
|
|
|
1248
1709
|
function CityScene({
|
|
@@ -1258,6 +1719,8 @@ function CityScene({
|
|
|
1258
1719
|
heightScaling,
|
|
1259
1720
|
linearScale,
|
|
1260
1721
|
focusDirectory,
|
|
1722
|
+
focusColor,
|
|
1723
|
+
adaptCameraToBuildings = false,
|
|
1261
1724
|
}: CitySceneProps) {
|
|
1262
1725
|
const centerOffset = useMemo(
|
|
1263
1726
|
() => ({
|
|
@@ -1272,6 +1735,12 @@ function CityScene({
|
|
|
1272
1735
|
cityData.bounds.maxZ - cityData.bounds.minZ,
|
|
1273
1736
|
);
|
|
1274
1737
|
|
|
1738
|
+
// Calculate max building height for camera positioning (when adaptCameraToBuildings is true)
|
|
1739
|
+
const maxBuildingHeight = useMemo(() => {
|
|
1740
|
+
if (!adaptCameraToBuildings) return 0;
|
|
1741
|
+
return Math.max(...cityData.buildings.map(b => b.dimensions[1]), 0);
|
|
1742
|
+
}, [adaptCameraToBuildings, cityData.buildings]);
|
|
1743
|
+
|
|
1275
1744
|
const activeHighlights = useMemo(() => hasActiveHighlights(highlightLayers), [highlightLayers]);
|
|
1276
1745
|
|
|
1277
1746
|
// Helper to check if a path is inside a directory
|
|
@@ -1285,10 +1754,12 @@ function CityScene({
|
|
|
1285
1754
|
// Phase 2: Buildings collapse/expand
|
|
1286
1755
|
// Phase 3: Camera zooms into new directory
|
|
1287
1756
|
//
|
|
1288
|
-
// We track
|
|
1757
|
+
// We track three separate states for smooth transitions:
|
|
1289
1758
|
// - buildingFocusDirectory: controls which buildings are collapsed (passed to InstancedBuildings)
|
|
1759
|
+
// - buildingFocusColor: the color for the focused district (synced with buildingFocusDirectory)
|
|
1290
1760
|
// - cameraFocusDirectory: controls camera position (used for focusTarget calculation)
|
|
1291
1761
|
const [buildingFocusDirectory, setBuildingFocusDirectory] = useState<string | null>(null);
|
|
1762
|
+
const [buildingFocusColor, setBuildingFocusColor] = useState<string | null>(null);
|
|
1292
1763
|
const [cameraFocusDirectory, setCameraFocusDirectory] = useState<string | null>(null);
|
|
1293
1764
|
const prevFocusDirectoryRef = useRef<string | null>(null);
|
|
1294
1765
|
const animationTimersRef = useRef<NodeJS.Timeout[]>([]);
|
|
@@ -1306,25 +1777,60 @@ function CityScene({
|
|
|
1306
1777
|
|
|
1307
1778
|
// Case 1: Going from overview to a directory (null -> dir)
|
|
1308
1779
|
if (prevFocus === null && focusDirectory !== null) {
|
|
1309
|
-
//
|
|
1780
|
+
// Check if camera is already focused on this area via highlight layers
|
|
1781
|
+
const highlightMatchesFocus = highlightLayers.some(
|
|
1782
|
+
layer => layer.enabled && layer.items.some(
|
|
1783
|
+
item => item.type === 'directory' && (
|
|
1784
|
+
item.path === focusDirectory ||
|
|
1785
|
+
focusDirectory.startsWith(item.path + '/')
|
|
1786
|
+
)
|
|
1787
|
+
)
|
|
1788
|
+
);
|
|
1789
|
+
|
|
1790
|
+
// Phase 1: Collapse buildings immediately with the new color
|
|
1310
1791
|
setBuildingFocusDirectory(focusDirectory);
|
|
1311
|
-
|
|
1312
|
-
|
|
1792
|
+
setBuildingFocusColor(focusColor ?? null);
|
|
1793
|
+
|
|
1794
|
+
if (highlightMatchesFocus) {
|
|
1795
|
+
// Camera is already there, set immediately
|
|
1313
1796
|
setCameraFocusDirectory(focusDirectory);
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1797
|
+
} else {
|
|
1798
|
+
// Phase 2: After collapse settles, zoom camera in
|
|
1799
|
+
const timer = setTimeout(() => {
|
|
1800
|
+
setCameraFocusDirectory(focusDirectory);
|
|
1801
|
+
}, 600);
|
|
1802
|
+
animationTimersRef.current.push(timer);
|
|
1803
|
+
}
|
|
1316
1804
|
return;
|
|
1317
1805
|
}
|
|
1318
1806
|
|
|
1319
1807
|
// Case 2: Going from a directory to overview (dir -> null)
|
|
1320
1808
|
if (prevFocus !== null && focusDirectory === null) {
|
|
1321
|
-
//
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1809
|
+
// Check if highlight layers will keep camera focused on same area
|
|
1810
|
+
const highlightMatchesPrevFocus = highlightLayers.some(
|
|
1811
|
+
layer => layer.enabled && layer.items.some(
|
|
1812
|
+
item => item.type === 'directory' && (
|
|
1813
|
+
item.path === prevFocus ||
|
|
1814
|
+
prevFocus.startsWith(item.path + '/')
|
|
1815
|
+
)
|
|
1816
|
+
)
|
|
1817
|
+
);
|
|
1818
|
+
|
|
1819
|
+
if (highlightMatchesPrevFocus) {
|
|
1820
|
+
// Camera will stay focused via highlights, just clear focus state
|
|
1821
|
+
setCameraFocusDirectory(null);
|
|
1325
1822
|
setBuildingFocusDirectory(null);
|
|
1326
|
-
|
|
1327
|
-
|
|
1823
|
+
setBuildingFocusColor(null);
|
|
1824
|
+
} else {
|
|
1825
|
+
// Phase 1: Zoom camera out first
|
|
1826
|
+
setCameraFocusDirectory(null);
|
|
1827
|
+
// Phase 2: After zoom-out settles, expand buildings and clear color
|
|
1828
|
+
const timer = setTimeout(() => {
|
|
1829
|
+
setBuildingFocusDirectory(null);
|
|
1830
|
+
setBuildingFocusColor(null);
|
|
1831
|
+
}, 500);
|
|
1832
|
+
animationTimersRef.current.push(timer);
|
|
1833
|
+
}
|
|
1328
1834
|
return;
|
|
1329
1835
|
}
|
|
1330
1836
|
|
|
@@ -1332,9 +1838,10 @@ function CityScene({
|
|
|
1332
1838
|
if (prevFocus !== null && focusDirectory !== null) {
|
|
1333
1839
|
// Phase 1: Zoom camera out
|
|
1334
1840
|
setCameraFocusDirectory(null);
|
|
1335
|
-
// Phase 2: After zoom-out, collapse/expand buildings
|
|
1841
|
+
// Phase 2: After zoom-out, collapse/expand buildings with new color
|
|
1336
1842
|
const timer1 = setTimeout(() => {
|
|
1337
1843
|
setBuildingFocusDirectory(focusDirectory);
|
|
1844
|
+
setBuildingFocusColor(focusColor ?? null);
|
|
1338
1845
|
}, 500);
|
|
1339
1846
|
// Phase 3: After collapse settles, zoom camera into new directory
|
|
1340
1847
|
const timer2 = setTimeout(() => {
|
|
@@ -1465,7 +1972,7 @@ function CityScene({
|
|
|
1465
1972
|
|
|
1466
1973
|
return (
|
|
1467
1974
|
<>
|
|
1468
|
-
<AnimatedCamera citySize={citySize} isFlat={growProgress === 0} focusTarget={focusTarget} />
|
|
1975
|
+
<AnimatedCamera citySize={citySize} isFlat={growProgress === 0} focusTarget={focusTarget} maxBuildingHeight={maxBuildingHeight} />
|
|
1469
1976
|
|
|
1470
1977
|
<ambientLight intensity={1.2} />
|
|
1471
1978
|
<hemisphereLight args={['#ddeeff', '#667788', 0.8]} position={[0, citySize, 0]} />
|
|
@@ -1481,14 +1988,39 @@ function CityScene({
|
|
|
1481
1988
|
/>
|
|
1482
1989
|
<directionalLight position={[citySize * 0.3, citySize, citySize]} intensity={0.6} />
|
|
1483
1990
|
|
|
1484
|
-
{cityData.districts.map(district =>
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
district
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1991
|
+
{cityData.districts.map(district => {
|
|
1992
|
+
// Check if district matches focusDirectory
|
|
1993
|
+
const isFocused = buildingFocusDirectory
|
|
1994
|
+
? district.path === buildingFocusDirectory
|
|
1995
|
+
: false;
|
|
1996
|
+
|
|
1997
|
+
// Check if district matches any highlight layer
|
|
1998
|
+
let highlightLayerColor: string | null = null;
|
|
1999
|
+
for (const layer of highlightLayers) {
|
|
2000
|
+
if (!layer.enabled) continue;
|
|
2001
|
+
for (const item of layer.items) {
|
|
2002
|
+
if (item.type === 'directory' && item.path === district.path) {
|
|
2003
|
+
highlightLayerColor = layer.color;
|
|
2004
|
+
break;
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
if (highlightLayerColor) break;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// Use buildingFocusColor (synced with animation) instead of focusColor prop
|
|
2011
|
+
// Focus color takes priority, then highlight layer color
|
|
2012
|
+
const districtColor = (isFocused && buildingFocusColor) ? buildingFocusColor : highlightLayerColor;
|
|
2013
|
+
|
|
2014
|
+
return (
|
|
2015
|
+
<DistrictFloor
|
|
2016
|
+
key={district.path}
|
|
2017
|
+
district={district}
|
|
2018
|
+
centerOffset={centerOffset}
|
|
2019
|
+
opacity={1}
|
|
2020
|
+
highlightColor={districtColor}
|
|
2021
|
+
/>
|
|
2022
|
+
);
|
|
2023
|
+
})}
|
|
1492
2024
|
|
|
1493
2025
|
<InstancedBuildings
|
|
1494
2026
|
buildings={cityData.buildings}
|
|
@@ -1567,6 +2099,8 @@ export interface FileCity3DProps {
|
|
|
1567
2099
|
linearScale?: number;
|
|
1568
2100
|
/** Directory path to focus on - buildings outside will collapse */
|
|
1569
2101
|
focusDirectory?: string | null;
|
|
2102
|
+
/** Color to highlight the focused directory (hex color, e.g. "#3b82f6") */
|
|
2103
|
+
focusColor?: string | null;
|
|
1570
2104
|
/** Callback when user clicks on a district to navigate */
|
|
1571
2105
|
onDirectorySelect?: (directory: string | null) => void;
|
|
1572
2106
|
/** Background color for the canvas container */
|
|
@@ -1575,6 +2109,8 @@ export interface FileCity3DProps {
|
|
|
1575
2109
|
textColor?: string;
|
|
1576
2110
|
/** Currently selected building (controlled by host) */
|
|
1577
2111
|
selectedBuilding?: CityBuilding | null;
|
|
2112
|
+
/** When true, camera height adjusts based on tallest building when grown */
|
|
2113
|
+
adaptCameraToBuildings?: boolean;
|
|
1578
2114
|
}
|
|
1579
2115
|
|
|
1580
2116
|
/**
|
|
@@ -1596,17 +2132,19 @@ export function FileCity3D({
|
|
|
1596
2132
|
showControls = true,
|
|
1597
2133
|
highlightLayers = [],
|
|
1598
2134
|
isolationMode = 'transparent',
|
|
1599
|
-
dimOpacity = 0.15,
|
|
2135
|
+
dimOpacity: _dimOpacity = 0.15,
|
|
1600
2136
|
isLoading = false,
|
|
1601
2137
|
loadingMessage = 'Loading file city...',
|
|
1602
2138
|
emptyMessage = 'No file tree data available',
|
|
1603
2139
|
heightScaling = 'logarithmic',
|
|
1604
2140
|
linearScale = 0.05,
|
|
1605
2141
|
focusDirectory = null,
|
|
1606
|
-
|
|
2142
|
+
focusColor = null,
|
|
2143
|
+
onDirectorySelect: _onDirectorySelect,
|
|
1607
2144
|
backgroundColor = '#0f172a',
|
|
1608
2145
|
textColor = '#94a3b8',
|
|
1609
2146
|
selectedBuilding = null,
|
|
2147
|
+
adaptCameraToBuildings = false,
|
|
1610
2148
|
}: FileCity3DProps) {
|
|
1611
2149
|
const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(null);
|
|
1612
2150
|
const [internalIsGrown, setInternalIsGrown] = useState(false);
|
|
@@ -1628,6 +2166,7 @@ export function FileCity3D({
|
|
|
1628
2166
|
} else if (!animationConfig.startFlat) {
|
|
1629
2167
|
setIsGrown(true);
|
|
1630
2168
|
}
|
|
2169
|
+
return undefined;
|
|
1631
2170
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1632
2171
|
}, [animationConfig.startFlat, animationConfig.autoStartDelay]);
|
|
1633
2172
|
|
|
@@ -1720,6 +2259,8 @@ export function FileCity3D({
|
|
|
1720
2259
|
heightScaling={heightScaling}
|
|
1721
2260
|
linearScale={linearScale}
|
|
1722
2261
|
focusDirectory={focusDirectory}
|
|
2262
|
+
focusColor={focusColor}
|
|
2263
|
+
adaptCameraToBuildings={adaptCameraToBuildings}
|
|
1723
2264
|
/>
|
|
1724
2265
|
</Canvas>
|
|
1725
2266
|
<InfoPanel building={selectedBuilding || hoveredBuilding} />
|