@principal-ai/file-city-react 0.5.41 → 0.5.43

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.
@@ -41,6 +41,14 @@ declare module 'react' {
41
41
  export type { CityData, CityBuilding, CityDistrict, LayerItem, LayerRenderStrategy };
42
42
  export type HighlightLayer = BuilderHighlightLayer;
43
43
 
44
+ /** Visual style for the `selectedPath` ring on a directory. */
45
+ export interface SelectionStyle {
46
+ /** Ring color. Defaults to the theme accent. */
47
+ color?: string;
48
+ /** Ring border width in world units. Default: 2. */
49
+ borderWidth?: number;
50
+ }
51
+
44
52
  /** What to do with non-highlighted buildings */
45
53
  export type IsolationMode =
46
54
  | 'none' // Show all buildings normally
@@ -2394,6 +2402,279 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera, onLookDown }: Contro
2394
2402
  );
2395
2403
  }
2396
2404
 
2405
+ // Distance (world units) the panel lifts upward when dismissed. Reads as
2406
+ // "toward the camera" because the camera looks down in flat mode.
2407
+ const PANEL_DISMISS_LIFT = 60;
2408
+
2409
+ interface ElevatedScopePanelMeshProps {
2410
+ panel: ElevatedScopePanel;
2411
+ centerOffset: { x: number; z: number };
2412
+ dismissing: boolean;
2413
+ onDismissed?: (id: string) => void;
2414
+ }
2415
+
2416
+ // Dismiss animation duration in seconds.
2417
+ const PANEL_DISMISS_DURATION = 0.7;
2418
+
2419
+ function ElevatedScopePanelMesh({
2420
+ panel,
2421
+ centerOffset,
2422
+ dismissing,
2423
+ onDismissed,
2424
+ }: ElevatedScopePanelMeshProps) {
2425
+ const cx = (panel.bounds.minX + panel.bounds.maxX) / 2 - centerOffset.x;
2426
+ const cz = (panel.bounds.minZ + panel.bounds.maxZ) / 2 - centerOffset.z;
2427
+ const w = Math.max(1, panel.bounds.maxX - panel.bounds.minX);
2428
+ const d = Math.max(1, panel.bounds.maxZ - panel.bounds.minZ);
2429
+ const t = panel.thickness ?? 2;
2430
+ const y = (panel.height ?? 4) + t / 2;
2431
+ const baseOpacity = panel.opacity ?? 1;
2432
+ const isOpaqueStatic = baseOpacity >= 1;
2433
+ const topY = y + t / 2;
2434
+ const tileMax = Math.min(w, d) / 2;
2435
+ const requested = panel.labelSize ?? Math.min(w, d) / 6;
2436
+ const labelSize = Math.max(4, Math.min(tileMax, requested));
2437
+
2438
+ // Drive the dismiss animation directly via useFrame + Three.js refs
2439
+ // rather than react-spring. React re-renders (e.g., from FileCity3D's
2440
+ // hover state on mouse-move) were disturbing the spring-driven
2441
+ // animation; mutating the Three.js objects directly keeps the
2442
+ // animation isolated from React's render cycle.
2443
+ const groupRef = useRef<THREE.Group>(null);
2444
+ const slabMaterialRef = useRef<THREE.MeshBasicMaterial>(null);
2445
+ const labelMaterialRefs = useRef<THREE.MeshBasicMaterial[]>([]);
2446
+ const startTimeRef = useRef<number | null>(null);
2447
+ const finishedRef = useRef(false);
2448
+ const [fullyDismissed, setFullyDismissed] = useState(false);
2449
+
2450
+ useFrame(({ clock }) => {
2451
+ if (!dismissing || finishedRef.current) return;
2452
+ if (startTimeRef.current === null) {
2453
+ startTimeRef.current = clock.elapsedTime;
2454
+ }
2455
+ const elapsed = clock.elapsedTime - startTimeRef.current;
2456
+ const t = Math.min(elapsed / PANEL_DISMISS_DURATION, 1);
2457
+ // ease-out cubic
2458
+ const eased = 1 - Math.pow(1 - t, 3);
2459
+
2460
+ if (groupRef.current) {
2461
+ groupRef.current.position.y = eased * PANEL_DISMISS_LIFT;
2462
+ }
2463
+ const opacityNow = baseOpacity * (1 - eased);
2464
+ if (slabMaterialRef.current) {
2465
+ slabMaterialRef.current.opacity = opacityNow;
2466
+ }
2467
+ for (const mat of labelMaterialRefs.current) {
2468
+ if (mat) mat.opacity = opacityNow;
2469
+ }
2470
+
2471
+ if (t >= 1) {
2472
+ finishedRef.current = true;
2473
+ setFullyDismissed(true);
2474
+ onDismissed?.(panel.id);
2475
+ }
2476
+ });
2477
+
2478
+ if (fullyDismissed) return null;
2479
+
2480
+ // Always transparent on the slab so animated opacity actually blends.
2481
+ // (Three.js skips alpha blending entirely when transparent=false, which
2482
+ // makes the fade invisible until the prop flips mid-animation.)
2483
+ // depthWrite stays true while the panel is at full opacity so it still
2484
+ // occludes buildings beneath; we drop it once dismissal starts so the
2485
+ // fading panel doesn't punch a hole in the scene.
2486
+ const slabDepthWrite = isOpaqueStatic && !dismissing;
2487
+
2488
+ const interactive = Boolean(panel.onClick || panel.onDoubleClick);
2489
+ const handleClick = panel.onClick
2490
+ ? (e: ThreeEvent<MouseEvent>) => {
2491
+ e.stopPropagation();
2492
+ panel.onClick!(e.nativeEvent);
2493
+ }
2494
+ : undefined;
2495
+ const handleDoubleClick = panel.onDoubleClick
2496
+ ? (e: ThreeEvent<MouseEvent>) => {
2497
+ e.stopPropagation();
2498
+ panel.onDoubleClick!(e.nativeEvent);
2499
+ }
2500
+ : undefined;
2501
+ const handlePointerOver = interactive
2502
+ ? (e: ThreeEvent<PointerEvent>) => {
2503
+ e.stopPropagation();
2504
+ document.body.style.cursor = 'pointer';
2505
+ }
2506
+ : undefined;
2507
+ const handlePointerOut = interactive
2508
+ ? () => {
2509
+ document.body.style.cursor = '';
2510
+ }
2511
+ : undefined;
2512
+
2513
+ const labelColor = panel.labelColor ?? '#ffffff';
2514
+ const displayLabelColor = panel.displayLabelColor ?? panel.labelColor ?? '#ffffff';
2515
+
2516
+ // Reset the array each render — the ref callbacks below will repopulate
2517
+ // it. This avoids stale entries if labels come and go.
2518
+ labelMaterialRefs.current = [];
2519
+ const captureLabelMat = (mat: THREE.MeshBasicMaterial | null) => {
2520
+ if (mat) labelMaterialRefs.current.push(mat);
2521
+ };
2522
+
2523
+ return (
2524
+ <group ref={groupRef}>
2525
+ <mesh
2526
+ position={[cx, y, cz]}
2527
+ renderOrder={10}
2528
+ onClick={dismissing ? undefined : handleClick}
2529
+ onDoubleClick={dismissing ? undefined : handleDoubleClick}
2530
+ onPointerOver={dismissing ? undefined : handlePointerOver}
2531
+ onPointerOut={dismissing ? undefined : handlePointerOut}
2532
+ >
2533
+ <boxGeometry args={[w, t, d]} />
2534
+ <meshBasicMaterial
2535
+ ref={slabMaterialRef}
2536
+ color={panel.color}
2537
+ transparent
2538
+ opacity={baseOpacity}
2539
+ depthWrite={slabDepthWrite}
2540
+ />
2541
+ </mesh>
2542
+ {panel.displayLabel && (
2543
+ <>
2544
+ <Text
2545
+ position={[cx, topY + 0.05, cz - labelSize * 0.6]}
2546
+ rotation={[-Math.PI / 2, 0, 0]}
2547
+ fontSize={labelSize}
2548
+ color={displayLabelColor}
2549
+ anchorX="center"
2550
+ anchorY="middle"
2551
+ maxWidth={w * 0.9}
2552
+ textAlign="center"
2553
+ renderOrder={11}
2554
+ frustumCulled={false}
2555
+ >
2556
+ {panel.displayLabel}
2557
+ <meshBasicMaterial
2558
+ ref={captureLabelMat}
2559
+ attach="material"
2560
+ color={displayLabelColor}
2561
+ depthWrite={false}
2562
+ depthTest={false}
2563
+ transparent
2564
+ opacity={baseOpacity}
2565
+ />
2566
+ </Text>
2567
+ <mesh
2568
+ position={[cx, topY + 0.06, cz - labelSize * 0.05]}
2569
+ rotation={[-Math.PI / 2, 0, 0]}
2570
+ renderOrder={11}
2571
+ >
2572
+ <planeGeometry
2573
+ args={[
2574
+ Math.min(w * 0.9, panel.displayLabel.length * labelSize * 0.55),
2575
+ labelSize * 0.06,
2576
+ ]}
2577
+ />
2578
+ <meshBasicMaterial
2579
+ ref={captureLabelMat}
2580
+ color={displayLabelColor}
2581
+ depthWrite={false}
2582
+ depthTest={false}
2583
+ transparent
2584
+ opacity={baseOpacity}
2585
+ />
2586
+ </mesh>
2587
+ </>
2588
+ )}
2589
+ {panel.label && (
2590
+ <Text
2591
+ position={[cx, topY + 0.05, cz + (panel.displayLabel ? labelSize * 0.6 : 0)]}
2592
+ rotation={[-Math.PI / 2, 0, 0]}
2593
+ fontSize={labelSize}
2594
+ color={labelColor}
2595
+ anchorX="center"
2596
+ anchorY="middle"
2597
+ maxWidth={w * 0.9}
2598
+ textAlign="center"
2599
+ renderOrder={11}
2600
+ frustumCulled={false}
2601
+ >
2602
+ {panel.label}
2603
+ <meshBasicMaterial
2604
+ ref={captureLabelMat}
2605
+ attach="material"
2606
+ color={labelColor}
2607
+ depthWrite={false}
2608
+ depthTest={false}
2609
+ transparent
2610
+ opacity={baseOpacity}
2611
+ />
2612
+ </Text>
2613
+ )}
2614
+ </group>
2615
+ );
2616
+ }
2617
+
2618
+ interface SelectionRingProps {
2619
+ district: CityDistrict;
2620
+ centerOffset: { x: number; z: number };
2621
+ color: string;
2622
+ borderWidth: number;
2623
+ growProgress: number;
2624
+ }
2625
+
2626
+ // Lifted just above the default umbrella topY (height 4 + thickness 2 → 6)
2627
+ // so the ring isn't occluded by an `ElevatedScopePanel` covering the same
2628
+ // district when the city is flat.
2629
+ const SELECTION_RING_FLAT_Y = 7;
2630
+
2631
+ function SelectionRing({
2632
+ district,
2633
+ centerOffset,
2634
+ color,
2635
+ borderWidth,
2636
+ growProgress,
2637
+ }: SelectionRingProps) {
2638
+ const { worldBounds } = district;
2639
+ const inflate = 1;
2640
+ const minX = worldBounds.minX - inflate;
2641
+ const maxX = worldBounds.maxX + inflate;
2642
+ const minZ = worldBounds.minZ - inflate;
2643
+ const maxZ = worldBounds.maxZ + inflate;
2644
+ const cx = (minX + maxX) / 2 - centerOffset.x;
2645
+ const cz = (minZ + maxZ) / 2 - centerOffset.z;
2646
+ const w = maxX - minX;
2647
+ const d = maxZ - minZ;
2648
+
2649
+ const pathDepth = district.path.split('/').length;
2650
+ const groundY = -5 - pathDepth * 0.1 + 0.2;
2651
+ const y = SELECTION_RING_FLAT_Y + (groundY - SELECTION_RING_FLAT_Y) * growProgress;
2652
+
2653
+ const t = Math.max(0.5, borderWidth);
2654
+ const barH = 0.5;
2655
+
2656
+ return (
2657
+ <group position={[cx, y, cz]}>
2658
+ <mesh position={[0, 0, -d / 2]} renderOrder={20}>
2659
+ <boxGeometry args={[w + t, barH, t]} />
2660
+ <meshBasicMaterial color={color} transparent opacity={0.95} />
2661
+ </mesh>
2662
+ <mesh position={[0, 0, d / 2]} renderOrder={20}>
2663
+ <boxGeometry args={[w + t, barH, t]} />
2664
+ <meshBasicMaterial color={color} transparent opacity={0.95} />
2665
+ </mesh>
2666
+ <mesh position={[-w / 2, 0, 0]} renderOrder={20}>
2667
+ <boxGeometry args={[t, barH, d + t]} />
2668
+ <meshBasicMaterial color={color} transparent opacity={0.95} />
2669
+ </mesh>
2670
+ <mesh position={[w / 2, 0, 0]} renderOrder={20}>
2671
+ <boxGeometry args={[t, barH, d + t]} />
2672
+ <meshBasicMaterial color={color} transparent opacity={0.95} />
2673
+ </mesh>
2674
+ </group>
2675
+ );
2676
+ }
2677
+
2397
2678
  // Main scene component
