@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.
- package/dist/components/FileCity3D/FileCity3D.d.ts +36 -2
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +146 -49
- package/dist/components/FileCity3D/index.d.ts +1 -1
- package/dist/components/FileCity3D/index.d.ts.map +1 -1
- package/dist/components/FileCityExplorer/FileCityExplorer.d.ts.map +1 -1
- package/dist/components/FileCityExplorer/FileCityExplorer.js +1 -31
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/utils/folderElevatedPanels.d.ts.map +1 -1
- package/dist/utils/folderElevatedPanels.js +8 -0
- package/package.json +1 -1
- package/src/components/FileCity3D/FileCity3D.tsx +376 -135
- package/src/components/FileCity3D/index.ts +1 -0
- package/src/components/FileCityExplorer/FileCityExplorer.tsx +2 -30
- package/src/index.ts +1 -0
- package/src/stories/ElevatedScopePanels.stories.tsx +72 -27
- package/src/stories/FileCityExplorer.stories.tsx +2 -29
- package/src/stories/LeaderLineSnippetOverlay.stories.tsx +894 -0
- package/src/utils/folderElevatedPanels.ts +7 -0
|
@@ -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
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
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
|
-
/**
|
|
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={
|
|
3385
|
+
onBuildingHover={handleBuildingHover}
|
|
3149
3386
|
onBuildingClick={onBuildingClick}
|
|
3150
3387
|
hoveredBuilding={hoveredBuilding}
|
|
3151
|
-
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={
|
|
3408
|
+
<InfoPanel building={resolvedSelection.building} />
|
|
3168
3409
|
{showControls && (
|
|
3169
3410
|
<ControlsOverlay
|
|
3170
3411
|
isFlat={!isGrown}
|
|
@@ -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,
|