@principal-ai/file-city-react 0.5.36 → 0.5.38
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 +72 -1
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +148 -16
- 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 +300 -10
- 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 +1535 -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,33 @@ 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
|
+
/** Click handler. When set, the slab becomes interactive and shows a pointer cursor. */
|
|
92
|
+
onClick?: () => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
68
95
|
/** Pattern for files that should render flat (e.g., lock files, generated files) */
|
|
69
96
|
export interface FlatPattern {
|
|
70
97
|
/** Glob-like pattern or regex to match file paths */
|
|
@@ -1308,6 +1335,7 @@ interface AnimatedCameraProps {
|
|
|
1308
1335
|
isFlat: boolean;
|
|
1309
1336
|
focusTarget?: FocusTarget | null;
|
|
1310
1337
|
maxBuildingHeight?: number;
|
|
1338
|
+
cameraControls?: CameraControlsConfig;
|
|
1311
1339
|
}
|
|
1312
1340
|
|
|
1313
1341
|
// Camera rotation options
|
|
@@ -1316,6 +1344,69 @@ export interface RotateOptions {
|
|
|
1316
1344
|
duration?: number;
|
|
1317
1345
|
}
|
|
1318
1346
|
|
|
1347
|
+
export type MouseDragAction = 'pan' | 'rotate' | 'zoom' | 'none';
|
|
1348
|
+
export type TouchOneAction = 'pan' | 'rotate' | 'none';
|
|
1349
|
+
export type TouchTwoAction = 'pan' | 'rotate' | 'dolly-pan' | 'dolly-rotate' | 'none';
|
|
1350
|
+
export type WheelAction = 'zoom' | 'pan';
|
|
1351
|
+
|
|
1352
|
+
export interface CameraControlsConfig {
|
|
1353
|
+
/** Left mouse button drag. Default: 'pan' */
|
|
1354
|
+
leftDrag?: MouseDragAction;
|
|
1355
|
+
/** Right mouse button drag. Default: 'rotate' */
|
|
1356
|
+
rightDrag?: MouseDragAction;
|
|
1357
|
+
/** Middle mouse button drag. Default: 'zoom' */
|
|
1358
|
+
middleDrag?: MouseDragAction;
|
|
1359
|
+
/** Mouse wheel / two-finger trackpad scroll. Default: 'zoom'.
|
|
1360
|
+
* When 'pan', ctrl/⌘+wheel still zooms (matches trackpad pinch). */
|
|
1361
|
+
wheel?: WheelAction;
|
|
1362
|
+
/** One-finger touch. Default: 'pan' */
|
|
1363
|
+
oneFingerTouch?: TouchOneAction;
|
|
1364
|
+
/** Two-finger touch. Default: 'dolly-pan' */
|
|
1365
|
+
twoFingerTouch?: TouchTwoAction;
|
|
1366
|
+
/** Pan speed multiplier. Default: 1 */
|
|
1367
|
+
panSpeed?: number;
|
|
1368
|
+
/** Rotate speed multiplier. Default: 1 */
|
|
1369
|
+
rotateSpeed?: number;
|
|
1370
|
+
/** Zoom speed multiplier. Default: 1 */
|
|
1371
|
+
zoomSpeed?: number;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
export const DEFAULT_CAMERA_CONTROLS: Required<Omit<CameraControlsConfig, 'panSpeed' | 'rotateSpeed' | 'zoomSpeed'>> & Pick<CameraControlsConfig, 'panSpeed' | 'rotateSpeed' | 'zoomSpeed'> = {
|
|
1375
|
+
leftDrag: 'pan',
|
|
1376
|
+
rightDrag: 'rotate',
|
|
1377
|
+
middleDrag: 'zoom',
|
|
1378
|
+
wheel: 'pan',
|
|
1379
|
+
oneFingerTouch: 'pan',
|
|
1380
|
+
twoFingerTouch: 'dolly-pan',
|
|
1381
|
+
};
|
|
1382
|
+
|
|
1383
|
+
function mouseAction(action: MouseDragAction): number | undefined {
|
|
1384
|
+
switch (action) {
|
|
1385
|
+
case 'pan': return THREE.MOUSE.PAN;
|
|
1386
|
+
case 'rotate': return THREE.MOUSE.ROTATE;
|
|
1387
|
+
case 'zoom': return THREE.MOUSE.DOLLY;
|
|
1388
|
+
case 'none': return undefined;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function touchOneAction(action: TouchOneAction): number | undefined {
|
|
1393
|
+
switch (action) {
|
|
1394
|
+
case 'pan': return THREE.TOUCH.PAN;
|
|
1395
|
+
case 'rotate': return THREE.TOUCH.ROTATE;
|
|
1396
|
+
case 'none': return undefined;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function touchTwoAction(action: TouchTwoAction): number | undefined {
|
|
1401
|
+
switch (action) {
|
|
1402
|
+
case 'pan': return THREE.TOUCH.PAN;
|
|
1403
|
+
case 'rotate': return THREE.TOUCH.ROTATE;
|
|
1404
|
+
case 'dolly-pan': return THREE.TOUCH.DOLLY_PAN;
|
|
1405
|
+
case 'dolly-rotate': return THREE.TOUCH.DOLLY_ROTATE;
|
|
1406
|
+
case 'none': return undefined;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1319
1410
|
// Camera control API - populated by AnimatedCamera
|
|
1320
1411
|
interface CameraApi {
|
|
1321
1412
|
reset: () => void;
|
|
@@ -1430,11 +1521,17 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
|
1430
1521
|
isFlat,
|
|
1431
1522
|
focusTarget,
|
|
1432
1523
|
maxBuildingHeight = 0,
|
|
1524
|
+
cameraControls,
|
|
1433
1525
|
onCameraReady,
|
|
1434
1526
|
}: AnimatedCameraProps & { onCameraReady?: () => void }) {
|
|
1435
1527
|
// Use selector to only subscribe to camera, not the entire R3F state
|
|
1436
1528
|
// This prevents re-renders on pointer movement
|
|
1437
1529
|
const camera = useThree((state) => state.camera);
|
|
1530
|
+
const gl = useThree((state) => state.gl);
|
|
1531
|
+
const controlsConfig = useMemo(
|
|
1532
|
+
() => ({ ...DEFAULT_CAMERA_CONTROLS, ...cameraControls }),
|
|
1533
|
+
[cameraControls],
|
|
1534
|
+
);
|
|
1438
1535
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1439
1536
|
const controlsRef = useRef<any>(null);
|
|
1440
1537
|
const isAnimatingRef = useRef(false);
|
|
@@ -1686,7 +1783,7 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
|
1686
1783
|
|
|
1687
1784
|
// Convert tilt angle to polar angle (0° tilt = looking down, 90° tilt = level)
|
|
1688
1785
|
// Clamp to avoid extreme angles
|
|
1689
|
-
const clampedTilt = Math.max(
|
|
1786
|
+
const clampedTilt = Math.max(0, Math.min(85, currentTilt));
|
|
1690
1787
|
const polarRadians = (clampedTilt * Math.PI) / 180;
|
|
1691
1788
|
const azimuthRadians = (azimuthAngle * Math.PI) / 180;
|
|
1692
1789
|
|
|
@@ -2000,26 +2097,99 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
|
2000
2097
|
};
|
|
2001
2098
|
}, [resetToInitial, moveTo, setTarget, rotateTo, rotateBy, tiltTo, tiltBy, getCurrentPosition, getCurrentTarget, getCurrentAngle, getCurrentTilt]);
|
|
2002
2099
|
|
|
2100
|
+
// Custom wheel handler for wheel === 'pan'. We disable MapControls' built-in
|
|
2101
|
+
// zoom (otherwise it competes with our handler) and handle both axes here:
|
|
2102
|
+
// ctrl/⌘+wheel = zoom (matches trackpad pinch), plain wheel = pan along the
|
|
2103
|
+
// camera-relative ground plane.
|
|
2104
|
+
useEffect(() => {
|
|
2105
|
+
if (controlsConfig.wheel !== 'pan') return;
|
|
2106
|
+
const canvas = gl.domElement;
|
|
2107
|
+
const right = new THREE.Vector3();
|
|
2108
|
+
const forward = new THREE.Vector3();
|
|
2109
|
+
const offset = new THREE.Vector3();
|
|
2110
|
+
const direction = new THREE.Vector3();
|
|
2111
|
+
const panSpeed = controlsConfig.panSpeed ?? 1;
|
|
2112
|
+
const zoomSpeed = controlsConfig.zoomSpeed ?? 1;
|
|
2113
|
+
|
|
2114
|
+
const onWheel = (e: WheelEvent) => {
|
|
2115
|
+
const controls = controlsRef.current;
|
|
2116
|
+
if (!controls) return;
|
|
2117
|
+
e.preventDefault();
|
|
2118
|
+
const target = controls.target as THREE.Vector3;
|
|
2119
|
+
|
|
2120
|
+
if (e.ctrlKey || e.metaKey) {
|
|
2121
|
+
direction.subVectors(camera.position, target);
|
|
2122
|
+
const distance = direction.length();
|
|
2123
|
+
const scale = Math.exp(e.deltaY * 0.01 * zoomSpeed);
|
|
2124
|
+
const minD = controls.minDistance ?? 0;
|
|
2125
|
+
const maxD = controls.maxDistance ?? Infinity;
|
|
2126
|
+
const newDistance = Math.min(Math.max(distance * scale, minD), maxD);
|
|
2127
|
+
direction.normalize().multiplyScalar(newDistance);
|
|
2128
|
+
camera.position.copy(target).add(direction);
|
|
2129
|
+
controls.update();
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
const distance = camera.position.distanceTo(target);
|
|
2134
|
+
const factor = distance * 0.0015 * panSpeed;
|
|
2135
|
+
|
|
2136
|
+
camera.getWorldDirection(forward);
|
|
2137
|
+
forward.y = 0;
|
|
2138
|
+
if (forward.lengthSq() < 1e-6) forward.set(0, 0, -1);
|
|
2139
|
+
forward.normalize();
|
|
2140
|
+
right.crossVectors(forward, camera.up).normalize();
|
|
2141
|
+
|
|
2142
|
+
offset.set(0, 0, 0);
|
|
2143
|
+
offset.addScaledVector(right, e.deltaX * factor);
|
|
2144
|
+
offset.addScaledVector(forward, -e.deltaY * factor);
|
|
2145
|
+
|
|
2146
|
+
camera.position.add(offset);
|
|
2147
|
+
target.add(offset);
|
|
2148
|
+
controls.update();
|
|
2149
|
+
};
|
|
2150
|
+
|
|
2151
|
+
canvas.addEventListener('wheel', onWheel, { passive: false });
|
|
2152
|
+
return () => canvas.removeEventListener('wheel', onWheel);
|
|
2153
|
+
}, [camera, gl, controlsConfig.wheel, controlsConfig.panSpeed, controlsConfig.zoomSpeed]);
|
|
2154
|
+
|
|
2155
|
+
const mouseButtons = useMemo(() => ({
|
|
2156
|
+
LEFT: mouseAction(controlsConfig.leftDrag),
|
|
2157
|
+
MIDDLE: mouseAction(controlsConfig.middleDrag),
|
|
2158
|
+
RIGHT: mouseAction(controlsConfig.rightDrag),
|
|
2159
|
+
}), [controlsConfig.leftDrag, controlsConfig.middleDrag, controlsConfig.rightDrag]);
|
|
2160
|
+
|
|
2161
|
+
const touches = useMemo(() => ({
|
|
2162
|
+
ONE: touchOneAction(controlsConfig.oneFingerTouch),
|
|
2163
|
+
TWO: touchTwoAction(controlsConfig.twoFingerTouch),
|
|
2164
|
+
}), [controlsConfig.oneFingerTouch, controlsConfig.twoFingerTouch]);
|
|
2165
|
+
|
|
2003
2166
|
return (
|
|
2004
2167
|
<>
|
|
2005
2168
|
<PerspectiveCamera makeDefault fov={50} near={1} far={citySize * 10} />
|
|
2006
|
-
<
|
|
2169
|
+
<MapControls
|
|
2007
2170
|
ref={controlsRef}
|
|
2008
2171
|
enableDamping
|
|
2009
2172
|
dampingFactor={0.05}
|
|
2010
2173
|
minDistance={10}
|
|
2011
2174
|
maxDistance={citySize * 3}
|
|
2012
2175
|
maxPolarAngle={Math.PI / 2.1}
|
|
2176
|
+
mouseButtons={mouseButtons}
|
|
2177
|
+
touches={touches}
|
|
2178
|
+
enableZoom={controlsConfig.wheel !== 'pan'}
|
|
2179
|
+
panSpeed={controlsConfig.panSpeed ?? 1}
|
|
2180
|
+
rotateSpeed={controlsConfig.rotateSpeed ?? 1}
|
|
2181
|
+
zoomSpeed={controlsConfig.zoomSpeed ?? 1}
|
|
2013
2182
|
/>
|
|
2014
2183
|
</>
|
|
2015
2184
|
);
|
|
2016
2185
|
}, (prevProps, nextProps) => {
|
|
2017
|
-
// Custom comparison: only re-render if isFlat, citySize, or
|
|
2186
|
+
// Custom comparison: only re-render if isFlat, citySize, maxBuildingHeight, or cameraControls changes
|
|
2018
2187
|
// Skip re-render when only focusTarget changes (handled internally by useEffect on isFlat)
|
|
2019
2188
|
return (
|
|
2020
2189
|
prevProps.isFlat === nextProps.isFlat &&
|
|
2021
2190
|
prevProps.citySize === nextProps.citySize &&
|
|
2022
|
-
prevProps.maxBuildingHeight === nextProps.maxBuildingHeight
|
|
2191
|
+
prevProps.maxBuildingHeight === nextProps.maxBuildingHeight &&
|
|
2192
|
+
prevProps.cameraControls === nextProps.cameraControls
|
|
2023
2193
|
);
|
|
2024
2194
|
});
|
|
2025
2195
|
|
|
@@ -2039,7 +2209,7 @@ function InfoPanel({ building }: InfoPanelProps) {
|
|
|
2039
2209
|
style={{
|
|
2040
2210
|
position: 'absolute',
|
|
2041
2211
|
bottom: 16,
|
|
2042
|
-
left:
|
|
2212
|
+
left: 60,
|
|
2043
2213
|
background: 'rgba(15, 23, 42, 0.9)',
|
|
2044
2214
|
border: '1px solid #334155',
|
|
2045
2215
|
borderRadius: 8,
|
|
@@ -2076,9 +2246,10 @@ interface ControlsOverlayProps {
|
|
|
2076
2246
|
isFlat: boolean;
|
|
2077
2247
|
onToggle: () => void;
|
|
2078
2248
|
onResetCamera: () => void;
|
|
2249
|
+
onLookDown: () => void;
|
|
2079
2250
|
}
|
|
2080
2251
|
|
|
2081
|
-
function ControlsOverlay({ isFlat, onToggle, onResetCamera }: ControlsOverlayProps) {
|
|
2252
|
+
function ControlsOverlay({ isFlat, onToggle, onResetCamera, onLookDown }: ControlsOverlayProps) {
|
|
2082
2253
|
const buttonStyle = {
|
|
2083
2254
|
background: 'rgba(15, 23, 42, 0.9)',
|
|
2084
2255
|
border: '1px solid #334155',
|
|
@@ -2123,6 +2294,20 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera }: ControlsOverlayPro
|
|
|
2123
2294
|
>
|
|
2124
2295
|
↻
|
|
2125
2296
|
</button>
|
|
2297
|
+
|
|
2298
|
+
{/* Look Down - Bottom Left */}
|
|
2299
|
+
<button
|
|
2300
|
+
onClick={onLookDown}
|
|
2301
|
+
style={{
|
|
2302
|
+
...buttonStyle,
|
|
2303
|
+
position: 'absolute',
|
|
2304
|
+
bottom: 8,
|
|
2305
|
+
left: 8,
|
|
2306
|
+
}}
|
|
2307
|
+
title="Look down"
|
|
2308
|
+
>
|
|
2309
|
+
⬇
|
|
2310
|
+
</button>
|
|
2126
2311
|
</>
|
|
2127
2312
|
);
|
|
2128
2313
|
}
|
|
@@ -2144,6 +2329,8 @@ interface CitySceneProps {
|
|
|
2144
2329
|
focusDirectory: string | null;
|
|
2145
2330
|
focusColor?: string | null;
|
|
2146
2331
|
adaptCameraToBuildings?: boolean;
|
|
2332
|
+
elevatedScopePanels?: ElevatedScopePanel[];
|
|
2333
|
+
cameraControls?: CameraControlsConfig;
|
|
2147
2334
|
}
|
|
2148
2335
|
|
|
2149
2336
|
function CityScene({
|
|
@@ -2162,6 +2349,8 @@ function CityScene({
|
|
|
2162
2349
|
focusDirectory,
|
|
2163
2350
|
focusColor,
|
|
2164
2351
|
adaptCameraToBuildings = false,
|
|
2352
|
+
elevatedScopePanels,
|
|
2353
|
+
cameraControls,
|
|
2165
2354
|
onCameraReady,
|
|
2166
2355
|
}: CitySceneProps & { onCameraReady?: () => void }) {
|
|
2167
2356
|
const centerOffset = useMemo(
|
|
@@ -2172,7 +2361,7 @@ function CityScene({
|
|
|
2172
2361
|
[cityData.bounds],
|
|
2173
2362
|
);
|
|
2174
2363
|
|
|
2175
|
-
const citySize = Math.
|
|
2364
|
+
const citySize = Math.max(
|
|
2176
2365
|
cityData.bounds.maxX - cityData.bounds.minX,
|
|
2177
2366
|
cityData.bounds.maxZ - cityData.bounds.minZ,
|
|
2178
2367
|
);
|
|
@@ -2329,7 +2518,7 @@ function CityScene({
|
|
|
2329
2518
|
|
|
2330
2519
|
const centerX = (minX + maxX) / 2;
|
|
2331
2520
|
const centerZ = (minZ + maxZ) / 2;
|
|
2332
|
-
const size = Math.
|
|
2521
|
+
const size = Math.max(maxX - minX, maxZ - minZ);
|
|
2333
2522
|
|
|
2334
2523
|
return { x: centerX, z: centerZ, size };
|
|
2335
2524
|
}
|
|
@@ -2381,6 +2570,7 @@ function CityScene({
|
|
|
2381
2570
|
isFlat={growProgress === 0}
|
|
2382
2571
|
focusTarget={focusTarget}
|
|
2383
2572
|
maxBuildingHeight={maxBuildingHeight}
|
|
2573
|
+
cameraControls={cameraControls}
|
|
2384
2574
|
onCameraReady={onCameraReady}
|
|
2385
2575
|
/>
|
|
2386
2576
|
|
|
@@ -2462,6 +2652,81 @@ function CityScene({
|
|
|
2462
2652
|
isolationMode={isolationMode}
|
|
2463
2653
|
hasActiveHighlights={activeHighlights}
|
|
2464
2654
|
/>
|
|
2655
|
+
|
|
2656
|
+
{growProgress === 0 &&
|
|
2657
|
+
elevatedScopePanels?.map(panel => {
|
|
2658
|
+
const cx = (panel.bounds.minX + panel.bounds.maxX) / 2 - centerOffset.x;
|
|
2659
|
+
const cz = (panel.bounds.minZ + panel.bounds.maxZ) / 2 - centerOffset.z;
|
|
2660
|
+
const w = Math.max(1, panel.bounds.maxX - panel.bounds.minX);
|
|
2661
|
+
const d = Math.max(1, panel.bounds.maxZ - panel.bounds.minZ);
|
|
2662
|
+
const t = panel.thickness ?? 2;
|
|
2663
|
+
const y = (panel.height ?? 4) + t / 2;
|
|
2664
|
+
const opacity = panel.opacity ?? 1;
|
|
2665
|
+
const isOpaque = opacity >= 1;
|
|
2666
|
+
const topY = y + t / 2;
|
|
2667
|
+
// Size text to the panel: roughly fit longest reasonable label,
|
|
2668
|
+
// clamped so tiny tiles still render legibly and huge ones don't
|
|
2669
|
+
// get absurd.
|
|
2670
|
+
const labelSize = Math.max(4, Math.min(24, Math.min(w, d) / 6));
|
|
2671
|
+
|
|
2672
|
+
const handleClick = panel.onClick
|
|
2673
|
+
? (e: ThreeEvent<MouseEvent>) => {
|
|
2674
|
+
e.stopPropagation();
|
|
2675
|
+
panel.onClick!();
|
|
2676
|
+
}
|
|
2677
|
+
: undefined;
|
|
2678
|
+
const handlePointerOver = panel.onClick
|
|
2679
|
+
? (e: ThreeEvent<PointerEvent>) => {
|
|
2680
|
+
e.stopPropagation();
|
|
2681
|
+
document.body.style.cursor = 'pointer';
|
|
2682
|
+
}
|
|
2683
|
+
: undefined;
|
|
2684
|
+
const handlePointerOut = panel.onClick
|
|
2685
|
+
? () => {
|
|
2686
|
+
document.body.style.cursor = '';
|
|
2687
|
+
}
|
|
2688
|
+
: undefined;
|
|
2689
|
+
|
|
2690
|
+
return (
|
|
2691
|
+
<group key={panel.id}>
|
|
2692
|
+
<mesh
|
|
2693
|
+
position={[cx, y, cz]}
|
|
2694
|
+
renderOrder={10}
|
|
2695
|
+
onClick={handleClick}
|
|
2696
|
+
onPointerOver={handlePointerOver}
|
|
2697
|
+
onPointerOut={handlePointerOut}
|
|
2698
|
+
>
|
|
2699
|
+
<boxGeometry args={[w, t, d]} />
|
|
2700
|
+
<meshBasicMaterial
|
|
2701
|
+
color={panel.color}
|
|
2702
|
+
transparent={!isOpaque}
|
|
2703
|
+
opacity={opacity}
|
|
2704
|
+
depthWrite={isOpaque}
|
|
2705
|
+
/>
|
|
2706
|
+
</mesh>
|
|
2707
|
+
{panel.label && (
|
|
2708
|
+
<Text
|
|
2709
|
+
position={[cx, topY + 0.05, cz]}
|
|
2710
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
2711
|
+
fontSize={labelSize}
|
|
2712
|
+
color={panel.labelColor ?? '#ffffff'}
|
|
2713
|
+
anchorX="center"
|
|
2714
|
+
anchorY="middle"
|
|
2715
|
+
maxWidth={w * 0.9}
|
|
2716
|
+
textAlign="center"
|
|
2717
|
+
renderOrder={11}
|
|
2718
|
+
>
|
|
2719
|
+
{panel.label}
|
|
2720
|
+
<meshBasicMaterial
|
|
2721
|
+
attach="material"
|
|
2722
|
+
color={panel.labelColor ?? '#ffffff'}
|
|
2723
|
+
depthWrite={false}
|
|
2724
|
+
/>
|
|
2725
|
+
</Text>
|
|
2726
|
+
)}
|
|
2727
|
+
</group>
|
|
2728
|
+
);
|
|
2729
|
+
})}
|
|
2465
2730
|
</>
|
|
2466
2731
|
);
|
|
2467
2732
|
}
|
|
@@ -2526,6 +2791,22 @@ export interface FileCity3DProps {
|
|
|
2526
2791
|
|
|
2527
2792
|
/** Base file type color layers (resolved with highlightLayers) */
|
|
2528
2793
|
fileColorLayers?: HighlightLayer[];
|
|
2794
|
+
|
|
2795
|
+
/**
|
|
2796
|
+
* Translucent slabs rendered above the city showing scope coverage as
|
|
2797
|
+
* elevated planes over the directories they own.
|
|
2798
|
+
*/
|
|
2799
|
+
elevatedScopePanels?: ElevatedScopePanel[];
|
|
2800
|
+
|
|
2801
|
+
/**
|
|
2802
|
+
* Configure how mouse / trackpad / touch input drives the camera.
|
|
2803
|
+
* Defaults match Google Maps style: left-drag pans, right-drag rotates,
|
|
2804
|
+
* wheel zooms. Set `wheel: 'pan'` to make trackpad two-finger scroll pan
|
|
2805
|
+
* (ctrl/⌘+wheel still zooms so pinch-zoom keeps working).
|
|
2806
|
+
*
|
|
2807
|
+
* Memoize this object to avoid unnecessary camera re-mounts.
|
|
2808
|
+
*/
|
|
2809
|
+
cameraControls?: CameraControlsConfig;
|
|
2529
2810
|
}
|
|
2530
2811
|
|
|
2531
2812
|
/**
|
|
@@ -2545,6 +2826,7 @@ export function FileCity3D({
|
|
|
2545
2826
|
isGrown: externalIsGrown,
|
|
2546
2827
|
onGrowChange,
|
|
2547
2828
|
showControls = false,
|
|
2829
|
+
elevatedScopePanels,
|
|
2548
2830
|
highlightLayers: externalHighlightLayers,
|
|
2549
2831
|
isolationMode: externalIsolationMode,
|
|
2550
2832
|
dimOpacity: _dimOpacity = 0.15,
|
|
@@ -2562,6 +2844,7 @@ export function FileCity3D({
|
|
|
2562
2844
|
selectedBuilding = null,
|
|
2563
2845
|
adaptCameraToBuildings = false,
|
|
2564
2846
|
fileColorLayers,
|
|
2847
|
+
cameraControls,
|
|
2565
2848
|
}: FileCity3DProps) {
|
|
2566
2849
|
const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(null);
|
|
2567
2850
|
const [internalIsGrown, setInternalIsGrown] = useState(false);
|
|
@@ -2718,12 +3001,19 @@ export function FileCity3D({
|
|
|
2718
3001
|
focusDirectory={focusDirectory}
|
|
2719
3002
|
focusColor={focusColor}
|
|
2720
3003
|
adaptCameraToBuildings={adaptCameraToBuildings}
|
|
3004
|
+
elevatedScopePanels={elevatedScopePanels}
|
|
3005
|
+
cameraControls={cameraControls}
|
|
2721
3006
|
onCameraReady={() => setCameraReady(true)}
|
|
2722
3007
|
/>
|
|
2723
3008
|
</Canvas>
|
|
2724
3009
|
<InfoPanel building={selectedBuilding} />
|
|
2725
3010
|
{showControls && (
|
|
2726
|
-
<ControlsOverlay
|
|
3011
|
+
<ControlsOverlay
|
|
3012
|
+
isFlat={!isGrown}
|
|
3013
|
+
onToggle={handleToggle}
|
|
3014
|
+
onResetCamera={resetCamera}
|
|
3015
|
+
onLookDown={() => tiltCameraTo(0)}
|
|
3016
|
+
/>
|
|
2727
3017
|
)}
|
|
2728
3018
|
</div>
|
|
2729
3019
|
);
|
|
@@ -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';
|