@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.
@@ -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
+ };