@principal-ai/file-city-react 0.5.40 → 0.5.42
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 +8 -2
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +129 -40
- package/dist/components/FileCityExplorer/AddToAreaModal.d.ts +14 -0
- package/dist/components/FileCityExplorer/AddToAreaModal.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/AddToAreaModal.js +140 -0
- package/dist/components/FileCityExplorer/AddToScopeModal.d.ts +14 -0
- package/dist/components/FileCityExplorer/AddToScopeModal.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/AddToScopeModal.js +176 -0
- package/dist/components/FileCityExplorer/FileCityExplorer.d.ts +30 -0
- package/dist/components/FileCityExplorer/FileCityExplorer.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/FileCityExplorer.js +1045 -0
- package/dist/components/FileCityExplorer/ScopeInfoOverlay.d.ts +10 -0
- package/dist/components/FileCityExplorer/ScopeInfoOverlay.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/ScopeInfoOverlay.js +73 -0
- package/dist/components/FileCityExplorer/index.d.ts +3 -0
- package/dist/components/FileCityExplorer/index.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/index.js +1 -0
- package/dist/components/FileCityExplorer/layers.d.ts +16 -0
- package/dist/components/FileCityExplorer/layers.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/layers.js +61 -0
- package/dist/components/FileCityExplorer/model.d.ts +32 -0
- package/dist/components/FileCityExplorer/model.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/model.js +14 -0
- package/dist/components/FileCityExplorer/pathConversion.d.ts +19 -0
- package/dist/components/FileCityExplorer/pathConversion.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/pathConversion.js +26 -0
- package/dist/components/FileCityExplorer/scopeTreePaths.d.ts +21 -0
- package/dist/components/FileCityExplorer/scopeTreePaths.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/scopeTreePaths.js +42 -0
- package/dist/components/FileCityExplorer/styles.d.ts +9 -0
- package/dist/components/FileCityExplorer/styles.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/styles.js +28 -0
- package/dist/utils/folderElevatedPanels.d.ts +3 -1
- package/dist/utils/folderElevatedPanels.d.ts.map +1 -1
- package/dist/utils/folderElevatedPanels.js +13 -2
- package/package.json +2 -1
- package/src/components/FileCity3D/FileCity3D.tsx +200 -52
- package/src/components/FileCityExplorer/AddToAreaModal.tsx +273 -0
- package/src/components/FileCityExplorer/AddToScopeModal.tsx +320 -0
- package/src/components/FileCityExplorer/FileCityExplorer.tsx +1457 -0
- package/src/components/FileCityExplorer/ScopeInfoOverlay.tsx +229 -0
- package/src/components/FileCityExplorer/index.ts +2 -0
- package/src/components/FileCityExplorer/layers.ts +72 -0
- package/src/components/FileCityExplorer/model.ts +35 -0
- package/src/components/FileCityExplorer/pathConversion.ts +32 -0
- package/src/components/FileCityExplorer/scopeTreePaths.ts +52 -0
- package/src/components/FileCityExplorer/styles.ts +34 -0
- package/src/stories/2D3DComparison.stories.tsx +13 -2
- package/src/stories/ElevatedScopePanels.stories.tsx +295 -0
- package/src/stories/FileCity3D.stories.tsx +24 -3
- package/src/stories/FileCityExplorer.stories.tsx +2474 -0
- package/src/stories/FileCityExplorerComponent.stories.tsx +59 -0
- package/src/stories/LeaderLineSnippetOverlay.stories.tsx +306 -0
- package/src/utils/folderElevatedPanels.ts +15 -2
- package/src/stories/ScopeOverlay.stories.tsx +0 -1610
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import React, { useMemo, useRef, useState, useEffect, useCallback } from 'react';
|
|
11
|
+
import { useTheme } from '@principal-ade/industry-theme';
|
|
11
12
|
import { Canvas, useFrame, ThreeEvent, useThree } from '@react-three/fiber';
|
|
12
13
|
|
|
13
14
|
import { useSpring } from '@react-spring/three';
|
|
@@ -27,10 +28,11 @@ import type { ThreeElements } from '@react-three/fiber';
|
|
|
27
28
|
import { resolveVisualizationIntent } from '../../utils/visualizationResolution';
|
|
28
29
|
|
|
29
30
|
// Extend JSX with Three.js elements
|
|
31
|
+
/* eslint-disable react/no-unknown-property */
|
|
30
32
|
declare module 'react' {
|
|
31
33
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
32
34
|
namespace JSX {
|
|
33
|
-
// eslint-disable-next-line @typescript-eslint/no-empty-
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
|
34
36
|
interface IntrinsicElements extends ThreeElements {}
|
|
35
37
|
}
|
|
36
38
|
}
|
|
@@ -93,8 +95,14 @@ export interface ElevatedScopePanel {
|
|
|
93
95
|
* size derived from the panel's footprint. Always clamped to fit the tile.
|
|
94
96
|
*/
|
|
95
97
|
labelSize?: number;
|
|
98
|
+
/** Optional secondary label rendered above the main label in a smaller font. */
|
|
99
|
+
displayLabel?: string;
|
|
100
|
+
/** Hex color for the display label (default `labelColor` or white). */
|
|
101
|
+
displayLabelColor?: string;
|
|
96
102
|
/** Click handler. When set, the slab becomes interactive and shows a pointer cursor. */
|
|
97
|
-
onClick?: () => void;
|
|
103
|
+
onClick?: (event: MouseEvent) => void;
|
|
104
|
+
/** Double-click handler. The slab becomes interactive (pointer cursor) when either onClick or onDoubleClick is set. */
|
|
105
|
+
onDoubleClick?: (event: MouseEvent) => void;
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
/** Pattern for files that should render flat (e.g., lock files, generated files) */
|
|
@@ -640,7 +648,7 @@ function BorderHighlights({
|
|
|
640
648
|
const animStartTime = startTimeRef.current ?? currentTime;
|
|
641
649
|
|
|
642
650
|
borderEdgeData.forEach((edge, idx) => {
|
|
643
|
-
const { x, z, fullHeight, staggerDelayMs, buildingIndex, color,
|
|
651
|
+
const { x, z, fullHeight, staggerDelayMs, buildingIndex, color, borderWidth, edgeType, width, depth } = edge;
|
|
644
652
|
|
|
645
653
|
// Get height multiplier from shared ref (for collapse animation)
|
|
646
654
|
const heightMultiplier = heightMultipliersRef.current?.[buildingIndex] ?? 1;
|
|
@@ -715,7 +723,7 @@ interface InstancedBuildingsProps {
|
|
|
715
723
|
buildings: CityBuilding[];
|
|
716
724
|
centerOffset: { x: number; z: number };
|
|
717
725
|
onHover?: (building: CityBuilding | null) => void;
|
|
718
|
-
onClick?: (building: CityBuilding) => void;
|
|
726
|
+
onClick?: (building: CityBuilding, event: MouseEvent) => void;
|
|
719
727
|
hoveredIndex: number | null;
|
|
720
728
|
selectedIndex: number | null;
|
|
721
729
|
growProgress: number;
|
|
@@ -1013,7 +1021,7 @@ function InstancedBuildings({
|
|
|
1013
1021
|
e.stopPropagation();
|
|
1014
1022
|
if (e.instanceId !== undefined && e.instanceId < buildingData.length) {
|
|
1015
1023
|
const data = buildingData[e.instanceId];
|
|
1016
|
-
onClick?.(data.building);
|
|
1024
|
+
onClick?.(data.building, e.nativeEvent);
|
|
1017
1025
|
}
|
|
1018
1026
|
},
|
|
1019
1027
|
[buildingData, onClick],
|
|
@@ -1707,6 +1715,75 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
|
1707
1715
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1708
1716
|
}, [isFlat]); // Only animate when isFlat changes, not when focusTarget/citySize/etc change
|
|
1709
1717
|
|
|
1718
|
+
// Animate the camera when focusTarget changes (works in both 2D and 3D).
|
|
1719
|
+
// - 3D + target: frame the directory using the same math as the 2D→3D path
|
|
1720
|
+
// - 3D + null: ease back to the overview position
|
|
1721
|
+
// - 2D + target: pan top-down camera over the directory and zoom to fit it
|
|
1722
|
+
// - 2D + null: return to the centered top-down overview
|
|
1723
|
+
useEffect(() => {
|
|
1724
|
+
if (!hasAppliedInitial.current) return;
|
|
1725
|
+
|
|
1726
|
+
let newPos: {
|
|
1727
|
+
x: number;
|
|
1728
|
+
y: number;
|
|
1729
|
+
z: number;
|
|
1730
|
+
targetX: number;
|
|
1731
|
+
targetY: number;
|
|
1732
|
+
targetZ: number;
|
|
1733
|
+
};
|
|
1734
|
+
|
|
1735
|
+
if (isFlat) {
|
|
1736
|
+
if (focusTarget) {
|
|
1737
|
+
const perspCam = camera as THREE.PerspectiveCamera;
|
|
1738
|
+
const aspect = perspCam.aspect || 1;
|
|
1739
|
+
const fovRad = (50 * Math.PI) / 180;
|
|
1740
|
+
const tanHalfFov = Math.tan(fovRad / 2);
|
|
1741
|
+
const effectiveAspect = Math.min(1, aspect);
|
|
1742
|
+
// Same framing math as calculateFlatCameraHeight, but using the focus
|
|
1743
|
+
// region's size instead of citySize so the directory fills the view.
|
|
1744
|
+
const height = (focusTarget.size / (2 * tanHalfFov * effectiveAspect)) * 1.08;
|
|
1745
|
+
newPos = {
|
|
1746
|
+
x: focusTarget.x,
|
|
1747
|
+
y: height,
|
|
1748
|
+
z: focusTarget.z + 0.001,
|
|
1749
|
+
targetX: focusTarget.x,
|
|
1750
|
+
targetY: 0,
|
|
1751
|
+
targetZ: focusTarget.z,
|
|
1752
|
+
};
|
|
1753
|
+
} else {
|
|
1754
|
+
newPos = getInitial2DPosition();
|
|
1755
|
+
}
|
|
1756
|
+
} else if (focusTarget) {
|
|
1757
|
+
newPos = {
|
|
1758
|
+
x: focusTarget.x,
|
|
1759
|
+
y: Math.max(focusTarget.size * 1.5, 40),
|
|
1760
|
+
z: focusTarget.z + Math.max(focusTarget.size * 2, 50),
|
|
1761
|
+
targetX: focusTarget.x,
|
|
1762
|
+
targetY: 0,
|
|
1763
|
+
targetZ: focusTarget.z,
|
|
1764
|
+
};
|
|
1765
|
+
} else {
|
|
1766
|
+
newPos = {
|
|
1767
|
+
x: 0,
|
|
1768
|
+
y: maxBuildingHeight > 0 ? Math.max(citySize * 1.1, maxBuildingHeight * 2.5) : citySize * 1.1,
|
|
1769
|
+
z: citySize * 1.3,
|
|
1770
|
+
targetX: 0,
|
|
1771
|
+
targetY: 0,
|
|
1772
|
+
targetZ: 0,
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
api.start({
|
|
1777
|
+
camX: newPos.x,
|
|
1778
|
+
camY: newPos.y,
|
|
1779
|
+
camZ: newPos.z,
|
|
1780
|
+
lookX: newPos.targetX,
|
|
1781
|
+
lookY: newPos.targetY,
|
|
1782
|
+
lookZ: newPos.targetZ,
|
|
1783
|
+
});
|
|
1784
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1785
|
+
}, [focusTarget, isFlat]);
|
|
1786
|
+
|
|
1710
1787
|
// Update camera each frame
|
|
1711
1788
|
useFrame(() => {
|
|
1712
1789
|
frameCount.current++;
|
|
@@ -1814,11 +1891,6 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
|
1814
1891
|
}
|
|
1815
1892
|
// Handle position animation
|
|
1816
1893
|
else if (isAnimatingRef.current) {
|
|
1817
|
-
const springY = camY.get();
|
|
1818
|
-
const currentY = camera.position.y;
|
|
1819
|
-
if (Math.abs(springY - currentY) > 1) {
|
|
1820
|
-
console.log('[useFrame] Spring animating - springY:', springY, 'currentY:', currentY);
|
|
1821
|
-
}
|
|
1822
1894
|
camera.position.set(camX.get(), camY.get(), camZ.get());
|
|
1823
1895
|
controlsRef.current.target.set(lookX.get(), lookY.get(), lookZ.get());
|
|
1824
1896
|
controlsRef.current.update();
|
|
@@ -2188,13 +2260,15 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
|
|
|
2188
2260
|
</>
|
|
2189
2261
|
);
|
|
2190
2262
|
}, (prevProps, nextProps) => {
|
|
2191
|
-
// Custom comparison:
|
|
2192
|
-
//
|
|
2263
|
+
// Custom comparison: re-render when isFlat, citySize, maxBuildingHeight,
|
|
2264
|
+
// cameraControls, or focusTarget change. focusTarget is included so the
|
|
2265
|
+
// useEffect that animates the camera on focus changes actually fires.
|
|
2193
2266
|
return (
|
|
2194
2267
|
prevProps.isFlat === nextProps.isFlat &&
|
|
2195
2268
|
prevProps.citySize === nextProps.citySize &&
|
|
2196
2269
|
prevProps.maxBuildingHeight === nextProps.maxBuildingHeight &&
|
|
2197
|
-
prevProps.cameraControls === nextProps.cameraControls
|
|
2270
|
+
prevProps.cameraControls === nextProps.cameraControls &&
|
|
2271
|
+
prevProps.focusTarget === nextProps.focusTarget
|
|
2198
2272
|
);
|
|
2199
2273
|
});
|
|
2200
2274
|
|
|
@@ -2204,6 +2278,7 @@ interface InfoPanelProps {
|
|
|
2204
2278
|
}
|
|
2205
2279
|
|
|
2206
2280
|
function InfoPanel({ building }: InfoPanelProps) {
|
|
2281
|
+
const { theme } = useTheme();
|
|
2207
2282
|
if (!building) return null;
|
|
2208
2283
|
|
|
2209
2284
|
const fileName = building.path.split('/').pop();
|
|
@@ -2215,26 +2290,26 @@ function InfoPanel({ building }: InfoPanelProps) {
|
|
|
2215
2290
|
position: 'absolute',
|
|
2216
2291
|
bottom: 16,
|
|
2217
2292
|
left: 60,
|
|
2218
|
-
background:
|
|
2219
|
-
border:
|
|
2220
|
-
borderRadius:
|
|
2293
|
+
background: `color-mix(in oklab, ${theme.colors.background} 90%, transparent)`,
|
|
2294
|
+
border: `1px solid ${theme.colors.border}`,
|
|
2295
|
+
borderRadius: theme.radii[4],
|
|
2221
2296
|
padding: '12px 16px',
|
|
2222
|
-
color:
|
|
2223
|
-
fontSize:
|
|
2224
|
-
fontFamily:
|
|
2297
|
+
color: theme.colors.text,
|
|
2298
|
+
fontSize: theme.fontSizes[1],
|
|
2299
|
+
fontFamily: theme.fonts.monospace,
|
|
2225
2300
|
maxWidth: 400,
|
|
2226
2301
|
pointerEvents: 'none',
|
|
2227
2302
|
}}
|
|
2228
2303
|
>
|
|
2229
|
-
<div style={{ fontWeight:
|
|
2230
|
-
<div style={{ color:
|
|
2304
|
+
<div style={{ fontWeight: theme.fontWeights.semibold, marginBottom: 4 }}>{fileName}</div>
|
|
2305
|
+
<div style={{ color: theme.colors.textMuted, fontSize: theme.fontSizes[0] }}>{dirPath}</div>
|
|
2231
2306
|
<div
|
|
2232
2307
|
style={{
|
|
2233
|
-
color:
|
|
2308
|
+
color: theme.colors.textTertiary,
|
|
2234
2309
|
fontSize: 11,
|
|
2235
2310
|
marginTop: 4,
|
|
2236
2311
|
display: 'flex',
|
|
2237
|
-
gap:
|
|
2312
|
+
gap: theme.space[3],
|
|
2238
2313
|
}}
|
|
2239
2314
|
>
|
|
2240
2315
|
{building.lineCount !== undefined && (
|
|
@@ -2255,63 +2330,65 @@ interface ControlsOverlayProps {
|
|
|
2255
2330
|
}
|
|
2256
2331
|
|
|
2257
2332
|
function ControlsOverlay({ isFlat, onToggle, onResetCamera, onLookDown }: ControlsOverlayProps) {
|
|
2333
|
+
const { theme } = useTheme();
|
|
2258
2334
|
const buttonStyle = {
|
|
2259
|
-
background:
|
|
2260
|
-
border:
|
|
2261
|
-
borderRadius:
|
|
2335
|
+
background: `color-mix(in oklab, ${theme.colors.background} 90%, transparent)`,
|
|
2336
|
+
border: `1px solid ${theme.colors.border}`,
|
|
2337
|
+
borderRadius: theme.radii[4],
|
|
2262
2338
|
padding: '10px',
|
|
2263
|
-
color:
|
|
2264
|
-
fontSize:
|
|
2339
|
+
color: theme.colors.text,
|
|
2340
|
+
fontSize: theme.fontSizes[1],
|
|
2265
2341
|
cursor: 'pointer',
|
|
2266
2342
|
display: 'flex',
|
|
2267
2343
|
alignItems: 'center',
|
|
2268
2344
|
justifyContent: 'center',
|
|
2269
2345
|
width: 40,
|
|
2270
2346
|
height: 40,
|
|
2271
|
-
fontWeight:
|
|
2347
|
+
fontWeight: theme.fontWeights.medium,
|
|
2272
2348
|
};
|
|
2273
2349
|
|
|
2274
2350
|
return (
|
|
2275
2351
|
<>
|
|
2276
|
-
{/* 2D/3D Toggle -
|
|
2352
|
+
{/* 2D/3D Toggle - Bottom Right (moved from top-left to leave room
|
|
2353
|
+
for story-level overlays like the focus-directory readout). */}
|
|
2277
2354
|
<button
|
|
2278
2355
|
onClick={onToggle}
|
|
2279
2356
|
style={{
|
|
2280
2357
|
...buttonStyle,
|
|
2281
2358
|
position: 'absolute',
|
|
2282
|
-
|
|
2283
|
-
|
|
2359
|
+
bottom: 8,
|
|
2360
|
+
right: 8,
|
|
2284
2361
|
}}
|
|
2285
2362
|
>
|
|
2286
2363
|
{isFlat ? '3D' : '2D'}
|
|
2287
2364
|
</button>
|
|
2288
2365
|
|
|
2289
|
-
{/*
|
|
2366
|
+
{/* Look Down - Bottom Left */}
|
|
2290
2367
|
<button
|
|
2291
|
-
onClick={
|
|
2368
|
+
onClick={onLookDown}
|
|
2292
2369
|
style={{
|
|
2293
2370
|
...buttonStyle,
|
|
2294
2371
|
position: 'absolute',
|
|
2295
|
-
|
|
2296
|
-
|
|
2372
|
+
bottom: 8,
|
|
2373
|
+
left: 8,
|
|
2297
2374
|
}}
|
|
2298
|
-
title="
|
|
2375
|
+
title="Look down"
|
|
2299
2376
|
>
|
|
2300
|
-
|
|
2377
|
+
⬇
|
|
2301
2378
|
</button>
|
|
2302
2379
|
|
|
2303
|
-
{/*
|
|
2380
|
+
{/* Reset Camera - Bottom Left (right of Look Down) */}
|
|
2304
2381
|
<button
|
|
2305
|
-
onClick={
|
|
2382
|
+
onClick={onResetCamera}
|
|
2306
2383
|
style={{
|
|
2307
2384
|
...buttonStyle,
|
|
2308
2385
|
position: 'absolute',
|
|
2309
2386
|
bottom: 8,
|
|
2310
|
-
left:
|
|
2387
|
+
left: 56,
|
|
2311
2388
|
}}
|
|
2312
|
-
title="
|
|
2389
|
+
title="Reset View"
|
|
2313
2390
|
>
|
|
2314
|
-
|
|
2391
|
+
↻
|
|
2315
2392
|
</button>
|
|
2316
2393
|
</>
|
|
2317
2394
|
);
|
|
@@ -2321,7 +2398,7 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera, onLookDown }: Contro
|
|
|
2321
2398
|
interface CitySceneProps {
|
|
2322
2399
|
cityData: CityData;
|
|
2323
2400
|
onBuildingHover?: (building: CityBuilding | null) => void;
|
|
2324
|
-
onBuildingClick?: (building: CityBuilding) => void;
|
|
2401
|
+
onBuildingClick?: (building: CityBuilding, event: MouseEvent) => void;
|
|
2325
2402
|
hoveredBuilding: CityBuilding | null;
|
|
2326
2403
|
selectedBuilding: CityBuilding | null;
|
|
2327
2404
|
growProgress: number;
|
|
@@ -2472,14 +2549,32 @@ function CityScene({
|
|
|
2472
2549
|
|
|
2473
2550
|
// Case 3: Switching between directories (dirA -> dirB)
|
|
2474
2551
|
if (prevFocus !== null && focusDirectory !== null) {
|
|
2475
|
-
//
|
|
2552
|
+
// Direct transition when the two directories are visually adjacent:
|
|
2553
|
+
// - parent ↔ child (one is a prefix of the other)
|
|
2554
|
+
// - immediate siblings (same parent folder)
|
|
2555
|
+
// In both cases the new directory is already in or near the current
|
|
2556
|
+
// view, so a zoom-out detour would feel like extra travel.
|
|
2557
|
+
const isDescendant = focusDirectory.startsWith(prevFocus + '/');
|
|
2558
|
+
const isAncestor = prevFocus.startsWith(focusDirectory + '/');
|
|
2559
|
+
const parentOf = (p: string) => {
|
|
2560
|
+
const i = p.lastIndexOf('/');
|
|
2561
|
+
return i >= 0 ? p.slice(0, i) : '';
|
|
2562
|
+
};
|
|
2563
|
+
const isSibling = parentOf(prevFocus) === parentOf(focusDirectory);
|
|
2564
|
+
if (isDescendant || isAncestor || isSibling) {
|
|
2565
|
+
setBuildingFocusDirectory(focusDirectory);
|
|
2566
|
+
setBuildingFocusColor(focusColor ?? null);
|
|
2567
|
+
setCameraFocusDirectory(focusDirectory);
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
// Unrelated branches — keep the 3-phase out/in transition so the
|
|
2572
|
+
// long camera flight stays legible.
|
|
2476
2573
|
setCameraFocusDirectory(null);
|
|
2477
|
-
// Phase 2: After zoom-out, collapse/expand buildings with new color
|
|
2478
2574
|
const timer1 = setTimeout(() => {
|
|
2479
2575
|
setBuildingFocusDirectory(focusDirectory);
|
|
2480
2576
|
setBuildingFocusColor(focusColor ?? null);
|
|
2481
2577
|
}, 500);
|
|
2482
|
-
// Phase 3: After collapse settles, zoom camera into new directory
|
|
2483
2578
|
const timer2 = setTimeout(() => {
|
|
2484
2579
|
setCameraFocusDirectory(focusDirectory);
|
|
2485
2580
|
}, 1100); // 500ms zoom-out + 600ms collapse
|
|
@@ -2677,19 +2772,26 @@ function CityScene({
|
|
|
2677
2772
|
const requested = panel.labelSize ?? Math.min(w, d) / 6;
|
|
2678
2773
|
const labelSize = Math.max(4, Math.min(tileMax, requested));
|
|
2679
2774
|
|
|
2775
|
+
const interactive = Boolean(panel.onClick || panel.onDoubleClick);
|
|
2680
2776
|
const handleClick = panel.onClick
|
|
2681
2777
|
? (e: ThreeEvent<MouseEvent>) => {
|
|
2682
2778
|
e.stopPropagation();
|
|
2683
|
-
panel.onClick!();
|
|
2779
|
+
panel.onClick!(e.nativeEvent);
|
|
2684
2780
|
}
|
|
2685
2781
|
: undefined;
|
|
2686
|
-
const
|
|
2782
|
+
const handleDoubleClick = panel.onDoubleClick
|
|
2783
|
+
? (e: ThreeEvent<MouseEvent>) => {
|
|
2784
|
+
e.stopPropagation();
|
|
2785
|
+
panel.onDoubleClick!(e.nativeEvent);
|
|
2786
|
+
}
|
|
2787
|
+
: undefined;
|
|
2788
|
+
const handlePointerOver = interactive
|
|
2687
2789
|
? (e: ThreeEvent<PointerEvent>) => {
|
|
2688
2790
|
e.stopPropagation();
|
|
2689
2791
|
document.body.style.cursor = 'pointer';
|
|
2690
2792
|
}
|
|
2691
2793
|
: undefined;
|
|
2692
|
-
const handlePointerOut =
|
|
2794
|
+
const handlePointerOut = interactive
|
|
2693
2795
|
? () => {
|
|
2694
2796
|
document.body.style.cursor = '';
|
|
2695
2797
|
}
|
|
@@ -2701,6 +2803,7 @@ function CityScene({
|
|
|
2701
2803
|
position={[cx, y, cz]}
|
|
2702
2804
|
renderOrder={10}
|
|
2703
2805
|
onClick={handleClick}
|
|
2806
|
+
onDoubleClick={handleDoubleClick}
|
|
2704
2807
|
onPointerOver={handlePointerOver}
|
|
2705
2808
|
onPointerOut={handlePointerOut}
|
|
2706
2809
|
>
|
|
@@ -2712,9 +2815,54 @@ function CityScene({
|
|
|
2712
2815
|
depthWrite={isOpaque}
|
|
2713
2816
|
/>
|
|
2714
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
|
+
)}
|
|
2715
2863
|
{panel.label && (
|
|
2716
2864
|
<Text
|
|
2717
|
-
position={[cx, topY + 0.05, cz]}
|
|
2865
|
+
position={[cx, topY + 0.05, cz + (panel.displayLabel ? labelSize * 0.6 : 0)]}
|
|
2718
2866
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
2719
2867
|
fontSize={labelSize}
|
|
2720
2868
|
color={panel.labelColor ?? '#ffffff'}
|
|
@@ -2753,7 +2901,7 @@ export interface FileCity3DProps {
|
|
|
2753
2901
|
/** Height of the container */
|
|
2754
2902
|
height?: number | string;
|
|
2755
2903
|
/** Callback when a building is clicked */
|
|
2756
|
-
onBuildingClick?: (building: CityBuilding) => void;
|
|
2904
|
+
onBuildingClick?: (building: CityBuilding, event: MouseEvent) => void;
|
|
2757
2905
|
/** CSS class name */
|
|
2758
2906
|
className?: string;
|
|
2759
2907
|
/** Inline styles */
|