@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.
@@ -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,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(5, Math.min(85, currentTilt));
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
- <OrbitControls
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 maxBuildingHeight changes
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: 16,
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 isFlat={!isGrown} onToggle={handleToggle} onResetCamera={resetCamera} />
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';
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