@principal-ai/file-city-react 0.5.44 → 0.5.46
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 +7 -0
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +41 -1
- package/dist/components/FileCity3D/index.d.ts +1 -1
- package/dist/components/FileCity3D/index.d.ts.map +1 -1
- package/dist/components/FileCity3D/index.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -1
- package/src/components/FileCity3D/FileCity3D.tsx +52 -1
- package/src/components/FileCity3D/index.ts +2 -0
- package/src/index.ts +23 -1
- package/src/stories/CameraOffsetForOverlays.stories.tsx +470 -0
- package/src/stories/SequenceDiagramOverlay.stories.tsx +427 -0
- package/src/stories/WorkflowSequenceDiagramPrototype.stories.tsx +754 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
import {
|
|
4
|
+
FileCity3D,
|
|
5
|
+
setCameraTarget,
|
|
6
|
+
setCameraFlatView,
|
|
7
|
+
getCameraTarget,
|
|
8
|
+
getCameraPosition,
|
|
9
|
+
type CityData,
|
|
10
|
+
type CityBuilding,
|
|
11
|
+
type CityDistrict,
|
|
12
|
+
} from '../components/FileCity3D';
|
|
13
|
+
|
|
14
|
+
const meta: Meta<typeof FileCity3D> = {
|
|
15
|
+
title: 'Components/CameraOffsetForOverlays',
|
|
16
|
+
component: FileCity3D,
|
|
17
|
+
parameters: { layout: 'fullscreen' },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default meta;
|
|
21
|
+
type Story = StoryObj<typeof FileCity3D>;
|
|
22
|
+
|
|
23
|
+
// --- sample city ---------------------------------------------------------
|
|
24
|
+
const CODE_EXTS = ['ts', 'tsx', 'js', 'py', 'rs', 'go'];
|
|
25
|
+
const NON_CODE_EXTS = ['json', 'css', 'md', 'yaml'];
|
|
26
|
+
|
|
27
|
+
function generateBuildings(
|
|
28
|
+
basePath: string,
|
|
29
|
+
count: number,
|
|
30
|
+
startX: number,
|
|
31
|
+
startZ: number,
|
|
32
|
+
areaWidth: number,
|
|
33
|
+
areaDepth: number,
|
|
34
|
+
): CityBuilding[] {
|
|
35
|
+
const buildings: CityBuilding[] = [];
|
|
36
|
+
const exts = [...CODE_EXTS, ...NON_CODE_EXTS];
|
|
37
|
+
const cols = Math.ceil(Math.sqrt(count));
|
|
38
|
+
for (let i = 0; i < count; i++) {
|
|
39
|
+
const col = i % cols;
|
|
40
|
+
const row = Math.floor(i / cols);
|
|
41
|
+
const ext = exts[i % exts.length];
|
|
42
|
+
const isCode = CODE_EXTS.includes(ext);
|
|
43
|
+
const lineCount = isCode
|
|
44
|
+
? Math.floor(Math.exp(Math.random() * Math.log(3000 - 20) + Math.log(20)))
|
|
45
|
+
: undefined;
|
|
46
|
+
const size = isCode ? lineCount! * 40 : Math.floor(Math.random() * 200000) + 1000;
|
|
47
|
+
buildings.push({
|
|
48
|
+
path: `${basePath}/file${i}.${ext}`,
|
|
49
|
+
position: {
|
|
50
|
+
x: startX + (col / cols) * areaWidth + areaWidth / cols / 2,
|
|
51
|
+
y: 0,
|
|
52
|
+
z: startZ + (row / cols) * areaDepth + areaDepth / cols / 2,
|
|
53
|
+
},
|
|
54
|
+
dimensions: [(areaWidth / cols) * 0.7, 10, (areaDepth / cols) * 0.7],
|
|
55
|
+
type: 'file',
|
|
56
|
+
fileExtension: ext,
|
|
57
|
+
size,
|
|
58
|
+
lineCount,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return buildings;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const districts: CityDistrict[] = [
|
|
65
|
+
{
|
|
66
|
+
path: 'src',
|
|
67
|
+
worldBounds: { minX: -2, maxX: 42, minZ: -2, maxZ: 42 },
|
|
68
|
+
fileCount: 12,
|
|
69
|
+
type: 'directory',
|
|
70
|
+
label: { text: 'src', bounds: { minX: -2, maxX: 42, minZ: 42, maxZ: 46 }, position: 'bottom' },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
path: 'src/components',
|
|
74
|
+
worldBounds: { minX: 48, maxX: 82, minZ: -2, maxZ: 32 },
|
|
75
|
+
fileCount: 8,
|
|
76
|
+
type: 'directory',
|
|
77
|
+
label: { text: 'components', bounds: { minX: 48, maxX: 82, minZ: 32, maxZ: 36 }, position: 'bottom' },
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
path: 'tests',
|
|
81
|
+
worldBounds: { minX: -2, maxX: 32, minZ: 48, maxZ: 72 },
|
|
82
|
+
fileCount: 5,
|
|
83
|
+
type: 'directory',
|
|
84
|
+
label: { text: 'tests', bounds: { minX: -2, maxX: 32, minZ: 72, maxZ: 76 }, position: 'bottom' },
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
const sampleCityData: CityData = {
|
|
89
|
+
buildings: [
|
|
90
|
+
...generateBuildings('src', 12, 0, 0, 40, 40),
|
|
91
|
+
...generateBuildings('src/components', 8, 50, 0, 30, 30),
|
|
92
|
+
...generateBuildings('tests', 5, 0, 50, 30, 20),
|
|
93
|
+
],
|
|
94
|
+
districts,
|
|
95
|
+
bounds: { minX: -5, maxX: 85, minZ: -5, maxZ: 80 },
|
|
96
|
+
metadata: { totalFiles: 25, totalDirectories: 3, rootPath: '/project', analyzedAt: new Date() },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// --- story template ------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
// FileCity3D re-centers the city around world origin internally (subtracts
|
|
102
|
+
// the bounds center from every building position), so the rendered city
|
|
103
|
+
// center sits at (0, 0, 0) — NOT at the bounds center of the input data.
|
|
104
|
+
const CITY_CENTER_X = 0;
|
|
105
|
+
const CITY_CENTER_Z = 0;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Compute the world-space delta to apply to the camera target so the city
|
|
109
|
+
* center appears at the visible-rect center (canvas minus overlays) instead
|
|
110
|
+
* of the canvas center. Assumes a roughly top-down camera (flat / 2D mode).
|
|
111
|
+
*
|
|
112
|
+
* Math: the camera target always projects to canvas center. To make the city
|
|
113
|
+
* center *appear* at visibleCenter, set target = cityCenter + (canvasCenter -
|
|
114
|
+
* visibleCenter), expressed in world units.
|
|
115
|
+
*/
|
|
116
|
+
function computeOverlayShift(opts: {
|
|
117
|
+
canvasW: number;
|
|
118
|
+
canvasH: number;
|
|
119
|
+
overlayTopPx: number;
|
|
120
|
+
overlayBottomPx: number;
|
|
121
|
+
overlayLeftPx: number;
|
|
122
|
+
overlayRightPx: number;
|
|
123
|
+
cameraHeightWorld: number; // y of camera in world units
|
|
124
|
+
fovDeg?: number;
|
|
125
|
+
}) {
|
|
126
|
+
const { canvasW, canvasH, overlayTopPx, overlayBottomPx, overlayLeftPx, overlayRightPx, cameraHeightWorld } = opts;
|
|
127
|
+
const fov = opts.fovDeg ?? 50;
|
|
128
|
+
|
|
129
|
+
// Visible world span at ground plane (perspective camera looking down).
|
|
130
|
+
const fovRad = (fov * Math.PI) / 180;
|
|
131
|
+
const visibleWorldH = 2 * cameraHeightWorld * Math.tan(fovRad / 2);
|
|
132
|
+
const aspect = canvasW / canvasH;
|
|
133
|
+
const visibleWorldW = visibleWorldH * aspect;
|
|
134
|
+
const worldPerPxX = visibleWorldW / canvasW;
|
|
135
|
+
const worldPerPxZ = visibleWorldH / canvasH;
|
|
136
|
+
|
|
137
|
+
const visibleCenterX = (overlayLeftPx + (canvasW - overlayRightPx)) / 2;
|
|
138
|
+
const visibleCenterY = (overlayTopPx + (canvasH - overlayBottomPx)) / 2;
|
|
139
|
+
const canvasCenterX = canvasW / 2;
|
|
140
|
+
const canvasCenterY = canvasH / 2;
|
|
141
|
+
|
|
142
|
+
// px delta: positive when canvas center is to the right of / below the visible center.
|
|
143
|
+
const dxPx = canvasCenterX - visibleCenterX;
|
|
144
|
+
const dyPx = canvasCenterY - visibleCenterY;
|
|
145
|
+
|
|
146
|
+
// In a top-down view, screen +X = world +X, screen +Y (down) = world +Z.
|
|
147
|
+
return {
|
|
148
|
+
dxWorld: dxPx * worldPerPxX,
|
|
149
|
+
dzWorld: dyPx * worldPerPxZ,
|
|
150
|
+
visibleCenterX,
|
|
151
|
+
visibleCenterY,
|
|
152
|
+
worldPerPxX,
|
|
153
|
+
worldPerPxZ,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const CameraOffsetTemplate: React.FC = () => {
|
|
158
|
+
// Default: bottom drawer covers 50%, side panels each cover 25%.
|
|
159
|
+
const [overlayBottomPct, setOverlayBottomPct] = React.useState(50);
|
|
160
|
+
const [overlayTopPct, setOverlayTopPct] = React.useState(0);
|
|
161
|
+
const [overlayLeftPct, setOverlayLeftPct] = React.useState(25);
|
|
162
|
+
const [overlayRightPct, setOverlayRightPct] = React.useState(25);
|
|
163
|
+
const [target, setTarget] = React.useState<{ x: number; y: number; z: number } | null>(null);
|
|
164
|
+
const [camHeight, setCamHeight] = React.useState<number | null>(null);
|
|
165
|
+
const [duration, setDuration] = React.useState(500);
|
|
166
|
+
|
|
167
|
+
// Capture the initial (auto-fit) flat-mode camera height once it's stable.
|
|
168
|
+
// We use this as the "city fills the canvas" baseline, so repeated fits don't
|
|
169
|
+
// compound and Reset can return to it.
|
|
170
|
+
const initialHeightRef = React.useRef<number | null>(null);
|
|
171
|
+
|
|
172
|
+
React.useEffect(() => {
|
|
173
|
+
const id = setInterval(() => {
|
|
174
|
+
const t = getCameraTarget();
|
|
175
|
+
const p = getCameraPosition();
|
|
176
|
+
if (t) setTarget({ x: +t.x.toFixed(1), y: +t.y.toFixed(1), z: +t.z.toFixed(1) });
|
|
177
|
+
if (p) {
|
|
178
|
+
setCamHeight(+p.y.toFixed(1));
|
|
179
|
+
if (initialHeightRef.current === null && p.y > 1) {
|
|
180
|
+
initialHeightRef.current = p.y;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}, 100);
|
|
184
|
+
return () => clearInterval(id);
|
|
185
|
+
}, []);
|
|
186
|
+
|
|
187
|
+
const shiftIntoVisibleArea = () => {
|
|
188
|
+
const cam = getCameraPosition();
|
|
189
|
+
if (!cam) return;
|
|
190
|
+
const canvasW = window.innerWidth;
|
|
191
|
+
const canvasH = window.innerHeight;
|
|
192
|
+
const { dxWorld, dzWorld } = computeOverlayShift({
|
|
193
|
+
canvasW,
|
|
194
|
+
canvasH,
|
|
195
|
+
overlayTopPx: (overlayTopPct / 100) * canvasH,
|
|
196
|
+
overlayBottomPx: (overlayBottomPct / 100) * canvasH,
|
|
197
|
+
overlayLeftPx: (overlayLeftPct / 100) * canvasW,
|
|
198
|
+
overlayRightPx: (overlayRightPct / 100) * canvasW,
|
|
199
|
+
cameraHeightWorld: cam.y,
|
|
200
|
+
});
|
|
201
|
+
setCameraTarget(CITY_CENTER_X + dxWorld, 0, CITY_CENTER_Z + dzWorld, { duration });
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Fit the city *into* the visible rect (shrink + pan). At the initial
|
|
205
|
+
// baseline height the city fills the whole canvas; raising the camera by
|
|
206
|
+
// 1/fraction makes the city appear at the size of the visible sub-rect.
|
|
207
|
+
// Then we shift the target so the city center lands at the visible center.
|
|
208
|
+
const fitIntoVisibleArea = () => {
|
|
209
|
+
const baseline = initialHeightRef.current;
|
|
210
|
+
if (baseline === null) return;
|
|
211
|
+
const canvasW = window.innerWidth;
|
|
212
|
+
const canvasH = window.innerHeight;
|
|
213
|
+
const visW = canvasW * (1 - overlayLeftPct / 100 - overlayRightPct / 100);
|
|
214
|
+
const visH = canvasH * (1 - overlayTopPct / 100 - overlayBottomPct / 100);
|
|
215
|
+
const fraction = Math.min(visW / canvasW, visH / canvasH);
|
|
216
|
+
if (fraction <= 0) return;
|
|
217
|
+
const newHeight = baseline / fraction;
|
|
218
|
+
|
|
219
|
+
const { dxWorld, dzWorld } = computeOverlayShift({
|
|
220
|
+
canvasW,
|
|
221
|
+
canvasH,
|
|
222
|
+
overlayTopPx: (overlayTopPct / 100) * canvasH,
|
|
223
|
+
overlayBottomPx: (overlayBottomPct / 100) * canvasH,
|
|
224
|
+
overlayLeftPx: (overlayLeftPct / 100) * canvasW,
|
|
225
|
+
overlayRightPx: (overlayRightPct / 100) * canvasW,
|
|
226
|
+
cameraHeightWorld: newHeight,
|
|
227
|
+
});
|
|
228
|
+
setCameraFlatView(CITY_CENTER_X + dxWorld, CITY_CENTER_Z + dzWorld, newHeight, { duration });
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const recenter = () => {
|
|
232
|
+
const baseline = initialHeightRef.current;
|
|
233
|
+
if (baseline !== null) {
|
|
234
|
+
setCameraFlatView(CITY_CENTER_X, CITY_CENTER_Z, baseline, { duration });
|
|
235
|
+
} else {
|
|
236
|
+
setCameraTarget(CITY_CENTER_X, 0, CITY_CENTER_Z, { duration });
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const nudge = (dx: number, dz: number) => {
|
|
241
|
+
const t = getCameraTarget();
|
|
242
|
+
if (!t) return;
|
|
243
|
+
setCameraTarget(t.x + dx, 0, t.z + dz, { duration });
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const buttonStyle: React.CSSProperties = {
|
|
247
|
+
padding: '8px 12px',
|
|
248
|
+
background: '#334155',
|
|
249
|
+
border: '1px solid #475569',
|
|
250
|
+
borderRadius: 6,
|
|
251
|
+
color: '#e2e8f0',
|
|
252
|
+
cursor: 'pointer',
|
|
253
|
+
fontSize: 12,
|
|
254
|
+
fontWeight: 500,
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const labelStyle: React.CSSProperties = { fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 };
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<div style={{ position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden' }}>
|
|
261
|
+
<FileCity3D
|
|
262
|
+
cityData={sampleCityData}
|
|
263
|
+
height="100vh"
|
|
264
|
+
showControls={false}
|
|
265
|
+
animation={{ startFlat: true, autoStartDelay: null }}
|
|
266
|
+
/>
|
|
267
|
+
|
|
268
|
+
{/* Overlays — semi-transparent so you can see how the city sits behind them */}
|
|
269
|
+
{overlayTopPct > 0 && (
|
|
270
|
+
<div
|
|
271
|
+
style={{
|
|
272
|
+
position: 'absolute',
|
|
273
|
+
top: 0,
|
|
274
|
+
left: 0,
|
|
275
|
+
right: 0,
|
|
276
|
+
height: `${overlayTopPct}%`,
|
|
277
|
+
background: 'rgba(99, 102, 241, 0.25)',
|
|
278
|
+
borderBottom: '2px dashed rgba(99, 102, 241, 0.8)',
|
|
279
|
+
pointerEvents: 'none',
|
|
280
|
+
}}
|
|
281
|
+
/>
|
|
282
|
+
)}
|
|
283
|
+
{overlayBottomPct > 0 && (
|
|
284
|
+
<div
|
|
285
|
+
style={{
|
|
286
|
+
position: 'absolute',
|
|
287
|
+
bottom: 0,
|
|
288
|
+
left: 0,
|
|
289
|
+
right: 0,
|
|
290
|
+
height: `${overlayBottomPct}%`,
|
|
291
|
+
background: 'rgba(99, 102, 241, 0.25)',
|
|
292
|
+
borderTop: '2px dashed rgba(99, 102, 241, 0.8)',
|
|
293
|
+
pointerEvents: 'none',
|
|
294
|
+
}}
|
|
295
|
+
/>
|
|
296
|
+
)}
|
|
297
|
+
{overlayLeftPct > 0 && (
|
|
298
|
+
<div
|
|
299
|
+
style={{
|
|
300
|
+
position: 'absolute',
|
|
301
|
+
top: `${overlayTopPct}%`,
|
|
302
|
+
bottom: `${overlayBottomPct}%`,
|
|
303
|
+
left: 0,
|
|
304
|
+
width: `${overlayLeftPct}%`,
|
|
305
|
+
background: 'rgba(244, 114, 182, 0.25)',
|
|
306
|
+
borderRight: '2px dashed rgba(244, 114, 182, 0.8)',
|
|
307
|
+
pointerEvents: 'none',
|
|
308
|
+
}}
|
|
309
|
+
/>
|
|
310
|
+
)}
|
|
311
|
+
{overlayRightPct > 0 && (
|
|
312
|
+
<div
|
|
313
|
+
style={{
|
|
314
|
+
position: 'absolute',
|
|
315
|
+
top: `${overlayTopPct}%`,
|
|
316
|
+
bottom: `${overlayBottomPct}%`,
|
|
317
|
+
right: 0,
|
|
318
|
+
width: `${overlayRightPct}%`,
|
|
319
|
+
background: 'rgba(244, 114, 182, 0.25)',
|
|
320
|
+
borderLeft: '2px dashed rgba(244, 114, 182, 0.8)',
|
|
321
|
+
pointerEvents: 'none',
|
|
322
|
+
}}
|
|
323
|
+
/>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
{/* Visible-area marker (for sanity) */}
|
|
327
|
+
<div
|
|
328
|
+
style={{
|
|
329
|
+
position: 'absolute',
|
|
330
|
+
top: `${overlayTopPct}%`,
|
|
331
|
+
bottom: `${overlayBottomPct}%`,
|
|
332
|
+
left: `${overlayLeftPct}%`,
|
|
333
|
+
right: `${overlayRightPct}%`,
|
|
334
|
+
border: '1px dashed rgba(34, 197, 94, 0.8)',
|
|
335
|
+
pointerEvents: 'none',
|
|
336
|
+
}}
|
|
337
|
+
/>
|
|
338
|
+
|
|
339
|
+
{/* Control panel */}
|
|
340
|
+
<div
|
|
341
|
+
style={{
|
|
342
|
+
position: 'absolute',
|
|
343
|
+
top: 16,
|
|
344
|
+
right: 16,
|
|
345
|
+
zIndex: 100,
|
|
346
|
+
background: 'rgba(15, 23, 42, 0.95)',
|
|
347
|
+
border: '1px solid #334155',
|
|
348
|
+
borderRadius: 8,
|
|
349
|
+
padding: 16,
|
|
350
|
+
color: '#e2e8f0',
|
|
351
|
+
fontFamily: 'system-ui, sans-serif',
|
|
352
|
+
width: 280,
|
|
353
|
+
}}
|
|
354
|
+
>
|
|
355
|
+
<div style={{ fontWeight: 600, marginBottom: 12, fontSize: 13 }}>
|
|
356
|
+
Camera offset for overlays
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
<div style={{ marginBottom: 12 }}>
|
|
360
|
+
<label style={labelStyle}>Bottom overlay: {overlayBottomPct}%</label>
|
|
361
|
+
<input
|
|
362
|
+
type="range"
|
|
363
|
+
min={0}
|
|
364
|
+
max={80}
|
|
365
|
+
value={overlayBottomPct}
|
|
366
|
+
onChange={e => setOverlayBottomPct(+e.target.value)}
|
|
367
|
+
style={{ width: '100%' }}
|
|
368
|
+
/>
|
|
369
|
+
</div>
|
|
370
|
+
<div style={{ marginBottom: 12 }}>
|
|
371
|
+
<label style={labelStyle}>Top overlay: {overlayTopPct}%</label>
|
|
372
|
+
<input
|
|
373
|
+
type="range"
|
|
374
|
+
min={0}
|
|
375
|
+
max={80}
|
|
376
|
+
value={overlayTopPct}
|
|
377
|
+
onChange={e => setOverlayTopPct(+e.target.value)}
|
|
378
|
+
style={{ width: '100%' }}
|
|
379
|
+
/>
|
|
380
|
+
</div>
|
|
381
|
+
<div style={{ marginBottom: 12 }}>
|
|
382
|
+
<label style={labelStyle}>Left overlay: {overlayLeftPct}%</label>
|
|
383
|
+
<input
|
|
384
|
+
type="range"
|
|
385
|
+
min={0}
|
|
386
|
+
max={50}
|
|
387
|
+
value={overlayLeftPct}
|
|
388
|
+
onChange={e => setOverlayLeftPct(+e.target.value)}
|
|
389
|
+
style={{ width: '100%' }}
|
|
390
|
+
/>
|
|
391
|
+
</div>
|
|
392
|
+
<div style={{ marginBottom: 12 }}>
|
|
393
|
+
<label style={labelStyle}>Right overlay: {overlayRightPct}%</label>
|
|
394
|
+
<input
|
|
395
|
+
type="range"
|
|
396
|
+
min={0}
|
|
397
|
+
max={50}
|
|
398
|
+
value={overlayRightPct}
|
|
399
|
+
onChange={e => setOverlayRightPct(+e.target.value)}
|
|
400
|
+
style={{ width: '100%' }}
|
|
401
|
+
/>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<div style={{ marginBottom: 12 }}>
|
|
405
|
+
<label style={labelStyle}>Animation duration: {duration}ms</label>
|
|
406
|
+
<input
|
|
407
|
+
type="range"
|
|
408
|
+
min={0}
|
|
409
|
+
max={2000}
|
|
410
|
+
step={100}
|
|
411
|
+
value={duration}
|
|
412
|
+
onChange={e => setDuration(+e.target.value)}
|
|
413
|
+
style={{ width: '100%' }}
|
|
414
|
+
/>
|
|
415
|
+
</div>
|
|
416
|
+
|
|
417
|
+
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
|
418
|
+
<button onClick={fitIntoVisibleArea} style={{ ...buttonStyle, flex: 1, background: '#2563eb', border: '1px solid #3b82f6' }}>
|
|
419
|
+
Fit into visible area
|
|
420
|
+
</button>
|
|
421
|
+
</div>
|
|
422
|
+
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
|
423
|
+
<button onClick={shiftIntoVisibleArea} style={{ ...buttonStyle, flex: 1 }}>
|
|
424
|
+
Shift only (no shrink)
|
|
425
|
+
</button>
|
|
426
|
+
</div>
|
|
427
|
+
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
|
428
|
+
<button onClick={recenter} style={{ ...buttonStyle, flex: 1 }}>
|
|
429
|
+
Recenter (flat)
|
|
430
|
+
</button>
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
<div style={{ fontSize: 11, color: '#94a3b8', marginBottom: 6 }}>Manual nudge (world units)</div>
|
|
434
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 4, marginBottom: 12 }}>
|
|
435
|
+
<div />
|
|
436
|
+
<button onClick={() => nudge(0, -10)} style={{ ...buttonStyle, padding: '6px 4px' }}>↑ -Z</button>
|
|
437
|
+
<div />
|
|
438
|
+
<button onClick={() => nudge(-10, 0)} style={{ ...buttonStyle, padding: '6px 4px' }}>← -X</button>
|
|
439
|
+
<button onClick={recenter} style={{ ...buttonStyle, padding: '6px 4px' }}>•</button>
|
|
440
|
+
<button onClick={() => nudge(10, 0)} style={{ ...buttonStyle, padding: '6px 4px' }}>+X →</button>
|
|
441
|
+
<div />
|
|
442
|
+
<button onClick={() => nudge(0, 10)} style={{ ...buttonStyle, padding: '6px 4px' }}>+Z ↓</button>
|
|
443
|
+
<div />
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
<div style={{ fontSize: 11, color: '#94a3b8', borderTop: '1px solid #334155', paddingTop: 8 }}>
|
|
447
|
+
target: {target ? `(${target.x}, ${target.y}, ${target.z})` : '—'}
|
|
448
|
+
<br />
|
|
449
|
+
camera height: {camHeight ?? '—'}
|
|
450
|
+
{initialHeightRef.current !== null && ` (baseline ${initialHeightRef.current.toFixed(1)})`}
|
|
451
|
+
<br />
|
|
452
|
+
city center: ({CITY_CENTER_X.toFixed(1)}, 0, {CITY_CENTER_Z.toFixed(1)})
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
);
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
export const CameraOffsetForOverlays: Story = {
|
|
460
|
+
render: () => <CameraOffsetTemplate />,
|
|
461
|
+
parameters: {
|
|
462
|
+
docs: {
|
|
463
|
+
description: {
|
|
464
|
+
story:
|
|
465
|
+
'Demonstrates panning the city out from under UI overlays in 2D mode by shifting the camera target with `setCameraTarget`. ' +
|
|
466
|
+
'Adjust the overlay sliders, then click "Shift into visible area" — the city center should align with the green dashed visible rect.',
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
};
|