@principal-ai/file-city-react 0.5.37 → 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.
@@ -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 { OrbitControls, PerspectiveCamera, Text } from '@react-three/drei';
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(5, Math.min(85, currentTilt));
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
- <OrbitControls
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 maxBuildingHeight changes
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: 16,
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(
@@ -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 isFlat={!isGrown} onToggle={handleToggle} onResetCamera={resetCamera} />
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';
package/src/index.ts CHANGED
@@ -80,6 +80,7 @@ export type {
80
80
  IsolationMode,
81
81
  HeightScaling,
82
82
  FlatPattern,
83
+ ElevatedScopePanel,
83
84
  } from './components/FileCity3D';
84
85
 
85
86
  // Re-export HighlightLayer from FileCity3D with distinct name to avoid conflict