@principal-ai/file-city-react 0.5.37 → 0.5.39
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 +77 -1
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +149 -14
- 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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/components/FileCity3D/FileCity3D.tsx +308 -8
- package/src/components/FileCity3D/index.ts +7 -0
- package/src/index.ts +1 -0
- package/src/stories/FileCity3D.stories.tsx +669 -0
- package/src/stories/ScopeOverlay.stories.tsx +1687 -0
|
@@ -11,7 +11,7 @@ import React, { useMemo, useRef, useState, useEffect, useCallback } from 'react'
|
|
|
11
11
|
import { Canvas, useFrame, ThreeEvent, useThree } from '@react-three/fiber';
|
|
12
12
|
|
|
13
13
|
import { useSpring } from '@react-spring/three';
|
|
14
|
-
import {
|
|
14
|
+
import { MapControls, PerspectiveCamera, Text } from '@react-three/drei';
|
|
15
15
|
import { getFileConfig } from '@principal-ai/file-city-builder';
|
|
16
16
|
import type {
|
|
17
17
|
CityData,
|
|
@@ -65,6 +65,38 @@ export interface AnimationConfig {
|
|
|
65
65
|
/** Height scaling mode for buildings */
|
|
66
66
|
export type HeightScaling = 'logarithmic' | 'linear';
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* An opaque slab rendered above the flat city to visualize scope coverage.
|
|
70
|
+
* Only renders when the city is in 2D (flat) mode — in 3D the buildings show
|
|
71
|
+
* through normally. When opaque, the slab's depth value occludes buildings and
|
|
72
|
+
* icons beneath its `bounds`, so the scope reads as a single colored tile.
|
|
73
|
+
*/
|
|
74
|
+
export interface ElevatedScopePanel {
|
|
75
|
+
/** Unique identifier (used as React key) */
|
|
76
|
+
id: string;
|
|
77
|
+
/** Hex color */
|
|
78
|
+
color: string;
|
|
79
|
+
/** 0–1 opacity. Default 1 (fully opaque). */
|
|
80
|
+
opacity?: number;
|
|
81
|
+
/** Y position (world units) above the ground when flat. Default 4. */
|
|
82
|
+
height?: number;
|
|
83
|
+
/** Slab thickness in world units (default 2) */
|
|
84
|
+
thickness?: number;
|
|
85
|
+
/** World-space bounds the slab covers */
|
|
86
|
+
bounds: { minX: number; maxX: number; minZ: number; maxZ: number };
|
|
87
|
+
/** Optional label rendered flat on top of the slab. */
|
|
88
|
+
label?: string;
|
|
89
|
+
/** Hex color for the label (default white). */
|
|
90
|
+
labelColor?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Absolute label font size in world units. When omitted, falls back to a
|
|
93
|
+
* size derived from the panel's footprint. Always clamped to fit the tile.
|
|
94
|
+
*/
|
|
95
|
+
labelSize?: number;
|
|
96
|
+
/** Click handler. When set, the slab becomes interactive and shows a pointer cursor. */
|
|
97
|
+
onClick?: () => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
68
100
|
/** Pattern for files that should render flat (e.g., lock files, generated files) */
|
|
69
101
|
export interface FlatPattern {
|
|
70
102
|
/** Glob-like pattern or regex to match file paths */
|
|
@@ -1308,6 +1340,7 @@ interface AnimatedCameraProps {
|
|
|
1308
1340
|
isFlat: boolean;
|
|
1309
1341
|
focusTarget?: FocusTarget | null;
|
|
1310
1342
|
maxBuildingHeight?: number;
|
|
1343
|
+
cameraControls?: CameraControlsConfig;
|
|
1311
1344
|
}
|
|
1312
1345
|
|
|
1313
1346
|
// Camera rotation options
|
|
@@ -1316,6 +1349,69 @@ export interface RotateOptions {
|
|
|
1316
1349
|
duration?: number;
|
|
1317
1350
|
}
|
|
1318
1351
|
|
|
1352
|
+
export type MouseDragAction = 'pan' | 'rotate' | 'zoom' | 'none';
|
|
1353
|
+
export type TouchOneAction = 'pan' | 'rotate' | 'none';
|
|
1354
|
+
export type TouchTwoAction = 'pan' | 'rotate' | 'dolly-pan' | 'dolly-rotate' | 'none';
|
|
1355
|
+
export type WheelAction = 'zoom' | 'pan';
|
|
1356
|
+
|
|
1357
|
+
export interface CameraControlsConfig {
|
|
1358
|
+
/** Left mouse button drag. Default: 'pan' */
|
|
1359
|
+
leftDrag?: MouseDragAction;
|
|
1360
|
+
/** Right mouse button drag. Default: 'rotate' */
|
|
1361
|
+
rightDrag?: MouseDragAction;
|
|
1362
|
+
/** Middle mouse button drag. Default: 'zoom' */
|
|
1363
|
+
middleDrag?: MouseDragAction;
|
|
1364
|
+
/** Mouse wheel / two-finger trackpad scroll. Default: 'zoom'.
|
|
1365
|
+
* When 'pan', ctrl/⌘+wheel still zooms (matches trackpad pinch). */
|
|
1366
|
+
wheel?: WheelAction;
|
|
1367
|
+
/** One-finger touch. Default: 'pan' */
|
|
1368
|
+
oneFingerTouch?: TouchOneAction;
|
|
1369
|
+
/** Two-finger touch. Default: 'dolly-pan' */
|
|
1370
|
+
twoFingerTouch?: TouchTwoAction;
|
|
1371
|
+
/** Pan speed multiplier. Default: 1 */
|
|
1372
|
+
panSpeed?: number;
|
|
1373
|
+
/** Rotate speed multiplier. Default: 1 */
|
|
1374
|
+
rotateSpeed?: number;
|
|
1375
|
+
/** Zoom speed multiplier. Default: 1 */
|
|
1376
|
+
zoomSpeed?: number;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
export const DEFAULT_CAMERA_CONTROLS: Required<Omit<CameraControlsConfig, 'panSpeed' | 'rotateSpeed' | 'zoomSpeed'>> & Pick<CameraControlsConfig, 'panSpeed' | 'rotateSpeed' | 'zoomSpeed'> = {
|
|
1380
|
+
leftDrag: 'pan',
|
|
1381
|
+
rightDrag: 'rotate',
|
|
1382
|
+
middleDrag: 'zoom',
|
|
1383
|
+
wheel: 'pan',
|
|
1384
|
+
oneFingerTouch: 'pan',
|
|
1385
|
+
twoFingerTouch: 'dolly-pan',
|
|
1386
|
+
};
|
|
1387
|
+
|
|
1388
|
+
function mouseAction(action: MouseDragAction): number | undefined {
|
|
1389
|
+
switch (action) {
|
|
1390
|
+
case 'pan': return THREE.MOUSE.PAN;
|
|
1391
|
+
case 'rotate': return THREE.MOUSE.ROTATE;
|
|
1392
|
+
case 'zoom': return THREE.MOUSE.DOLLY;
|
|
1393
|
+
case 'none': return undefined;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function touchOneAction(action: TouchOneAction): number | undefined {
|
|
1398
|
+
switch (action) {
|
|
1399
|
+
case 'pan': return THREE.TOUCH.PAN;
|
|
1400
|
+
case 'rotate': return THREE.TOUCH.ROTATE;
|
|
1401
|
+
case 'none': return undefined;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
function touchTwoAction(action: TouchTwoAction): number | undefined {
|
|
1406
|
+
switch (action) {
|
|
1407
|
+
case 'pan': return THREE.TOUCH.PAN;
|
|
1408
|
+
case 'rotate': return THREE.TOUCH.ROTATE;
|
|
1409
|
+
case 'dolly-pan': return THREE.TOUCH.DOLLY_PAN;
|
|
1410
|
+
case 'dolly-rotate': return THREE.TOUCH.DOLLY_ROTATE;
|
|
1411
|
+
case 'none': return undefined;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1319
1415
|
// Camera control API - populated by AnimatedCamera
|
|
1320
1416
|
interface CameraApi {
|
|
1321
1417
|
reset: () => void;
|
|
@@ -1430,11 +1526,17 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
|
1430
1526
|
isFlat,
|
|
1431
1527
|
focusTarget,
|
|
1432
1528
|
maxBuildingHeight = 0,
|
|
1529
|
+
cameraControls,
|
|
1433
1530
|
onCameraReady,
|
|
1434
1531
|
}: AnimatedCameraProps & { onCameraReady?: () => void }) {
|
|
1435
1532
|
// Use selector to only subscribe to camera, not the entire R3F state
|
|
1436
1533
|
// This prevents re-renders on pointer movement
|
|
1437
1534
|
const camera = useThree((state) => state.camera);
|
|
1535
|
+
const gl = useThree((state) => state.gl);
|
|
1536
|
+
const controlsConfig = useMemo(
|
|
1537
|
+
() => ({ ...DEFAULT_CAMERA_CONTROLS, ...cameraControls }),
|
|
1538
|
+
[cameraControls],
|
|
1539
|
+
);
|
|
1438
1540
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1439
1541
|
const controlsRef = useRef<any>(null);
|
|
1440
1542
|
const isAnimatingRef = useRef(false);
|
|
@@ -1686,7 +1788,7 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
|
1686
1788
|
|
|
1687
1789
|
// Convert tilt angle to polar angle (0° tilt = looking down, 90° tilt = level)
|
|
1688
1790
|
// Clamp to avoid extreme angles
|
|
1689
|
-
const clampedTilt = Math.max(
|
|
1791
|
+
const clampedTilt = Math.max(0, Math.min(85, currentTilt));
|
|
1690
1792
|
const polarRadians = (clampedTilt * Math.PI) / 180;
|
|
1691
1793
|
const azimuthRadians = (azimuthAngle * Math.PI) / 180;
|
|
1692
1794
|
|
|
@@ -2000,26 +2102,99 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
|
2000
2102
|
};
|
|
2001
2103
|
}, [resetToInitial, moveTo, setTarget, rotateTo, rotateBy, tiltTo, tiltBy, getCurrentPosition, getCurrentTarget, getCurrentAngle, getCurrentTilt]);
|
|
2002
2104
|
|
|
2105
|
+
// Custom wheel handler for wheel === 'pan'. We disable MapControls' built-in
|
|
2106
|
+
// zoom (otherwise it competes with our handler) and handle both axes here:
|
|
2107
|
+
// ctrl/⌘+wheel = zoom (matches trackpad pinch), plain wheel = pan along the
|
|
2108
|
+
// camera-relative ground plane.
|
|
2109
|
+
useEffect(() => {
|
|
2110
|
+
if (controlsConfig.wheel !== 'pan') return;
|
|
2111
|
+
const canvas = gl.domElement;
|
|
2112
|
+
const right = new THREE.Vector3();
|
|
2113
|
+
const forward = new THREE.Vector3();
|
|
2114
|
+
const offset = new THREE.Vector3();
|
|
2115
|
+
const direction = new THREE.Vector3();
|
|
2116
|
+
const panSpeed = controlsConfig.panSpeed ?? 1;
|
|
2117
|
+
const zoomSpeed = controlsConfig.zoomSpeed ?? 1;
|
|
2118
|
+
|
|
2119
|
+
const onWheel = (e: WheelEvent) => {
|
|
2120
|
+
const controls = controlsRef.current;
|
|
2121
|
+
if (!controls) return;
|
|
2122
|
+
e.preventDefault();
|
|
2123
|
+
const target = controls.target as THREE.Vector3;
|
|
2124
|
+
|
|
2125
|
+
if (e.ctrlKey || e.metaKey) {
|
|
2126
|
+
direction.subVectors(camera.position, target);
|
|
2127
|
+
const distance = direction.length();
|
|
2128
|
+
const scale = Math.exp(e.deltaY * 0.01 * zoomSpeed);
|
|
2129
|
+
const minD = controls.minDistance ?? 0;
|
|
2130
|
+
const maxD = controls.maxDistance ?? Infinity;
|
|
2131
|
+
const newDistance = Math.min(Math.max(distance * scale, minD), maxD);
|
|
2132
|
+
direction.normalize().multiplyScalar(newDistance);
|
|
2133
|
+
camera.position.copy(target).add(direction);
|
|
2134
|
+
controls.update();
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
const distance = camera.position.distanceTo(target);
|
|
2139
|
+
const factor = distance * 0.0015 * panSpeed;
|
|
2140
|
+
|
|
2141
|
+
camera.getWorldDirection(forward);
|
|
2142
|
+
forward.y = 0;
|
|
2143
|
+
if (forward.lengthSq() < 1e-6) forward.set(0, 0, -1);
|
|
2144
|
+
forward.normalize();
|
|
2145
|
+
right.crossVectors(forward, camera.up).normalize();
|
|
2146
|
+
|
|
2147
|
+
offset.set(0, 0, 0);
|
|
2148
|
+
offset.addScaledVector(right, e.deltaX * factor);
|
|
2149
|
+
offset.addScaledVector(forward, -e.deltaY * factor);
|
|
2150
|
+
|
|
2151
|
+
camera.position.add(offset);
|
|
2152
|
+
target.add(offset);
|
|
2153
|
+
controls.update();
|
|
2154
|
+
};
|
|
2155
|
+
|
|
2156
|
+
canvas.addEventListener('wheel', onWheel, { passive: false });
|
|
2157
|
+
return () => canvas.removeEventListener('wheel', onWheel);
|
|
2158
|
+
}, [camera, gl, controlsConfig.wheel, controlsConfig.panSpeed, controlsConfig.zoomSpeed]);
|
|
2159
|
+
|
|
2160
|
+
const mouseButtons = useMemo(() => ({
|
|
2161
|
+
LEFT: mouseAction(controlsConfig.leftDrag),
|
|
2162
|
+
MIDDLE: mouseAction(controlsConfig.middleDrag),
|
|
2163
|
+
RIGHT: mouseAction(controlsConfig.rightDrag),
|
|
2164
|
+
}), [controlsConfig.leftDrag, controlsConfig.middleDrag, controlsConfig.rightDrag]);
|
|
2165
|
+
|
|
2166
|
+
const touches = useMemo(() => ({
|
|
2167
|
+
ONE: touchOneAction(controlsConfig.oneFingerTouch),
|
|
2168
|
+
TWO: touchTwoAction(controlsConfig.twoFingerTouch),
|
|
2169
|
+
}), [controlsConfig.oneFingerTouch, controlsConfig.twoFingerTouch]);
|
|
2170
|
+
|
|
2003
2171
|
return (
|
|
2004
2172
|
<>
|
|
2005
2173
|
<PerspectiveCamera makeDefault fov={50} near={1} far={citySize * 10} />
|
|
2006
|
-
<
|
|
2174
|
+
<MapControls
|
|
2007
2175
|
ref={controlsRef}
|
|
2008
2176
|
enableDamping
|
|
2009
2177
|
dampingFactor={0.05}
|
|
2010
2178
|
minDistance={10}
|
|
2011
2179
|
maxDistance={citySize * 3}
|
|
2012
2180
|
maxPolarAngle={Math.PI / 2.1}
|
|
2181
|
+
mouseButtons={mouseButtons}
|
|
2182
|
+
touches={touches}
|
|
2183
|
+
enableZoom={controlsConfig.wheel !== 'pan'}
|
|
2184
|
+
panSpeed={controlsConfig.panSpeed ?? 1}
|
|
2185
|
+
rotateSpeed={controlsConfig.rotateSpeed ?? 1}
|
|
2186
|
+
zoomSpeed={controlsConfig.zoomSpeed ?? 1}
|
|
2013
2187
|
/>
|
|
2014
2188
|
</>
|
|
2015
2189
|
);
|
|
2016
2190
|
}, (prevProps, nextProps) => {
|
|
2017
|
-
// Custom comparison: only re-render if isFlat, citySize, or
|
|
2191
|
+
// Custom comparison: only re-render if isFlat, citySize, maxBuildingHeight, or cameraControls changes
|
|
2018
2192
|
// Skip re-render when only focusTarget changes (handled internally by useEffect on isFlat)
|
|
2019
2193
|
return (
|
|
2020
2194
|
prevProps.isFlat === nextProps.isFlat &&
|
|
2021
2195
|
prevProps.citySize === nextProps.citySize &&
|
|
2022
|
-
prevProps.maxBuildingHeight === nextProps.maxBuildingHeight
|
|
2196
|
+
prevProps.maxBuildingHeight === nextProps.maxBuildingHeight &&
|
|
2197
|
+
prevProps.cameraControls === nextProps.cameraControls
|
|
2023
2198
|
);
|
|
2024
2199
|
});
|
|
2025
2200
|
|
|
@@ -2039,7 +2214,7 @@ function InfoPanel({ building }: InfoPanelProps) {
|
|
|
2039
2214
|
style={{
|
|
2040
2215
|
position: 'absolute',
|
|
2041
2216
|
bottom: 16,
|
|
2042
|
-
left:
|
|
2217
|
+
left: 60,
|
|
2043
2218
|
background: 'rgba(15, 23, 42, 0.9)',
|
|
2044
2219
|
border: '1px solid #334155',
|
|
2045
2220
|
borderRadius: 8,
|
|
@@ -2076,9 +2251,10 @@ interface ControlsOverlayProps {
|
|
|
2076
2251
|
isFlat: boolean;
|
|
2077
2252
|
onToggle: () => void;
|
|
2078
2253
|
onResetCamera: () => void;
|
|
2254
|
+
onLookDown: () => void;
|
|
2079
2255
|
}
|
|
2080
2256
|
|
|
2081
|
-
function ControlsOverlay({ isFlat, onToggle, onResetCamera }: ControlsOverlayProps) {
|
|
2257
|
+
function ControlsOverlay({ isFlat, onToggle, onResetCamera, onLookDown }: ControlsOverlayProps) {
|
|
2082
2258
|
const buttonStyle = {
|
|
2083
2259
|
background: 'rgba(15, 23, 42, 0.9)',
|
|
2084
2260
|
border: '1px solid #334155',
|
|
@@ -2123,6 +2299,20 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera }: ControlsOverlayPro
|
|
|
2123
2299
|
>
|
|
2124
2300
|
↻
|
|
2125
2301
|
</button>
|
|
2302
|
+
|
|
2303
|
+
{/* Look Down - Bottom Left */}
|
|
2304
|
+
<button
|
|
2305
|
+
onClick={onLookDown}
|
|
2306
|
+
style={{
|
|
2307
|
+
...buttonStyle,
|
|
2308
|
+
position: 'absolute',
|
|
2309
|
+
bottom: 8,
|
|
2310
|
+
left: 8,
|
|
2311
|
+
}}
|
|
2312
|
+
title="Look down"
|
|
2313
|
+
>
|
|
2314
|
+
⬇
|
|
2315
|
+
</button>
|
|
2126
2316
|
</>
|
|
2127
2317
|
);
|
|
2128
2318
|
}
|
|
@@ -2144,6 +2334,8 @@ interface CitySceneProps {
|
|
|
2144
2334
|
focusDirectory: string | null;
|
|
2145
2335
|
focusColor?: string | null;
|
|
2146
2336
|
adaptCameraToBuildings?: boolean;
|
|
2337
|
+
elevatedScopePanels?: ElevatedScopePanel[];
|
|
2338
|
+
cameraControls?: CameraControlsConfig;
|
|
2147
2339
|
}
|
|
2148
2340
|
|
|
2149
2341
|
function CityScene({
|
|
@@ -2162,6 +2354,8 @@ function CityScene({
|
|
|
2162
2354
|
focusDirectory,
|
|
2163
2355
|
focusColor,
|
|
2164
2356
|
adaptCameraToBuildings = false,
|
|
2357
|
+
elevatedScopePanels,
|
|
2358
|
+
cameraControls,
|
|
2165
2359
|
onCameraReady,
|
|
2166
2360
|
}: CitySceneProps & { onCameraReady?: () => void }) {
|
|
2167
2361
|
const centerOffset = useMemo(
|
|
@@ -2381,6 +2575,7 @@ function CityScene({
|
|
|
2381
2575
|
isFlat={growProgress === 0}
|
|
2382
2576
|
focusTarget={focusTarget}
|
|
2383
2577
|
maxBuildingHeight={maxBuildingHeight}
|
|
2578
|
+
cameraControls={cameraControls}
|
|
2384
2579
|
onCameraReady={onCameraReady}
|
|
2385
2580
|
/>
|
|
2386
2581
|
|
|
@@ -2462,6 +2657,86 @@ function CityScene({
|
|
|
2462
2657
|
isolationMode={isolationMode}
|
|
2463
2658
|
hasActiveHighlights={activeHighlights}
|
|
2464
2659
|
/>
|
|
2660
|
+
|
|
2661
|
+
{growProgress === 0 &&
|
|
2662
|
+
elevatedScopePanels?.map(panel => {
|
|
2663
|
+
const cx = (panel.bounds.minX + panel.bounds.maxX) / 2 - centerOffset.x;
|
|
2664
|
+
const cz = (panel.bounds.minZ + panel.bounds.maxZ) / 2 - centerOffset.z;
|
|
2665
|
+
const w = Math.max(1, panel.bounds.maxX - panel.bounds.minX);
|
|
2666
|
+
const d = Math.max(1, panel.bounds.maxZ - panel.bounds.minZ);
|
|
2667
|
+
const t = panel.thickness ?? 2;
|
|
2668
|
+
const y = (panel.height ?? 4) + t / 2;
|
|
2669
|
+
const opacity = panel.opacity ?? 1;
|
|
2670
|
+
const isOpaque = opacity >= 1;
|
|
2671
|
+
const topY = y + t / 2;
|
|
2672
|
+
// Size text to the panel: roughly fit longest reasonable label,
|
|
2673
|
+
// clamped so tiny tiles still render legibly and huge ones don't
|
|
2674
|
+
// get absurd. Callers may override via panel.labelSize, but we
|
|
2675
|
+
// still cap to the tile footprint so the label fits.
|
|
2676
|
+
const tileMax = Math.min(w, d) / 2;
|
|
2677
|
+
const requested = panel.labelSize ?? Math.min(w, d) / 6;
|
|
2678
|
+
const labelSize = Math.max(4, Math.min(tileMax, requested));
|
|
2679
|
+
|
|
2680
|
+
const handleClick = panel.onClick
|
|
2681
|
+
? (e: ThreeEvent<MouseEvent>) => {
|
|
2682
|
+
e.stopPropagation();
|
|
2683
|
+
panel.onClick!();
|
|
2684
|
+
}
|
|
2685
|
+
: undefined;
|
|
2686
|
+
const handlePointerOver = panel.onClick
|
|
2687
|
+
? (e: ThreeEvent<PointerEvent>) => {
|
|
2688
|
+
e.stopPropagation();
|
|
2689
|
+
document.body.style.cursor = 'pointer';
|
|
2690
|
+
}
|
|
2691
|
+
: undefined;
|
|
2692
|
+
const handlePointerOut = panel.onClick
|
|
2693
|
+
? () => {
|
|
2694
|
+
document.body.style.cursor = '';
|
|
2695
|
+
}
|
|
2696
|
+
: undefined;
|
|
2697
|
+
|
|
2698
|
+
return (
|
|
2699
|
+
<group key={panel.id}>
|
|
2700
|
+
<mesh
|
|
2701
|
+
position={[cx, y, cz]}
|
|
2702
|
+
renderOrder={10}
|
|
2703
|
+
onClick={handleClick}
|
|
2704
|
+
onPointerOver={handlePointerOver}
|
|
2705
|
+
onPointerOut={handlePointerOut}
|
|
2706
|
+
>
|
|
2707
|
+
<boxGeometry args={[w, t, d]} />
|
|
2708
|
+
<meshBasicMaterial
|
|
2709
|
+
color={panel.color}
|
|
2710
|
+
transparent={!isOpaque}
|
|
2711
|
+
opacity={opacity}
|
|
2712
|
+
depthWrite={isOpaque}
|
|
2713
|
+
/>
|
|
2714
|
+
</mesh>
|
|
2715
|
+
{panel.label && (
|
|
2716
|
+
<Text
|
|
2717
|
+
position={[cx, topY + 0.05, cz]}
|
|
2718
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
2719
|
+
fontSize={labelSize}
|
|
2720
|
+
color={panel.labelColor ?? '#ffffff'}
|
|
2721
|
+
anchorX="center"
|
|
2722
|
+
anchorY="middle"
|
|
2723
|
+
maxWidth={w * 0.9}
|
|
2724
|
+
textAlign="center"
|
|
2725
|
+
renderOrder={11}
|
|
2726
|
+
frustumCulled={false}
|
|
2727
|
+
>
|
|
2728
|
+
{panel.label}
|
|
2729
|
+
<meshBasicMaterial
|
|
2730
|
+
attach="material"
|
|
2731
|
+
color={panel.labelColor ?? '#ffffff'}
|
|
2732
|
+
depthWrite={false}
|
|
2733
|
+
depthTest={false}
|
|
2734
|
+
/>
|
|
2735
|
+
</Text>
|
|
2736
|
+
)}
|
|
2737
|
+
</group>
|
|
2738
|
+
);
|
|
2739
|
+
})}
|
|
2465
2740
|
</>
|
|
2466
2741
|
);
|
|
2467
2742
|
}
|
|
@@ -2526,6 +2801,22 @@ export interface FileCity3DProps {
|
|
|
2526
2801
|
|
|
2527
2802
|
/** Base file type color layers (resolved with highlightLayers) */
|
|
2528
2803
|
fileColorLayers?: HighlightLayer[];
|
|
2804
|
+
|
|
2805
|
+
/**
|
|
2806
|
+
* Translucent slabs rendered above the city showing scope coverage as
|
|
2807
|
+
* elevated planes over the directories they own.
|
|
2808
|
+
*/
|
|
2809
|
+
elevatedScopePanels?: ElevatedScopePanel[];
|
|
2810
|
+
|
|
2811
|
+
/**
|
|
2812
|
+
* Configure how mouse / trackpad / touch input drives the camera.
|
|
2813
|
+
* Defaults match Google Maps style: left-drag pans, right-drag rotates,
|
|
2814
|
+
* wheel zooms. Set `wheel: 'pan'` to make trackpad two-finger scroll pan
|
|
2815
|
+
* (ctrl/⌘+wheel still zooms so pinch-zoom keeps working).
|
|
2816
|
+
*
|
|
2817
|
+
* Memoize this object to avoid unnecessary camera re-mounts.
|
|
2818
|
+
*/
|
|
2819
|
+
cameraControls?: CameraControlsConfig;
|
|
2529
2820
|
}
|
|
2530
2821
|
|
|
2531
2822
|
/**
|
|
@@ -2545,6 +2836,7 @@ export function FileCity3D({
|
|
|
2545
2836
|
isGrown: externalIsGrown,
|
|
2546
2837
|
onGrowChange,
|
|
2547
2838
|
showControls = false,
|
|
2839
|
+
elevatedScopePanels,
|
|
2548
2840
|
highlightLayers: externalHighlightLayers,
|
|
2549
2841
|
isolationMode: externalIsolationMode,
|
|
2550
2842
|
dimOpacity: _dimOpacity = 0.15,
|
|
@@ -2562,6 +2854,7 @@ export function FileCity3D({
|
|
|
2562
2854
|
selectedBuilding = null,
|
|
2563
2855
|
adaptCameraToBuildings = false,
|
|
2564
2856
|
fileColorLayers,
|
|
2857
|
+
cameraControls,
|
|
2565
2858
|
}: FileCity3DProps) {
|
|
2566
2859
|
const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(null);
|
|
2567
2860
|
const [internalIsGrown, setInternalIsGrown] = useState(false);
|
|
@@ -2718,12 +3011,19 @@ export function FileCity3D({
|
|
|
2718
3011
|
focusDirectory={focusDirectory}
|
|
2719
3012
|
focusColor={focusColor}
|
|
2720
3013
|
adaptCameraToBuildings={adaptCameraToBuildings}
|
|
3014
|
+
elevatedScopePanels={elevatedScopePanels}
|
|
3015
|
+
cameraControls={cameraControls}
|
|
2721
3016
|
onCameraReady={() => setCameraReady(true)}
|
|
2722
3017
|
/>
|
|
2723
3018
|
</Canvas>
|
|
2724
3019
|
<InfoPanel building={selectedBuilding} />
|
|
2725
3020
|
{showControls && (
|
|
2726
|
-
<ControlsOverlay
|
|
3021
|
+
<ControlsOverlay
|
|
3022
|
+
isFlat={!isGrown}
|
|
3023
|
+
onToggle={handleToggle}
|
|
3024
|
+
onResetCamera={resetCamera}
|
|
3025
|
+
onLookDown={() => tiltCameraTo(0)}
|
|
3026
|
+
/>
|
|
2727
3027
|
)}
|
|
2728
3028
|
</div>
|
|
2729
3029
|
);
|
|
@@ -15,6 +15,7 @@ export {
|
|
|
15
15
|
moveCameraTo,
|
|
16
16
|
setCameraTarget,
|
|
17
17
|
DEFAULT_FLAT_PATTERNS,
|
|
18
|
+
DEFAULT_CAMERA_CONTROLS,
|
|
18
19
|
} from './FileCity3D';
|
|
19
20
|
export type {
|
|
20
21
|
FileCity3DProps,
|
|
@@ -25,7 +26,13 @@ export type {
|
|
|
25
26
|
IsolationMode,
|
|
26
27
|
HeightScaling,
|
|
27
28
|
FlatPattern,
|
|
29
|
+
ElevatedScopePanel,
|
|
28
30
|
CityData,
|
|
29
31
|
CityBuilding,
|
|
30
32
|
CityDistrict,
|
|
33
|
+
CameraControlsConfig,
|
|
34
|
+
MouseDragAction,
|
|
35
|
+
TouchOneAction,
|
|
36
|
+
TouchTwoAction,
|
|
37
|
+
WheelAction,
|
|
31
38
|
} from './FileCity3D';
|