@principal-ai/file-city-react 0.5.42 → 0.5.44
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 +70 -2
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +164 -54
- 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/package.json +1 -1
- package/src/components/FileCity3D/FileCity3D.tsx +437 -137
- package/src/components/FileCity3D/index.ts +2 -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/HighlightLayersFlatDebug.stories.tsx +319 -0
- package/src/stories/LeaderLineSnippetOverlay.stories.tsx +725 -137
- package/src/stories/LeaderLineSnippetOverlay3D.stories.tsx +1060 -0
|
@@ -41,6 +41,32 @@ 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
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Per-frame camera callback signature. Fires once per R3F render frame from
|
|
54
|
+
* inside the Canvas, so projection done in the callback is in lockstep with
|
|
55
|
+
* the city render — useful for HTML/SVG overlays that need to track world
|
|
56
|
+
* positions (e.g. leader lines anchored to buildings).
|
|
57
|
+
*
|
|
58
|
+
* The callback receives the live `THREE.Camera` and the current canvas
|
|
59
|
+
* size in CSS pixels. To project a world point to canvas-local pixels:
|
|
60
|
+
*
|
|
61
|
+
* const v = new THREE.Vector3(x, y, z).project(camera);
|
|
62
|
+
* const px = (v.x * 0.5 + 0.5) * size.width;
|
|
63
|
+
* const py = (v.y * -0.5 + 0.5) * size.height;
|
|
64
|
+
*/
|
|
65
|
+
export type OnCameraFrame = (
|
|
66
|
+
camera: THREE.Camera,
|
|
67
|
+
size: { width: number; height: number },
|
|
68
|
+
) => void;
|
|
69
|
+
|
|
44
70
|
/** What to do with non-highlighted buildings */
|
|
45
71
|
export type IsolationMode =
|
|
46
72
|
| 'none' // Show all buildings normally
|
|
@@ -710,7 +736,7 @@ function BorderHighlights({
|
|
|
710
736
|
return (
|
|
711
737
|
<instancedMesh ref={meshRef} args={[undefined, undefined, borderEdgeData.length]} frustumCulled={false}>
|
|
712
738
|
<boxGeometry args={[1, 1, 1]} />
|
|
713
|
-
<meshBasicMaterial transparent opacity={0.9}
|
|
739
|
+
<meshBasicMaterial transparent opacity={0.9} />
|
|
714
740
|
</instancedMesh>
|
|
715
741
|
);
|
|
716
742
|
}
|
|
@@ -735,6 +761,7 @@ interface InstancedBuildingsProps {
|
|
|
735
761
|
focusDirectory: string | null;
|
|
736
762
|
highlightLayers: HighlightLayer[];
|
|
737
763
|
isolationMode: IsolationMode;
|
|
764
|
+
defaultBuildingColor?: string;
|
|
738
765
|
}
|
|
739
766
|
|
|
740
767
|
// Helper to check if a path is inside a directory
|
|
@@ -759,6 +786,7 @@ function InstancedBuildings({
|
|
|
759
786
|
focusDirectory,
|
|
760
787
|
highlightLayers,
|
|
761
788
|
isolationMode,
|
|
789
|
+
defaultBuildingColor,
|
|
762
790
|
}: InstancedBuildingsProps) {
|
|
763
791
|
const meshRef = useRef<THREE.InstancedMesh>(null);
|
|
764
792
|
const startTimeRef = useRef<number | null>(null);
|
|
@@ -844,7 +872,7 @@ function InstancedBuildings({
|
|
|
844
872
|
// Get all layer matches and find first fill match for building color
|
|
845
873
|
const matches = getLayerMatchesForPath(building.path, highlightLayers);
|
|
846
874
|
const fillMatch = matches.find(m => m.renderStrategy === 'fill');
|
|
847
|
-
const color = fillMatch?.color ?? getColorForFile(building);
|
|
875
|
+
const color = fillMatch?.color ?? defaultBuildingColor ?? getColorForFile(building);
|
|
848
876
|
|
|
849
877
|
const x = building.position.x - centerOffset.x;
|
|
850
878
|
const z = building.position.z - centerOffset.z;
|
|
@@ -873,6 +901,7 @@ function InstancedBuildings({
|
|
|
873
901
|
staggerIndices,
|
|
874
902
|
animationConfig.staggerDelay,
|
|
875
903
|
highlightLayers,
|
|
904
|
+
defaultBuildingColor,
|
|
876
905
|
]);
|
|
877
906
|
|
|
878
907
|
const minHeight = 0.3;
|
|
@@ -1529,6 +1558,19 @@ export function getCameraTilt() {
|
|
|
1529
1558
|
return cameraApi?.getCurrentTilt() ?? null;
|
|
1530
1559
|
}
|
|
1531
1560
|
|
|
1561
|
+
/**
|
|
1562
|
+
* Bridge for piping the live camera + canvas size out of the R3F Canvas on
|
|
1563
|
+
* every frame. Mounted as a child of `<Canvas>` so it has access to the R3F
|
|
1564
|
+
* render loop; runs zero work if no callback is provided.
|
|
1565
|
+
*/
|
|
1566
|
+
function CameraFrameBridge({ onCameraFrame }: { onCameraFrame?: OnCameraFrame }) {
|
|
1567
|
+
const { camera, size } = useThree();
|
|
1568
|
+
useFrame(() => {
|
|
1569
|
+
onCameraFrame?.(camera, { width: size.width, height: size.height });
|
|
1570
|
+
});
|
|
1571
|
+
return null;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1532
1574
|
const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
1533
1575
|
citySize,
|
|
1534
1576
|
isFlat,
|
|
@@ -2394,6 +2436,279 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera, onLookDown }: Contro
|
|
|
2394
2436
|
);
|
|
2395
2437
|
}
|
|
2396
2438
|
|
|
2439
|
+
// Distance (world units) the panel lifts upward when dismissed. Reads as
|
|
2440
|
+
// "toward the camera" because the camera looks down in flat mode.
|
|
2441
|
+
const PANEL_DISMISS_LIFT = 60;
|
|
2442
|
+
|
|
2443
|
+
interface ElevatedScopePanelMeshProps {
|
|
2444
|
+
panel: ElevatedScopePanel;
|
|
2445
|
+
centerOffset: { x: number; z: number };
|
|
2446
|
+
dismissing: boolean;
|
|
2447
|
+
onDismissed?: (id: string) => void;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
// Dismiss animation duration in seconds.
|
|
2451
|
+
const PANEL_DISMISS_DURATION = 0.7;
|
|
2452
|
+
|
|
2453
|
+
function ElevatedScopePanelMesh({
|
|
2454
|
+
panel,
|
|
2455
|
+
centerOffset,
|
|
2456
|
+
dismissing,
|
|
2457
|
+
onDismissed,
|
|
2458
|
+
}: ElevatedScopePanelMeshProps) {
|
|
2459
|
+
const cx = (panel.bounds.minX + panel.bounds.maxX) / 2 - centerOffset.x;
|
|
2460
|
+
const cz = (panel.bounds.minZ + panel.bounds.maxZ) / 2 - centerOffset.z;
|
|
2461
|
+
const w = Math.max(1, panel.bounds.maxX - panel.bounds.minX);
|
|
2462
|
+
const d = Math.max(1, panel.bounds.maxZ - panel.bounds.minZ);
|
|
2463
|
+
const t = panel.thickness ?? 2;
|
|
2464
|
+
const y = (panel.height ?? 4) + t / 2;
|
|
2465
|
+
const baseOpacity = panel.opacity ?? 1;
|
|
2466
|
+
const isOpaqueStatic = baseOpacity >= 1;
|
|
2467
|
+
const topY = y + t / 2;
|
|
2468
|
+
const tileMax = Math.min(w, d) / 2;
|
|
2469
|
+
const requested = panel.labelSize ?? Math.min(w, d) / 6;
|
|
2470
|
+
const labelSize = Math.max(4, Math.min(tileMax, requested));
|
|
2471
|
+
|
|
2472
|
+
// Drive the dismiss animation directly via useFrame + Three.js refs
|
|
2473
|
+
// rather than react-spring. React re-renders (e.g., from FileCity3D's
|
|
2474
|
+
// hover state on mouse-move) were disturbing the spring-driven
|
|
2475
|
+
// animation; mutating the Three.js objects directly keeps the
|
|
2476
|
+
// animation isolated from React's render cycle.
|
|
2477
|
+
const groupRef = useRef<THREE.Group>(null);
|
|
2478
|
+
const slabMaterialRef = useRef<THREE.MeshBasicMaterial>(null);
|
|
2479
|
+
const labelMaterialRefs = useRef<THREE.MeshBasicMaterial[]>([]);
|
|
2480
|
+
const startTimeRef = useRef<number | null>(null);
|
|
2481
|
+
const finishedRef = useRef(false);
|
|
2482
|
+
const [fullyDismissed, setFullyDismissed] = useState(false);
|
|
2483
|
+
|
|
2484
|
+
useFrame(({ clock }) => {
|
|
2485
|
+
if (!dismissing || finishedRef.current) return;
|
|
2486
|
+
if (startTimeRef.current === null) {
|
|
2487
|
+
startTimeRef.current = clock.elapsedTime;
|
|
2488
|
+
}
|
|
2489
|
+
const elapsed = clock.elapsedTime - startTimeRef.current;
|
|
2490
|
+
const t = Math.min(elapsed / PANEL_DISMISS_DURATION, 1);
|
|
2491
|
+
// ease-out cubic
|
|
2492
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
2493
|
+
|
|
2494
|
+
if (groupRef.current) {
|
|
2495
|
+
groupRef.current.position.y = eased * PANEL_DISMISS_LIFT;
|
|
2496
|
+
}
|
|
2497
|
+
const opacityNow = baseOpacity * (1 - eased);
|
|
2498
|
+
if (slabMaterialRef.current) {
|
|
2499
|
+
slabMaterialRef.current.opacity = opacityNow;
|
|
2500
|
+
}
|
|
2501
|
+
for (const mat of labelMaterialRefs.current) {
|
|
2502
|
+
if (mat) mat.opacity = opacityNow;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
if (t >= 1) {
|
|
2506
|
+
finishedRef.current = true;
|
|
2507
|
+
setFullyDismissed(true);
|
|
2508
|
+
onDismissed?.(panel.id);
|
|
2509
|
+
}
|
|
2510
|
+
});
|
|
2511
|
+
|
|
2512
|
+
if (fullyDismissed) return null;
|
|
2513
|
+
|
|
2514
|
+
// Always transparent on the slab so animated opacity actually blends.
|
|
2515
|
+
// (Three.js skips alpha blending entirely when transparent=false, which
|
|
2516
|
+
// makes the fade invisible until the prop flips mid-animation.)
|
|
2517
|
+
// depthWrite stays true while the panel is at full opacity so it still
|
|
2518
|
+
// occludes buildings beneath; we drop it once dismissal starts so the
|
|
2519
|
+
// fading panel doesn't punch a hole in the scene.
|
|
2520
|
+
const slabDepthWrite = isOpaqueStatic && !dismissing;
|
|
2521
|
+
|
|
2522
|
+
const interactive = Boolean(panel.onClick || panel.onDoubleClick);
|
|
2523
|
+
const handleClick = panel.onClick
|
|
2524
|
+
? (e: ThreeEvent<MouseEvent>) => {
|
|
2525
|
+
e.stopPropagation();
|
|
2526
|
+
panel.onClick!(e.nativeEvent);
|
|
2527
|
+
}
|
|
2528
|
+
: undefined;
|
|
2529
|
+
const handleDoubleClick = panel.onDoubleClick
|
|
2530
|
+
? (e: ThreeEvent<MouseEvent>) => {
|
|
2531
|
+
e.stopPropagation();
|
|
2532
|
+
panel.onDoubleClick!(e.nativeEvent);
|
|
2533
|
+
}
|
|
2534
|
+
: undefined;
|
|
2535
|
+
const handlePointerOver = interactive
|
|
2536
|
+
? (e: ThreeEvent<PointerEvent>) => {
|
|
2537
|
+
e.stopPropagation();
|
|
2538
|
+
document.body.style.cursor = 'pointer';
|
|
2539
|
+
}
|
|
2540
|
+
: undefined;
|
|
2541
|
+
const handlePointerOut = interactive
|
|
2542
|
+
? () => {
|
|
2543
|
+
document.body.style.cursor = '';
|
|
2544
|
+
}
|
|
2545
|
+
: undefined;
|
|
2546
|
+
|
|
2547
|
+
const labelColor = panel.labelColor ?? '#ffffff';
|
|
2548
|
+
const displayLabelColor = panel.displayLabelColor ?? panel.labelColor ?? '#ffffff';
|
|
2549
|
+
|
|
2550
|
+
// Reset the array each render — the ref callbacks below will repopulate
|
|
2551
|
+
// it. This avoids stale entries if labels come and go.
|
|
2552
|
+
labelMaterialRefs.current = [];
|
|
2553
|
+
const captureLabelMat = (mat: THREE.MeshBasicMaterial | null) => {
|
|
2554
|
+
if (mat) labelMaterialRefs.current.push(mat);
|
|
2555
|
+
};
|
|
2556
|
+
|
|
2557
|
+
return (
|
|
2558
|
+
<group ref={groupRef}>
|
|
2559
|
+
<mesh
|
|
2560
|
+
position={[cx, y, cz]}
|
|
2561
|
+
renderOrder={10}
|
|
2562
|
+
onClick={dismissing ? undefined : handleClick}
|
|
2563
|
+
onDoubleClick={dismissing ? undefined : handleDoubleClick}
|
|
2564
|
+
onPointerOver={dismissing ? undefined : handlePointerOver}
|
|
2565
|
+
onPointerOut={dismissing ? undefined : handlePointerOut}
|
|
2566
|
+
>
|
|
2567
|
+
<boxGeometry args={[w, t, d]} />
|
|
2568
|
+
<meshBasicMaterial
|
|
2569
|
+
ref={slabMaterialRef}
|
|
2570
|
+
color={panel.color}
|
|
2571
|
+
transparent
|
|
2572
|
+
opacity={baseOpacity}
|
|
2573
|
+
depthWrite={slabDepthWrite}
|
|
2574
|
+
/>
|
|
2575
|
+
</mesh>
|
|
2576
|
+
{panel.displayLabel && (
|
|
2577
|
+
<>
|
|
2578
|
+
<Text
|
|
2579
|
+
position={[cx, topY + 0.05, cz - labelSize * 0.6]}
|
|
2580
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
2581
|
+
fontSize={labelSize}
|
|
2582
|
+
color={displayLabelColor}
|
|
2583
|
+
anchorX="center"
|
|
2584
|
+
anchorY="middle"
|
|
2585
|
+
maxWidth={w * 0.9}
|
|
2586
|
+
textAlign="center"
|
|
2587
|
+
renderOrder={11}
|
|
2588
|
+
frustumCulled={false}
|
|
2589
|
+
>
|
|
2590
|
+
{panel.displayLabel}
|
|
2591
|
+
<meshBasicMaterial
|
|
2592
|
+
ref={captureLabelMat}
|
|
2593
|
+
attach="material"
|
|
2594
|
+
color={displayLabelColor}
|
|
2595
|
+
depthWrite={false}
|
|
2596
|
+
depthTest={false}
|
|
2597
|
+
transparent
|
|
2598
|
+
opacity={baseOpacity}
|
|
2599
|
+
/>
|
|
2600
|
+
</Text>
|
|
2601
|
+
<mesh
|
|
2602
|
+
position={[cx, topY + 0.06, cz - labelSize * 0.05]}
|
|
2603
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
2604
|
+
renderOrder={11}
|
|
2605
|
+
>
|
|
2606
|
+
<planeGeometry
|
|
2607
|
+
args={[
|
|
2608
|
+
Math.min(w * 0.9, panel.displayLabel.length * labelSize * 0.55),
|
|
2609
|
+
labelSize * 0.06,
|
|
2610
|
+
]}
|
|
2611
|
+
/>
|
|
2612
|
+
<meshBasicMaterial
|
|
2613
|
+
ref={captureLabelMat}
|
|
2614
|
+
color={displayLabelColor}
|
|
2615
|
+
depthWrite={false}
|
|
2616
|
+
depthTest={false}
|
|
2617
|
+
transparent
|
|
2618
|
+
opacity={baseOpacity}
|
|
2619
|
+
/>
|
|
2620
|
+
</mesh>
|
|
2621
|
+
</>
|
|
2622
|
+
)}
|
|
2623
|
+
{panel.label && (
|
|
2624
|
+
<Text
|
|
2625
|
+
position={[cx, topY + 0.05, cz + (panel.displayLabel ? labelSize * 0.6 : 0)]}
|
|
2626
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
2627
|
+
fontSize={labelSize}
|
|
2628
|
+
color={labelColor}
|
|
2629
|
+
anchorX="center"
|
|
2630
|
+
anchorY="middle"
|
|
2631
|
+
maxWidth={w * 0.9}
|
|
2632
|
+
textAlign="center"
|
|
2633
|
+
renderOrder={11}
|
|
2634
|
+
frustumCulled={false}
|
|
2635
|
+
>
|
|
2636
|
+
{panel.label}
|
|
2637
|
+
<meshBasicMaterial
|
|
2638
|
+
ref={captureLabelMat}
|
|
2639
|
+
attach="material"
|
|
2640
|
+
color={labelColor}
|
|
2641
|
+
depthWrite={false}
|
|
2642
|
+
depthTest={false}
|
|
2643
|
+
transparent
|
|
2644
|
+
opacity={baseOpacity}
|
|
2645
|
+
/>
|
|
2646
|
+
</Text>
|
|
2647
|
+
)}
|
|
2648
|
+
</group>
|
|
2649
|
+
);
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
interface SelectionRingProps {
|
|
2653
|
+
district: CityDistrict;
|
|
2654
|
+
centerOffset: { x: number; z: number };
|
|
2655
|
+
color: string;
|
|
2656
|
+
borderWidth: number;
|
|
2657
|
+
growProgress: number;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// Lifted just above the default umbrella topY (height 4 + thickness 2 → 6)
|
|
2661
|
+
// so the ring isn't occluded by an `ElevatedScopePanel` covering the same
|
|
2662
|
+
// district when the city is flat.
|
|
2663
|
+
const SELECTION_RING_FLAT_Y = 7;
|
|
2664
|
+
|
|
2665
|
+
function SelectionRing({
|
|
2666
|
+
district,
|
|
2667
|
+
centerOffset,
|
|
2668
|
+
color,
|
|
2669
|
+
borderWidth,
|
|
2670
|
+
growProgress,
|
|
2671
|
+
}: SelectionRingProps) {
|
|
2672
|
+
const { worldBounds } = district;
|
|
2673
|
+
const inflate = 1;
|
|
2674
|
+
const minX = worldBounds.minX - inflate;
|
|
2675
|
+
const maxX = worldBounds.maxX + inflate;
|
|
2676
|
+
const minZ = worldBounds.minZ - inflate;
|
|
2677
|
+
const maxZ = worldBounds.maxZ + inflate;
|
|
2678
|
+
const cx = (minX + maxX) / 2 - centerOffset.x;
|
|
2679
|
+
const cz = (minZ + maxZ) / 2 - centerOffset.z;
|
|
2680
|
+
const w = maxX - minX;
|
|
2681
|
+
const d = maxZ - minZ;
|
|
2682
|
+
|
|
2683
|
+
const pathDepth = district.path.split('/').length;
|
|
2684
|
+
const groundY = -5 - pathDepth * 0.1 + 0.2;
|
|
2685
|
+
const y = SELECTION_RING_FLAT_Y + (groundY - SELECTION_RING_FLAT_Y) * growProgress;
|
|
2686
|
+
|
|
2687
|
+
const t = Math.max(0.5, borderWidth);
|
|
2688
|
+
const barH = 0.5;
|
|
2689
|
+
|
|
2690
|
+
return (
|
|
2691
|
+
<group position={[cx, y, cz]}>
|
|
2692
|
+
<mesh position={[0, 0, -d / 2]} renderOrder={20}>
|
|
2693
|
+
<boxGeometry args={[w + t, barH, t]} />
|
|
2694
|
+
<meshBasicMaterial color={color} transparent opacity={0.95} />
|
|
2695
|
+
</mesh>
|
|
2696
|
+
<mesh position={[0, 0, d / 2]} renderOrder={20}>
|
|
2697
|
+
<boxGeometry args={[w + t, barH, t]} />
|
|
2698
|
+
<meshBasicMaterial color={color} transparent opacity={0.95} />
|
|
2699
|
+
</mesh>
|
|
2700
|
+
<mesh position={[-w / 2, 0, 0]} renderOrder={20}>
|
|
2701
|
+
<boxGeometry args={[t, barH, d + t]} />
|
|
2702
|
+
<meshBasicMaterial color={color} transparent opacity={0.95} />
|
|
2703
|
+
</mesh>
|
|
2704
|
+
<mesh position={[w / 2, 0, 0]} renderOrder={20}>
|
|
2705
|
+
<boxGeometry args={[t, barH, d + t]} />
|
|
2706
|
+
<meshBasicMaterial color={color} transparent opacity={0.95} />
|
|
2707
|
+
</mesh>
|
|
2708
|
+
</group>
|
|
2709
|
+
);
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2397
2712
|
// Main scene component
|
|
2398
2713
|
interface CitySceneProps {
|
|
2399
2714
|
cityData: CityData;
|
|
@@ -2401,6 +2716,8 @@ interface CitySceneProps {
|
|
|
2401
2716
|
onBuildingClick?: (building: CityBuilding, event: MouseEvent) => void;
|
|
2402
2717
|
hoveredBuilding: CityBuilding | null;
|
|
2403
2718
|
selectedBuilding: CityBuilding | null;
|
|
2719
|
+
selectedDistrict: CityDistrict | null;
|
|
2720
|
+
selectionStyle?: SelectionStyle;
|
|
2404
2721
|
growProgress: number;
|
|
2405
2722
|
animationConfig: AnimationConfig;
|
|
2406
2723
|
highlightLayers: HighlightLayer[];
|
|
@@ -2412,7 +2729,10 @@ interface CitySceneProps {
|
|
|
2412
2729
|
focusColor?: string | null;
|
|
2413
2730
|
adaptCameraToBuildings?: boolean;
|
|
2414
2731
|
elevatedScopePanels?: ElevatedScopePanel[];
|
|
2732
|
+
dismissingPanelIds?: ReadonlySet<string>;
|
|
2733
|
+
onPanelDismissed?: (id: string) => void;
|
|
2415
2734
|
cameraControls?: CameraControlsConfig;
|
|
2735
|
+
defaultBuildingColor?: string;
|
|
2416
2736
|
}
|
|
2417
2737
|
|
|
2418
2738
|
function CityScene({
|
|
@@ -2421,6 +2741,8 @@ function CityScene({
|
|
|
2421
2741
|
onBuildingClick,
|
|
2422
2742
|
hoveredBuilding,
|
|
2423
2743
|
selectedBuilding,
|
|
2744
|
+
selectedDistrict,
|
|
2745
|
+
selectionStyle,
|
|
2424
2746
|
growProgress,
|
|
2425
2747
|
animationConfig,
|
|
2426
2748
|
highlightLayers,
|
|
@@ -2432,7 +2754,10 @@ function CityScene({
|
|
|
2432
2754
|
focusColor,
|
|
2433
2755
|
adaptCameraToBuildings = false,
|
|
2434
2756
|
elevatedScopePanels,
|
|
2757
|
+
dismissingPanelIds,
|
|
2758
|
+
onPanelDismissed,
|
|
2435
2759
|
cameraControls,
|
|
2760
|
+
defaultBuildingColor,
|
|
2436
2761
|
onCameraReady,
|
|
2437
2762
|
}: CitySceneProps & { onCameraReady?: () => void }) {
|
|
2438
2763
|
const centerOffset = useMemo(
|
|
@@ -2739,6 +3064,7 @@ function CityScene({
|
|
|
2739
3064
|
focusDirectory={buildingFocusDirectory}
|
|
2740
3065
|
highlightLayers={highlightLayers}
|
|
2741
3066
|
isolationMode={isolationMode}
|
|
3067
|
+
defaultBuildingColor={defaultBuildingColor}
|
|
2742
3068
|
/>
|
|
2743
3069
|
|
|
2744
3070
|
<BuildingIcons
|
|
@@ -2754,137 +3080,25 @@ function CityScene({
|
|
|
2754
3080
|
/>
|
|
2755
3081
|
|
|
2756
3082
|
{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
|
-
})}
|
|
3083
|
+
elevatedScopePanels?.map(panel => (
|
|
3084
|
+
<ElevatedScopePanelMesh
|
|
3085
|
+
key={panel.id}
|
|
3086
|
+
panel={panel}
|
|
3087
|
+
centerOffset={centerOffset}
|
|
3088
|
+
dismissing={dismissingPanelIds?.has(panel.id) ?? false}
|
|
3089
|
+
onDismissed={onPanelDismissed}
|
|
3090
|
+
/>
|
|
3091
|
+
))}
|
|
3092
|
+
|
|
3093
|
+
{selectedDistrict && (
|
|
3094
|
+
<SelectionRing
|
|
3095
|
+
district={selectedDistrict}
|
|
3096
|
+
centerOffset={centerOffset}
|
|
3097
|
+
color={selectionStyle?.color ?? '#facc15'}
|
|
3098
|
+
borderWidth={selectionStyle?.borderWidth ?? 2}
|
|
3099
|
+
growProgress={growProgress}
|
|
3100
|
+
/>
|
|
3101
|
+
)}
|
|
2888
3102
|
</>
|
|
2889
3103
|
);
|
|
2890
3104
|
}
|
|
@@ -2902,6 +3116,8 @@ export interface FileCity3DProps {
|
|
|
2902
3116
|
height?: number | string;
|
|
2903
3117
|
/** Callback when a building is clicked */
|
|
2904
3118
|
onBuildingClick?: (building: CityBuilding, event: MouseEvent) => void;
|
|
3119
|
+
/** Callback when the hovered building changes (fires with null on hover-out) */
|
|
3120
|
+
onBuildingHover?: (building: CityBuilding | null) => void;
|
|
2905
3121
|
/** CSS class name */
|
|
2906
3122
|
className?: string;
|
|
2907
3123
|
/** Inline styles */
|
|
@@ -2942,20 +3158,58 @@ export interface FileCity3DProps {
|
|
|
2942
3158
|
backgroundColor?: string;
|
|
2943
3159
|
/** Text color for secondary/placeholder text */
|
|
2944
3160
|
textColor?: string;
|
|
2945
|
-
/**
|
|
3161
|
+
/**
|
|
3162
|
+
* @deprecated Use `selectedPath` instead. When both are set, `selectedPath`
|
|
3163
|
+
* wins. This prop will be removed in a future release.
|
|
3164
|
+
*/
|
|
2946
3165
|
selectedBuilding?: CityBuilding | null;
|
|
3166
|
+
|
|
3167
|
+
/**
|
|
3168
|
+
* Path of the selected building or directory. The component resolves the
|
|
3169
|
+
* path against `cityData.buildings` (file selection — emphasizes the
|
|
3170
|
+
* building and shows the InfoPanel) and `cityData.districts` (directory
|
|
3171
|
+
* selection — draws a ring around the district). When both `selectedPath`
|
|
3172
|
+
* and `selectedBuilding` are set, `selectedPath` wins.
|
|
3173
|
+
*/
|
|
3174
|
+
selectedPath?: string | null;
|
|
3175
|
+
|
|
3176
|
+
/** Visual style for the directory selection ring drawn for `selectedPath`. */
|
|
3177
|
+
selectionStyle?: SelectionStyle;
|
|
2947
3178
|
/** When true, camera height adjusts based on tallest building when grown */
|
|
2948
3179
|
adaptCameraToBuildings?: boolean;
|
|
2949
3180
|
|
|
2950
3181
|
/** Base file type color layers (resolved with highlightLayers) */
|
|
2951
3182
|
fileColorLayers?: HighlightLayer[];
|
|
2952
3183
|
|
|
3184
|
+
/**
|
|
3185
|
+
* Override the per-building color fallback. When unset (default), buildings
|
|
3186
|
+
* not matched by a fill highlight layer are colored by file extension via
|
|
3187
|
+
* the built-in file-type palette. Set to a CSS color (e.g. `'#475569'`) to
|
|
3188
|
+
* render unmatched buildings in a neutral tone — useful for debug stories
|
|
3189
|
+
* that want to isolate highlight-layer rendering.
|
|
3190
|
+
*/
|
|
3191
|
+
defaultBuildingColor?: string;
|
|
3192
|
+
|
|
2953
3193
|
/**
|
|
2954
3194
|
* Translucent slabs rendered above the city showing scope coverage as
|
|
2955
3195
|
* elevated planes over the directories they own.
|
|
2956
3196
|
*/
|
|
2957
3197
|
elevatedScopePanels?: ElevatedScopePanel[];
|
|
2958
3198
|
|
|
3199
|
+
/**
|
|
3200
|
+
* Set of panel ids that should play the "lift up and fade out" dismiss
|
|
3201
|
+
* animation. Add an id here to start the animation; once it settles,
|
|
3202
|
+
* `onPanelDismissed` fires so the host can drop the panel from
|
|
3203
|
+
* `elevatedScopePanels`.
|
|
3204
|
+
*/
|
|
3205
|
+
dismissingPanelIds?: ReadonlySet<string>;
|
|
3206
|
+
|
|
3207
|
+
/**
|
|
3208
|
+
* Fires once a panel's dismiss animation has settled. The host should
|
|
3209
|
+
* remove the id from both `dismissingPanelIds` and `elevatedScopePanels`.
|
|
3210
|
+
*/
|
|
3211
|
+
onPanelDismissed?: (id: string) => void;
|
|
3212
|
+
|
|
2959
3213
|
/**
|
|
2960
3214
|
* Configure how mouse / trackpad / touch input drives the camera.
|
|
2961
3215
|
* Defaults match Google Maps style: left-drag pans, right-drag rotates,
|
|
@@ -2965,6 +3219,15 @@ export interface FileCity3DProps {
|
|
|
2965
3219
|
* Memoize this object to avoid unnecessary camera re-mounts.
|
|
2966
3220
|
*/
|
|
2967
3221
|
cameraControls?: CameraControlsConfig;
|
|
3222
|
+
|
|
3223
|
+
/**
|
|
3224
|
+
* Fires once per R3F render frame with the live camera and canvas size.
|
|
3225
|
+
* Use to project world points to canvas pixels for HTML/SVG overlays that
|
|
3226
|
+
* need to track buildings as the camera pans / zooms / rotates.
|
|
3227
|
+
*
|
|
3228
|
+
* Memoize the callback to avoid re-mounting the bridge.
|
|
3229
|
+
*/
|
|
3230
|
+
onCameraFrame?: OnCameraFrame;
|
|
2968
3231
|
}
|
|
2969
3232
|
|
|
2970
3233
|
/**
|
|
@@ -2978,6 +3241,7 @@ export function FileCity3D({
|
|
|
2978
3241
|
width = '100%',
|
|
2979
3242
|
height = 600,
|
|
2980
3243
|
onBuildingClick,
|
|
3244
|
+
onBuildingHover,
|
|
2981
3245
|
className,
|
|
2982
3246
|
style,
|
|
2983
3247
|
animation,
|
|
@@ -2985,6 +3249,8 @@ export function FileCity3D({
|
|
|
2985
3249
|
onGrowChange,
|
|
2986
3250
|
showControls = false,
|
|
2987
3251
|
elevatedScopePanels,
|
|
3252
|
+
dismissingPanelIds,
|
|
3253
|
+
onPanelDismissed,
|
|
2988
3254
|
highlightLayers: externalHighlightLayers,
|
|
2989
3255
|
isolationMode: externalIsolationMode,
|
|
2990
3256
|
dimOpacity: _dimOpacity = 0.15,
|
|
@@ -3000,14 +3266,26 @@ export function FileCity3D({
|
|
|
3000
3266
|
backgroundColor = '#0f172a',
|
|
3001
3267
|
textColor = '#94a3b8',
|
|
3002
3268
|
selectedBuilding = null,
|
|
3269
|
+
selectedPath = null,
|
|
3270
|
+
selectionStyle,
|
|
3003
3271
|
adaptCameraToBuildings = false,
|
|
3004
3272
|
fileColorLayers,
|
|
3273
|
+
defaultBuildingColor,
|
|
3005
3274
|
cameraControls,
|
|
3275
|
+
onCameraFrame,
|
|
3006
3276
|
}: FileCity3DProps) {
|
|
3007
3277
|
const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(null);
|
|
3008
3278
|
const [internalIsGrown, setInternalIsGrown] = useState(false);
|
|
3009
3279
|
const [cameraReady, setCameraReady] = useState(false);
|
|
3010
3280
|
|
|
3281
|
+
const handleBuildingHover = useCallback(
|
|
3282
|
+
(building: CityBuilding | null) => {
|
|
3283
|
+
setHoveredBuilding(building);
|
|
3284
|
+
onBuildingHover?.(building);
|
|
3285
|
+
},
|
|
3286
|
+
[onBuildingHover],
|
|
3287
|
+
);
|
|
3288
|
+
|
|
3011
3289
|
const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
|
|
3012
3290
|
|
|
3013
3291
|
// ============================================================================
|
|
@@ -3045,6 +3323,22 @@ export function FileCity3D({
|
|
|
3045
3323
|
const focusColor = resolved.focusColor;
|
|
3046
3324
|
const isolationMode = resolved.isolationMode as IsolationMode;
|
|
3047
3325
|
|
|
3326
|
+
// `selectedPath` wins over the deprecated `selectedBuilding` when both are
|
|
3327
|
+
// set. A path resolves to either a building (file selection) or a district
|
|
3328
|
+
// (directory selection) — never both.
|
|
3329
|
+
const resolvedSelection = useMemo<{
|
|
3330
|
+
building: CityBuilding | null;
|
|
3331
|
+
district: CityDistrict | null;
|
|
3332
|
+
}>(() => {
|
|
3333
|
+
if (selectedPath != null) {
|
|
3334
|
+
const building = cityData.buildings.find(b => b.path === selectedPath) ?? null;
|
|
3335
|
+
if (building) return { building, district: null };
|
|
3336
|
+
const district = cityData.districts.find(d => d.path === selectedPath) ?? null;
|
|
3337
|
+
return { building: null, district };
|
|
3338
|
+
}
|
|
3339
|
+
return { building: selectedBuilding ?? null, district: null };
|
|
3340
|
+
}, [selectedPath, selectedBuilding, cityData.buildings, cityData.districts]);
|
|
3341
|
+
|
|
3048
3342
|
const isGrown = externalIsGrown !== undefined ? externalIsGrown : internalIsGrown;
|
|
3049
3343
|
const setIsGrown = (value: boolean) => {
|
|
3050
3344
|
setInternalIsGrown(value);
|
|
@@ -3145,10 +3439,12 @@ export function FileCity3D({
|
|
|
3145
3439
|
>
|
|
3146
3440
|
<CityScene
|
|
3147
3441
|
cityData={cityData}
|
|
3148
|
-
onBuildingHover={
|
|
3442
|
+
onBuildingHover={handleBuildingHover}
|
|
3149
3443
|
onBuildingClick={onBuildingClick}
|
|
3150
3444
|
hoveredBuilding={hoveredBuilding}
|
|
3151
|
-
selectedBuilding={
|
|
3445
|
+
selectedBuilding={resolvedSelection.building}
|
|
3446
|
+
selectedDistrict={resolvedSelection.district}
|
|
3447
|
+
selectionStyle={selectionStyle}
|
|
3152
3448
|
growProgress={growProgress}
|
|
3153
3449
|
animationConfig={animationConfig}
|
|
3154
3450
|
highlightLayers={highlightLayers}
|
|
@@ -3160,11 +3456,15 @@ export function FileCity3D({
|
|
|
3160
3456
|
focusColor={focusColor}
|
|
3161
3457
|
adaptCameraToBuildings={adaptCameraToBuildings}
|
|
3162
3458
|
elevatedScopePanels={elevatedScopePanels}
|
|
3459
|
+
dismissingPanelIds={dismissingPanelIds}
|
|
3460
|
+
onPanelDismissed={onPanelDismissed}
|
|
3163
3461
|
cameraControls={cameraControls}
|
|
3462
|
+
defaultBuildingColor={defaultBuildingColor}
|
|
3164
3463
|
onCameraReady={() => setCameraReady(true)}
|
|
3165
3464
|
/>
|
|
3465
|
+
{onCameraFrame && <CameraFrameBridge onCameraFrame={onCameraFrame} />}
|
|
3166
3466
|
</Canvas>
|
|
3167
|
-
<InfoPanel building={
|
|
3467
|
+
<InfoPanel building={resolvedSelection.building} />
|
|
3168
3468
|
{showControls && (
|
|
3169
3469
|
<ControlsOverlay
|
|
3170
3470
|
isFlat={!isGrown}
|