2398
2679
  interface CitySceneProps {
2399
2680
  cityData: CityData;
@@ -2401,6 +2682,8 @@ interface CitySceneProps {
2401
2682
  onBuildingClick?: (building: CityBuilding, event: MouseEvent) => void;
2402
2683
  hoveredBuilding: CityBuilding | null;
2403
2684
  selectedBuilding: CityBuilding | null;
2685
+ selectedDistrict: CityDistrict | null;
2686
+ selectionStyle?: SelectionStyle;
2404
2687
  growProgress: number;
2405
2688
  animationConfig: AnimationConfig;
2406
2689
  highlightLayers: HighlightLayer[];
@@ -2412,6 +2695,8 @@ interface CitySceneProps {
2412
2695
  focusColor?: string | null;
2413
2696
  adaptCameraToBuildings?: boolean;
2414
2697
  elevatedScopePanels?: ElevatedScopePanel[];
2698
+ dismissingPanelIds?: ReadonlySet<string>;
2699
+ onPanelDismissed?: (id: string) => void;
2415
2700
  cameraControls?: CameraControlsConfig;
2416
2701
  }
2417
2702
 
@@ -2421,6 +2706,8 @@ function CityScene({
2421
2706
  onBuildingClick,
2422
2707
  hoveredBuilding,
2423
2708
  selectedBuilding,
2709
+ selectedDistrict,
2710
+ selectionStyle,
2424
2711
  growProgress,
2425
2712
  animationConfig,
2426
2713
  highlightLayers,
@@ -2432,6 +2719,8 @@ function CityScene({
2432
2719
  focusColor,
2433
2720
  adaptCameraToBuildings = false,
2434
2721
  elevatedScopePanels,
2722
+ dismissingPanelIds,
2723
+ onPanelDismissed,
2435
2724
  cameraControls,
2436
2725
  onCameraReady,
2437
2726
  }: CitySceneProps & { onCameraReady?: () => void }) {
@@ -2754,137 +3043,25 @@ function CityScene({
2754
3043
  />
2755
3044
 
2756
3045
  {growProgress === 0 &&
2757
- elevatedScopePanels?.map(panel => {
2758
- const cx = (panel.bounds.minX + panel.bounds.maxX) / 2 - centerOffset.x;
2759
- const cz = (panel.bounds.minZ + panel.bounds.maxZ) / 2 - centerOffset.z;
2760
- const w = Math.max(1, panel.bounds.maxX - panel.bounds.minX);
2761
- const d = Math.max(1, panel.bounds.maxZ - panel.bounds.minZ);
2762
- const t = panel.thickness ?? 2;
2763
- const y = (panel.height ?? 4) + t / 2;
2764
- const opacity = panel.opacity ?? 1;
2765
- const isOpaque = opacity >= 1;
2766
- const topY = y + t / 2;
2767
- // Size text to the panel: roughly fit longest reasonable label,
2768
- // clamped so tiny tiles still render legibly and huge ones don't
2769
- // get absurd. Callers may override via panel.labelSize, but we
2770
- // still cap to the tile footprint so the label fits.
2771
- const tileMax = Math.min(w, d) / 2;
2772
- const requested = panel.labelSize ?? Math.min(w, d) / 6;
2773
- const labelSize = Math.max(4, Math.min(tileMax, requested));
2774
-
2775
- const interactive = Boolean(panel.onClick || panel.onDoubleClick);
2776
- const handleClick = panel.onClick
2777
- ? (e: ThreeEvent<MouseEvent>) => {
2778
- e.stopPropagation();
2779
- panel.onClick!(e.nativeEvent);
2780
- }
2781
- : undefined;
2782
- const handleDoubleClick = panel.onDoubleClick
2783
- ? (e: ThreeEvent<MouseEvent>) => {
2784
- e.stopPropagation();
2785
- panel.onDoubleClick!(e.nativeEvent);
2786
- }
2787
- : undefined;
2788
- const handlePointerOver = interactive
2789
- ? (e: ThreeEvent<PointerEvent>) => {
2790
- e.stopPropagation();
2791
- document.body.style.cursor = 'pointer';
2792
- }
2793
- : undefined;
2794
- const handlePointerOut = interactive
2795
- ? () => {
2796
- document.body.style.cursor = '';
2797
- }
2798
- : undefined;
2799
-
2800
- return (
2801
- <group key={panel.id}>
2802
- <mesh
2803
- position={[cx, y, cz]}
2804
- renderOrder={10}
2805
- onClick={handleClick}
2806
- onDoubleClick={handleDoubleClick}
2807
- onPointerOver={handlePointerOver}
2808
- onPointerOut={handlePointerOut}
2809
- >
2810
- <boxGeometry args={[w, t, d]} />
2811
- <meshBasicMaterial
2812
- color={panel.color}
2813
- transparent={!isOpaque}
2814
- opacity={opacity}
2815
- depthWrite={isOpaque}
2816
- />
2817
- </mesh>
2818
- {panel.displayLabel && (
2819
- <>
2820
- <Text
2821
- position={[cx, topY + 0.05, cz - labelSize * 0.6]}
2822
- rotation={[-Math.PI / 2, 0, 0]}
2823
- fontSize={labelSize}
2824
- color={panel.displayLabelColor ?? panel.labelColor ?? '#ffffff'}
2825
- anchorX="center"
2826
- anchorY="middle"
2827
- maxWidth={w * 0.9}
2828
- textAlign="center"
2829
- renderOrder={11}
2830
- frustumCulled={false}
2831
- >
2832
- {panel.displayLabel}
2833
- <meshBasicMaterial
2834
- attach="material"
2835
- color={panel.displayLabelColor ?? panel.labelColor ?? '#ffffff'}
2836
- depthWrite={false}
2837
- depthTest={false}
2838
- />
2839
- </Text>
2840
- {/* Underline rendered as a thin plane just below the
2841
- displayLabel. Width approximated from character count
2842
- (~0.55em advance) and clamped to the panel footprint. */}
2843
- <mesh
2844
- position={[cx, topY + 0.06, cz - labelSize * 0.05]}
2845
- rotation={[-Math.PI / 2, 0, 0]}
2846
- renderOrder={11}
2847
- >
2848
- <planeGeometry
2849
- args={[
2850
- Math.min(w * 0.9, panel.displayLabel.length * labelSize * 0.55),
2851
- labelSize * 0.06,
2852
- ]}
2853
- />
2854
- <meshBasicMaterial
2855
- color={panel.displayLabelColor ?? panel.labelColor ?? '#ffffff'}
2856
- depthWrite={false}
2857
- depthTest={false}
2858
- transparent
2859
- />
2860
- </mesh>
2861
- </>
2862
- )}
2863
- {panel.label && (
2864
- <Text
2865
- position={[cx, topY + 0.05, cz + (panel.displayLabel ? labelSize * 0.6 : 0)]}
2866
- rotation={[-Math.PI / 2, 0, 0]}
2867
- fontSize={labelSize}
2868
- color={panel.labelColor ?? '#ffffff'}
2869
- anchorX="center"
2870
- anchorY="middle"
2871
- maxWidth={w * 0.9}
2872
- textAlign="center"
2873
- renderOrder={11}
2874
- frustumCulled={false}
2875
- >
2876
- {panel.label}
2877
- <meshBasicMaterial
2878
- attach="material"
2879
- color={panel.labelColor ?? '#ffffff'}
2880
- depthWrite={false}
2881
- depthTest={false}
2882
- />
2883
- </Text>
2884
- )}
2885
- </group>
2886
- );
2887
- })}
3046
+ elevatedScopePanels?.map(panel => (
3047
+ <ElevatedScopePanelMesh
3048
+ key={panel.id}
3049
+ panel={panel}
3050
+ centerOffset={centerOffset}
3051
+ dismissing={dismissingPanelIds?.has(panel.id) ?? false}
3052
+ onDismissed={onPanelDismissed}
3053
+ />
3054
+ ))}
3055
+
3056
+ {selectedDistrict && (
3057
+ <SelectionRing
3058
+ district={selectedDistrict}
3059
+ centerOffset={centerOffset}
3060
+ color={selectionStyle?.color ?? '#facc15'}
3061
+ borderWidth={selectionStyle?.borderWidth ?? 2}
3062
+ growProgress={growProgress}
3063
+ />
3064
+ )}
2888
3065
  </>
2889
3066
  );
2890
3067
  }
@@ -2902,6 +3079,8 @@ export interface FileCity3DProps {
2902
3079
  height?: number | string;
2903
3080
  /** Callback when a building is clicked */
2904
3081
  onBuildingClick?: (building: CityBuilding, event: MouseEvent) => void;
3082
+ /** Callback when the hovered building changes (fires with null on hover-out) */
3083
+ onBuildingHover?: (building: CityBuilding | null) => void;
2905
3084
  /** CSS class name */
2906
3085
  className?: string;
2907
3086
  /** Inline styles */
@@ -2942,8 +3121,23 @@ export interface FileCity3DProps {
2942
3121
  backgroundColor?: string;
2943
3122
  /** Text color for secondary/placeholder text */
2944
3123
  textColor?: string;
2945
- /** Currently selected building (controlled by host) */
3124
+ /**
3125
+ * @deprecated Use `selectedPath` instead. When both are set, `selectedPath`
3126
+ * wins. This prop will be removed in a future release.
3127
+ */
2946
3128
  selectedBuilding?: CityBuilding | null;
3129
+
3130
+ /**
3131
+ * Path of the selected building or directory. The component resolves the
3132
+ * path against `cityData.buildings` (file selection — emphasizes the
3133
+ * building and shows the InfoPanel) and `cityData.districts` (directory
3134
+ * selection — draws a ring around the district). When both `selectedPath`
3135
+ * and `selectedBuilding` are set, `selectedPath` wins.
3136
+ */
3137
+ selectedPath?: string | null;
3138
+
3139
+ /** Visual style for the directory selection ring drawn for `selectedPath`. */
3140
+ selectionStyle?: SelectionStyle;
2947
3141
  /** When true, camera height adjusts based on tallest building when grown */
2948
3142
  adaptCameraToBuildings?: boolean;
2949
3143
 
@@ -2956,6 +3150,20 @@ export interface FileCity3DProps {
2956
3150
  */
2957
3151
  elevatedScopePanels?: ElevatedScopePanel[];
2958
3152
 
3153
+ /**
3154
+ * Set of panel ids that should play the "lift up and fade out" dismiss
3155
+ * animation. Add an id here to start the animation; once it settles,
3156
+ * `onPanelDismissed` fires so the host can drop the panel from
3157
+ * `elevatedScopePanels`.
3158
+ */
3159
+ dismissingPanelIds?: ReadonlySet<string>;
3160
+
3161
+ /**
3162
+ * Fires once a panel's dismiss animation has settled. The host should
3163
+ * remove the id from both `dismissingPanelIds` and `elevatedScopePanels`.
3164
+ */
3165
+ onPanelDismissed?: (id: string) => void;
3166
+
2959
3167
  /**
2960
3168
  * Configure how mouse / trackpad / touch input drives the camera.
2961
3169
  * Defaults match Google Maps style: left-drag pans, right-drag rotates,
@@ -2978,6 +3186,7 @@ export function FileCity3D({
2978
3186
  width = '100%',
2979
3187
  height = 600,
2980
3188
  onBuildingClick,
3189
+ onBuildingHover,
2981
3190
  className,
2982
3191
  style,
2983
3192
  animation,
@@ -2985,6 +3194,8 @@ export function FileCity3D({
2985
3194
  onGrowChange,
2986
3195
  showControls = false,
2987
3196
  elevatedScopePanels,
3197
+ dismissingPanelIds,
3198
+ onPanelDismissed,
2988
3199
  highlightLayers: externalHighlightLayers,
2989
3200
  isolationMode: externalIsolationMode,
2990
3201
  dimOpacity: _dimOpacity = 0.15,
@@ -3000,6 +3211,8 @@ export function FileCity3D({
3000
3211
  backgroundColor = '#0f172a',
3001
3212
  textColor = '#94a3b8',
3002
3213
  selectedBuilding = null,
3214
+ selectedPath = null,
3215
+ selectionStyle,
3003
3216
  adaptCameraToBuildings = false,
3004
3217
  fileColorLayers,
3005
3218
  cameraControls,
@@ -3008,6 +3221,14 @@ export function FileCity3D({
3008
3221
  const [internalIsGrown, setInternalIsGrown] = useState(false);
3009
3222
  const [cameraReady, setCameraReady] = useState(false);
3010
3223
 
3224
+ const handleBuildingHover = useCallback(
3225
+ (building: CityBuilding | null) => {
3226
+ setHoveredBuilding(building);
3227
+ onBuildingHover?.(building);
3228
+ },
3229
+ [onBuildingHover],
3230
+ );
3231
+
3011
3232
  const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
3012
3233
 
3013
3234
  // ============================================================================
@@ -3045,6 +3266,22 @@ export function FileCity3D({
3045
3266
  const focusColor = resolved.focusColor;
3046
3267
  const isolationMode = resolved.isolationMode as IsolationMode;
3047
3268
 
3269
+ // `selectedPath` wins over the deprecated `selectedBuilding` when both are
3270
+ // set. A path resolves to either a building (file selection) or a district
3271
+ // (directory selection) — never both.
3272
+ const resolvedSelection = useMemo<{
3273
+ building: CityBuilding | null;
3274
+ district: CityDistrict | null;
3275
+ }>(() => {
3276
+ if (selectedPath != null) {
3277
+ const building = cityData.buildings.find(b => b.path === selectedPath) ?? null;
3278
+ if (building) return { building, district: null };
3279
+ const district = cityData.districts.find(d => d.path === selectedPath) ?? null;
3280
+ return { building: null, district };
3281
+ }
3282
+ return { building: selectedBuilding ?? null, district: null };
3283
+ }, [selectedPath, selectedBuilding, cityData.buildings, cityData.districts]);
3284
+
3048
3285
  const isGrown = externalIsGrown !== undefined ? externalIsGrown : internalIsGrown;
3049
3286
  const setIsGrown = (value: boolean) => {
3050
3287
  setInternalIsGrown(value);
@@ -3145,10 +3382,12 @@ export function FileCity3D({
3145
3382
  >
3146
3383
  <CityScene
3147
3384
  cityData={cityData}
3148
- onBuildingHover={setHoveredBuilding}
3385
+ onBuildingHover={handleBuildingHover}
3149
3386
  onBuildingClick={onBuildingClick}
3150
3387
  hoveredBuilding={hoveredBuilding}
3151
- selectedBuilding={selectedBuilding}
3388
+ selectedBuilding={resolvedSelection.building}
3389
+ selectedDistrict={resolvedSelection.district}
3390
+ selectionStyle={selectionStyle}
3152
3391
  growProgress={growProgress}
3153
3392
  animationConfig={animationConfig}
3154
3393
  highlightLayers={highlightLayers}
@@ -3160,11 +3399,13 @@ export function FileCity3D({
3160
3399
  focusColor={focusColor}
3161
3400
  adaptCameraToBuildings={adaptCameraToBuildings}
3162
3401
  elevatedScopePanels={elevatedScopePanels}
3402
+ dismissingPanelIds={dismissingPanelIds}
3403
+ onPanelDismissed={onPanelDismissed}
3163
3404
  cameraControls={cameraControls}
3164
3405
  onCameraReady={() => setCameraReady(true)}
3165
3406
  />
3166
3407
  </Canvas>
3167
- <InfoPanel building={selectedBuilding} />
3408
+ <InfoPanel building={resolvedSelection.building} />
3168
3409
  {showControls && (
3169
3410
  <ControlsOverlay
3170
3411
  isFlat={!isGrown}
@@ -27,6 +27,7 @@ export type {
27
27
  HeightScaling,
28
28
  FlatPattern,
29
29
  ElevatedScopePanel,
30
+ SelectionStyle,
30
31
  CityData,
31
32
  CityBuilding,
32
33
  CityDistrict,
@@ -564,47 +564,17 @@ export const FileCityExplorer: React.FC<FileCityExplorerProps> = ({
564
564
  const displayLabel = folderPath ? areaNameByCityPath.get(folderPath) : undefined;
565
565
  return displayLabel ? { ...panel, displayLabel } : panel;
566
566
  });
567
- // Selection indicator: render a thin, slightly-larger panel underneath
568
- // the selected folder's umbrella so an accent ring peeks out around its
569
- // edges. Inserted *before* the umbrella in the list so the umbrella
570
- // draws on top — only the inflated rim shows. If the folder is expanded
571
- // (no umbrella in the panel list) findIndex returns -1 and no ring is
572
- // drawn, which is exactly what we want.
573
- if (selectedPanelFolder) {
574
- const idx = panels.findIndex(p => p.id === `folder::${selectedPanelFolder}`);
575
- if (idx >= 0) {
576
- const target = panels[idx];
577
- const inflate = 4;
578
- const border: ElevatedScopePanel = {
579
- id: `folder-border::${selectedPanelFolder}`,
580
- color: theme.colors.warning,
581
- height: (target.height ?? 4) - 2,
582
- thickness: 1,
583
- bounds: {
584
- minX: target.bounds.minX - inflate,
585
- maxX: target.bounds.maxX + inflate,
586
- minZ: target.bounds.minZ - inflate,
587
- maxZ: target.bounds.maxZ + inflate,
588
- },
589
- };
590
- const next = [...panels];
591
- next.splice(idx, 0, border);
592
- return next;
593
- }
594
- }
595
567
 
596
568
  return panels.length > 0 ? panels : undefined;
597
569
  }, [
598
570
  activeTab,
599
571
  cityData,
600
- selectedPanelFolder,
601
572
  treeModel,
602
573
  folderTreeExpansion,
603
574
  setFocusDirectoryIfUnpinned,
604
575
  areaNameByCityPath,
605
576
  folderIndex,
606
577
  packageRootClamp,
607
- theme,
608
578
  ]);
609
579
 
610
580
  // Cmd-click on a building → surface the chain of expanded ancestor folders
@@ -829,6 +799,8 @@ export const FileCityExplorer: React.FC<FileCityExplorerProps> = ({
829
799
  focusDirectory={focusDirectory}
830
800
  highlightLayers={cityHighlightLayers}
831
801
  elevatedScopePanels={cityElevatedPanels ?? folderElevatedPanels}
802
+ selectedPath={activeTab === 'files' ? selectedPanelFolder : null}
803
+ selectionStyle={{ color: theme.colors.warning }}
832
804
  onBuildingClick={handleBuildingClick}
833
805
  animation={{
834
806
  startFlat: true,