@principal-ai/file-city-react 0.5.14 → 0.5.16

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.
@@ -2,6 +2,16 @@ import React from 'react';
2
2
  import type { Meta, StoryObj } from '@storybook/react';
3
3
  import {
4
4
  FileCity3D,
5
+ resetCamera,
6
+ rotateCameraTo,
7
+ rotateCameraBy,
8
+ tiltCameraTo,
9
+ tiltCameraBy,
10
+ moveCameraTo,
11
+ setCameraTarget,
12
+ getCameraTarget,
13
+ getCameraAngle,
14
+ getCameraTilt,
5
15
  type CityData,
6
16
  type CityBuilding,
7
17
  type CityDistrict,
@@ -130,7 +140,7 @@ const sampleCityData: CityData = {
130
140
  },
131
141
  ],
132
142
  bounds: { minX: -5, maxX: 85, minZ: -5, maxZ: 80 },
133
- metadata: { totalFiles: 31, totalDirectories: 4, rootPath: '/project' },
143
+ metadata: { totalFiles: 31, totalDirectories: 4, rootPath: '/project', analyzedAt: new Date() },
134
144
  };
135
145
 
136
146
  // Large city for stress testing
@@ -181,6 +191,7 @@ function generateLargeCityData(): CityData {
181
191
  totalFiles: buildings.length,
182
192
  totalDirectories: districts.length,
183
193
  rootPath: '/large-project',
194
+ analyzedAt: new Date(),
184
195
  },
185
196
  };
186
197
  }
@@ -232,6 +243,7 @@ function generateMonorepoCityData(): CityData {
232
243
  totalFiles: buildings.length,
233
244
  totalDirectories: districts.length,
234
245
  rootPath: '/monorepo',
246
+ analyzedAt: new Date(),
235
247
  },
236
248
  };
237
249
  }
@@ -925,7 +937,7 @@ const DirectorySelectionTemplate: React.FC = () => {
925
937
  }}
926
938
  >
927
939
  <div style={{ marginBottom: 12, fontSize: 12, color: '#64748b' }}>
928
- Click a directory to focus (collapse others). Click again or "Show All" to reset.
940
+ Click a directory to focus (collapse others). Click again or &quot;Show All&quot; to reset.
929
941
  </div>
930
942
  <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
931
943
  <button
@@ -985,3 +997,1007 @@ const DirectorySelectionTemplate: React.FC = () => {
985
997
  export const DirectorySelection: Story = {
986
998
  render: () => <DirectorySelectionTemplate />,
987
999
  };
1000
+
1001
+ /**
1002
+ * Tour Scenario Tester - Test all combinations of focusDirectory and highlightLayers
1003
+ *
1004
+ * This story allows testing the animation system across all documented scenarios:
1005
+ * - Baseline (no focus, no highlights)
1006
+ * - Focus only
1007
+ * - Highlight only
1008
+ * - Focus + Highlight combinations
1009
+ * - Transitions between states
1010
+ *
1011
+ * See docs/TOUR_TEST_SCENARIOS.md for full documentation.
1012
+ */
1013
+ interface TestScenario {
1014
+ id: string;
1015
+ name: string;
1016
+ description: string;
1017
+ focusDirectory: string | null;
1018
+ focusColor?: string | null;
1019
+ highlightLayers: HighlightLayer[];
1020
+ isolationMode: IsolationMode;
1021
+ }
1022
+
1023
+ const testScenarios: TestScenario[] = [
1024
+ // Base scenarios
1025
+ {
1026
+ id: 'S1-baseline',
1027
+ name: 'S1: Baseline',
1028
+ description: 'Full city view, no focus, no highlights',
1029
+ focusDirectory: null,
1030
+ highlightLayers: [],
1031
+ isolationMode: 'none',
1032
+ },
1033
+ {
1034
+ id: 'S2-focus-only',
1035
+ name: 'S2: Focus Only (src)',
1036
+ description: 'Camera zooms to src, others collapse, focused area highlighted',
1037
+ focusDirectory: 'auth-server/src',
1038
+ focusColor: '#3b82f6',
1039
+ highlightLayers: [],
1040
+ isolationMode: 'collapse',
1041
+ },
1042
+ {
1043
+ id: 'S2b-focus-only-tests',
1044
+ name: 'S2b: Focus Only (bruno)',
1045
+ description: 'Camera zooms to bruno directory, highlighted in green',
1046
+ focusDirectory: 'auth-server/bruno',
1047
+ focusColor: '#22c55e',
1048
+ highlightLayers: [],
1049
+ isolationMode: 'collapse',
1050
+ },
1051
+ {
1052
+ id: 'S3-highlight-only',
1053
+ name: 'S3: Highlight Only',
1054
+ description: 'Full view with highlight layer, non-highlighted collapse',
1055
+ focusDirectory: null,
1056
+ highlightLayers: [
1057
+ {
1058
+ id: 'api-layer',
1059
+ name: 'API Routes',
1060
+ enabled: true,
1061
+ color: '#22c55e',
1062
+ items: [{ path: 'auth-server/src/app/api', type: 'directory' as const }],
1063
+ },
1064
+ ],
1065
+ isolationMode: 'collapse',
1066
+ },
1067
+ {
1068
+ id: 'S4-focus-highlight-same',
1069
+ name: 'S4: Focus + Highlight (same directory)',
1070
+ description: 'Focus and highlight on same directory',
1071
+ focusDirectory: 'auth-server/src/app/api',
1072
+ highlightLayers: [
1073
+ {
1074
+ id: 'api-layer',
1075
+ name: 'API Routes',
1076
+ enabled: true,
1077
+ color: '#3b82f6',
1078
+ items: [{ path: 'auth-server/src/app/api', type: 'directory' as const }],
1079
+ },
1080
+ ],
1081
+ isolationMode: 'collapse',
1082
+ },
1083
+ {
1084
+ id: 'S5-focus-highlight-subset',
1085
+ name: 'S5: Focus + Highlight (subset)',
1086
+ description: 'Focus on src, highlight only components subset',
1087
+ focusDirectory: 'auth-server/src',
1088
+ highlightLayers: [
1089
+ {
1090
+ id: 'lib-layer',
1091
+ name: 'Libraries',
1092
+ enabled: true,
1093
+ color: '#8b5cf6',
1094
+ items: [{ path: 'auth-server/src/lib', type: 'directory' as const }],
1095
+ },
1096
+ ],
1097
+ isolationMode: 'collapse',
1098
+ },
1099
+ {
1100
+ id: 'S6-multiple-highlights-focus',
1101
+ name: 'S6: Multiple Highlights (with focus)',
1102
+ description: 'Two highlight layers within focused area',
1103
+ focusDirectory: 'auth-server/src',
1104
+ highlightLayers: [
1105
+ {
1106
+ id: 'api-layer',
1107
+ name: 'API Routes',
1108
+ enabled: true,
1109
+ color: '#22c55e',
1110
+ items: [{ path: 'auth-server/src/app/api', type: 'directory' as const }],
1111
+ },
1112
+ {
1113
+ id: 'lib-layer',
1114
+ name: 'Libraries',
1115
+ enabled: true,
1116
+ color: '#f59e0b',
1117
+ items: [{ path: 'auth-server/src/lib', type: 'directory' as const }],
1118
+ },
1119
+ ],
1120
+ isolationMode: 'collapse',
1121
+ },
1122
+ {
1123
+ id: 'S7-multiple-highlights-no-focus',
1124
+ name: 'S7: Multiple Highlights (no focus)',
1125
+ description: 'Two highlight layers, non-highlighted collapse',
1126
+ focusDirectory: null,
1127
+ highlightLayers: [
1128
+ {
1129
+ id: 'api-layer',
1130
+ name: 'API Routes',
1131
+ enabled: true,
1132
+ color: '#3b82f6',
1133
+ items: [{ path: 'auth-server/src/app/api', type: 'directory' as const }],
1134
+ },
1135
+ {
1136
+ id: 'bruno-layer',
1137
+ name: 'Bruno Tests',
1138
+ enabled: true,
1139
+ color: '#ef4444',
1140
+ items: [{ path: 'auth-server/bruno', type: 'directory' as const }],
1141
+ },
1142
+ ],
1143
+ isolationMode: 'collapse',
1144
+ },
1145
+ // Edge cases
1146
+ {
1147
+ id: 'E3-overlapping-highlights',
1148
+ name: 'E3: Overlapping Highlights',
1149
+ description: 'Two layers highlight overlapping paths',
1150
+ focusDirectory: 'auth-server/src',
1151
+ highlightLayers: [
1152
+ {
1153
+ id: 'src-layer',
1154
+ name: 'All Source',
1155
+ enabled: true,
1156
+ color: '#22c55e',
1157
+ items: [{ path: 'auth-server/src', type: 'directory' as const }],
1158
+ },
1159
+ {
1160
+ id: 'api-layer',
1161
+ name: 'API Only',
1162
+ enabled: true,
1163
+ color: '#ef4444',
1164
+ items: [{ path: 'auth-server/src/app/api', type: 'directory' as const }],
1165
+ },
1166
+ ],
1167
+ isolationMode: 'collapse',
1168
+ },
1169
+ {
1170
+ id: 'E4-focus-inside-highlight',
1171
+ name: 'E4: Focus Inside Highlight',
1172
+ description: 'Focus on subset of highlighted area',
1173
+ focusDirectory: 'auth-server/src/app/api/auth',
1174
+ highlightLayers: [
1175
+ {
1176
+ id: 'api-layer',
1177
+ name: 'All API',
1178
+ enabled: true,
1179
+ color: '#8b5cf6',
1180
+ items: [{ path: 'auth-server/src/app/api', type: 'directory' as const }],
1181
+ },
1182
+ ],
1183
+ isolationMode: 'collapse',
1184
+ },
1185
+ ];
1186
+
1187
+ const TourScenarioTesterTemplate: React.FC = () => {
1188
+ const [currentScenarioIndex, setCurrentScenarioIndex] = React.useState(0);
1189
+ const [transitionLog, setTransitionLog] = React.useState<string[]>([]);
1190
+ const prevScenarioRef = React.useRef<TestScenario | null>(null);
1191
+
1192
+ const scenario = testScenarios[currentScenarioIndex];
1193
+
1194
+ // Log transitions
1195
+ React.useEffect(() => {
1196
+ const prev = prevScenarioRef.current;
1197
+ if (prev && prev.id !== scenario.id) {
1198
+ const logEntry = `${new Date().toLocaleTimeString()} | ${prev.name} → ${scenario.name}`;
1199
+ setTransitionLog(logs => [...logs.slice(-9), logEntry]);
1200
+ console.log('[TourScenario]', logEntry);
1201
+ console.log(' From:', { focus: prev.focusDirectory, layers: prev.highlightLayers.length });
1202
+ console.log(' To:', {
1203
+ focus: scenario.focusDirectory,
1204
+ layers: scenario.highlightLayers.length,
1205
+ });
1206
+ }
1207
+ prevScenarioRef.current = scenario;
1208
+ }, [scenario]);
1209
+
1210
+ const goToScenario = (index: number) => {
1211
+ if (index >= 0 && index < testScenarios.length) {
1212
+ setCurrentScenarioIndex(index);
1213
+ }
1214
+ };
1215
+
1216
+ return (
1217
+ <div
1218
+ style={{ height: '100vh', display: 'flex', flexDirection: 'column', position: 'relative' }}
1219
+ >
1220
+ {/* 3D City */}
1221
+ <FileCity3D
1222
+ cityData={authServerCityData as CityData}
1223
+ height="100%"
1224
+ heightScaling="linear"
1225
+ linearScale={0.5}
1226
+ focusDirectory={scenario.focusDirectory}
1227
+ focusColor={scenario.focusColor}
1228
+ highlightLayers={scenario.highlightLayers}
1229
+ isolationMode={scenario.isolationMode}
1230
+ dimOpacity={0.12}
1231
+ animation={{
1232
+ startFlat: false,
1233
+ staggerDelay: 8,
1234
+ tension: 140,
1235
+ friction: 14,
1236
+ }}
1237
+ showControls={true}
1238
+ />
1239
+
1240
+ {/* Scenario selector panel */}
1241
+ <div
1242
+ style={{
1243
+ position: 'absolute',
1244
+ top: 16,
1245
+ left: 16,
1246
+ zIndex: 100,
1247
+ background: 'rgba(15, 23, 42, 0.95)',
1248
+ border: '1px solid #334155',
1249
+ borderRadius: 8,
1250
+ padding: 16,
1251
+ color: '#e2e8f0',
1252
+ fontFamily: 'system-ui, sans-serif',
1253
+ maxWidth: 360,
1254
+ }}
1255
+ >
1256
+ <h3 style={{ margin: '0 0 12px', fontSize: 14, fontWeight: 600 }}>
1257
+ Tour Scenario Tester
1258
+ </h3>
1259
+
1260
+ {/* Scenario dropdown */}
1261
+ <select
1262
+ value={currentScenarioIndex}
1263
+ onChange={e => goToScenario(Number(e.target.value))}
1264
+ style={{
1265
+ width: '100%',
1266
+ padding: '8px 12px',
1267
+ background: '#1e293b',
1268
+ border: '1px solid #475569',
1269
+ borderRadius: 6,
1270
+ color: '#e2e8f0',
1271
+ fontSize: 13,
1272
+ marginBottom: 12,
1273
+ }}
1274
+ >
1275
+ {testScenarios.map((s, i) => (
1276
+ <option key={s.id} value={i}>
1277
+ {s.name}
1278
+ </option>
1279
+ ))}
1280
+ </select>
1281
+
1282
+ {/* Scenario description */}
1283
+ <p style={{ margin: '0 0 12px', fontSize: 12, color: '#94a3b8' }}>
1284
+ {scenario.description}
1285
+ </p>
1286
+
1287
+ {/* Current state */}
1288
+ <div style={{ fontSize: 11, color: '#64748b', marginBottom: 8 }}>
1289
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
1290
+ <strong>focusDirectory:</strong>{' '}
1291
+ <code style={{ color: '#22c55e' }}>{scenario.focusDirectory ?? 'null'}</code>
1292
+ {scenario.focusColor && (
1293
+ <span
1294
+ style={{
1295
+ width: 10,
1296
+ height: 10,
1297
+ borderRadius: 2,
1298
+ background: scenario.focusColor,
1299
+ marginLeft: 4,
1300
+ }}
1301
+ />
1302
+ )}
1303
+ </div>
1304
+ <div>
1305
+ <strong>highlightLayers:</strong>{' '}
1306
+ <code style={{ color: '#3b82f6' }}>{scenario.highlightLayers.length} layer(s)</code>
1307
+ </div>
1308
+ <div>
1309
+ <strong>isolationMode:</strong>{' '}
1310
+ <code style={{ color: '#f59e0b' }}>{scenario.isolationMode}</code>
1311
+ </div>
1312
+ </div>
1313
+
1314
+ {/* Highlight layer details */}
1315
+ {scenario.highlightLayers.length > 0 && (
1316
+ <div style={{ fontSize: 10, color: '#64748b', marginBottom: 8 }}>
1317
+ {scenario.highlightLayers.map(layer => (
1318
+ <div key={layer.id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
1319
+ <span
1320
+ style={{
1321
+ width: 10,
1322
+ height: 10,
1323
+ borderRadius: 2,
1324
+ background: layer.color,
1325
+ }}
1326
+ />
1327
+ <span>{layer.name}</span>
1328
+ </div>
1329
+ ))}
1330
+ </div>
1331
+ )}
1332
+
1333
+ {/* Quick navigation */}
1334
+ <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
1335
+ <button
1336
+ onClick={() => goToScenario(currentScenarioIndex - 1)}
1337
+ disabled={currentScenarioIndex === 0}
1338
+ style={{
1339
+ flex: 1,
1340
+ padding: '6px 12px',
1341
+ background: currentScenarioIndex === 0 ? '#1e293b' : '#334155',
1342
+ border: '1px solid #475569',
1343
+ borderRadius: 4,
1344
+ color: currentScenarioIndex === 0 ? '#475569' : '#e2e8f0',
1345
+ cursor: currentScenarioIndex === 0 ? 'not-allowed' : 'pointer',
1346
+ fontSize: 12,
1347
+ }}
1348
+ >
1349
+ ← Prev
1350
+ </button>
1351
+ <button
1352
+ onClick={() => goToScenario(0)}
1353
+ style={{
1354
+ padding: '6px 12px',
1355
+ background: '#334155',
1356
+ border: '1px solid #475569',
1357
+ borderRadius: 4,
1358
+ color: '#e2e8f0',
1359
+ cursor: 'pointer',
1360
+ fontSize: 12,
1361
+ }}
1362
+ >
1363
+ Reset
1364
+ </button>
1365
+ <button
1366
+ onClick={() => goToScenario(currentScenarioIndex + 1)}
1367
+ disabled={currentScenarioIndex === testScenarios.length - 1}
1368
+ style={{
1369
+ flex: 1,
1370
+ padding: '6px 12px',
1371
+ background:
1372
+ currentScenarioIndex === testScenarios.length - 1 ? '#1e293b' : '#334155',
1373
+ border: '1px solid #475569',
1374
+ borderRadius: 4,
1375
+ color:
1376
+ currentScenarioIndex === testScenarios.length - 1 ? '#475569' : '#e2e8f0',
1377
+ cursor:
1378
+ currentScenarioIndex === testScenarios.length - 1 ? 'not-allowed' : 'pointer',
1379
+ fontSize: 12,
1380
+ }}
1381
+ >
1382
+ Next →
1383
+ </button>
1384
+ </div>
1385
+ </div>
1386
+
1387
+ {/* Transition log */}
1388
+ {transitionLog.length > 0 && (
1389
+ <div
1390
+ style={{
1391
+ position: 'absolute',
1392
+ bottom: 16,
1393
+ left: 16,
1394
+ zIndex: 100,
1395
+ background: 'rgba(15, 23, 42, 0.9)',
1396
+ border: '1px solid #334155',
1397
+ borderRadius: 8,
1398
+ padding: 12,
1399
+ color: '#94a3b8',
1400
+ fontFamily: 'monospace',
1401
+ fontSize: 10,
1402
+ maxWidth: 400,
1403
+ }}
1404
+ >
1405
+ <div style={{ marginBottom: 4, color: '#64748b', fontWeight: 600 }}>
1406
+ Transition Log:
1407
+ </div>
1408
+ {transitionLog.map((log, i) => (
1409
+ <div key={i} style={{ opacity: 0.5 + (i / transitionLog.length) * 0.5 }}>
1410
+ {log}
1411
+ </div>
1412
+ ))}
1413
+ </div>
1414
+ )}
1415
+
1416
+ {/* Scenario indicator pills */}
1417
+ <div
1418
+ style={{
1419
+ position: 'absolute',
1420
+ top: 16,
1421
+ right: 16,
1422
+ zIndex: 100,
1423
+ display: 'flex',
1424
+ gap: 4,
1425
+ }}
1426
+ >
1427
+ {testScenarios.map((s, i) => (
1428
+ <button
1429
+ key={s.id}
1430
+ onClick={() => goToScenario(i)}
1431
+ title={s.name}
1432
+ style={{
1433
+ width: i === currentScenarioIndex ? 24 : 8,
1434
+ height: 8,
1435
+ borderRadius: 4,
1436
+ border: 'none',
1437
+ background: i === currentScenarioIndex ? '#3b82f6' : '#475569',
1438
+ cursor: 'pointer',
1439
+ transition: 'all 0.2s',
1440
+ }}
1441
+ />
1442
+ ))}
1443
+ </div>
1444
+ </div>
1445
+ );
1446
+ };
1447
+
1448
+ export const TourScenarioTester: Story = {
1449
+ render: () => <TourScenarioTesterTemplate />,
1450
+ parameters: {
1451
+ docs: {
1452
+ description: {
1453
+ story:
1454
+ 'Interactive tester for all tour scenarios. See docs/TOUR_TEST_SCENARIOS.md for documentation.',
1455
+ },
1456
+ },
1457
+ },
1458
+ };
1459
+
1460
+ /**
1461
+ * Camera Controls - Test programmatic camera rotation and movement
1462
+ */
1463
+ const CameraControlsTemplate: React.FC = () => {
1464
+ const [currentAngle, setCurrentAngle] = React.useState<number | null>(null);
1465
+ const [currentTilt, setCurrentTilt] = React.useState<number | null>(null);
1466
+ const [currentTarget, setCurrentTarget] = React.useState<{ x: number; y: number; z: number } | null>(null);
1467
+ const [customAngle, setCustomAngle] = React.useState(0);
1468
+ const [duration, setDuration] = React.useState<number | undefined>(undefined);
1469
+
1470
+ // Update angle, tilt, and target display periodically
1471
+ React.useEffect(() => {
1472
+ const interval = setInterval(() => {
1473
+ const angle = getCameraAngle();
1474
+ const tilt = getCameraTilt();
1475
+ const target = getCameraTarget();
1476
+ if (angle !== null) {
1477
+ setCurrentAngle(Math.round(angle));
1478
+ }
1479
+ if (tilt !== null) {
1480
+ setCurrentTilt(Math.round(tilt));
1481
+ }
1482
+ if (target !== null) {
1483
+ setCurrentTarget({
1484
+ x: Math.round(target.x),
1485
+ y: Math.round(target.y),
1486
+ z: Math.round(target.z),
1487
+ });
1488
+ }
1489
+ }, 100);
1490
+ return () => clearInterval(interval);
1491
+ }, []);
1492
+
1493
+ // Get rotation options based on current duration setting
1494
+ const getRotateOptions = () => duration ? { duration } : undefined;
1495
+
1496
+ const buttonStyle = {
1497
+ padding: '10px 16px',
1498
+ background: '#334155',
1499
+ border: '1px solid #475569',
1500
+ borderRadius: 6,
1501
+ color: '#e2e8f0',
1502
+ cursor: 'pointer',
1503
+ fontSize: 13,
1504
+ fontWeight: 500 as const,
1505
+ minWidth: 80,
1506
+ };
1507
+
1508
+ const directionButtonStyle = {
1509
+ ...buttonStyle,
1510
+ width: 60,
1511
+ height: 60,
1512
+ display: 'flex',
1513
+ alignItems: 'center',
1514
+ justifyContent: 'center',
1515
+ flexDirection: 'column' as const,
1516
+ gap: 2,
1517
+ };
1518
+
1519
+ return (
1520
+ <div
1521
+ style={{ height: '100vh', display: 'flex', flexDirection: 'column', position: 'relative' }}
1522
+ >
1523
+ {/* 3D City */}
1524
+ <FileCity3D
1525
+ cityData={authServerCityData as CityData}
1526
+ height="100%"
1527
+ heightScaling="linear"
1528
+ linearScale={0.5}
1529
+ animation={{
1530
+ startFlat: true,
1531
+ autoStartDelay: 600,
1532
+ staggerDelay: 8,
1533
+ tension: 140,
1534
+ friction: 14,
1535
+ }}
1536
+ showControls={false}
1537
+ />
1538
+
1539
+ {/* Camera controls panel */}
1540
+ <div
1541
+ style={{
1542
+ position: 'absolute',
1543
+ top: 16,
1544
+ left: 16,
1545
+ zIndex: 100,
1546
+ background: 'rgba(15, 23, 42, 0.95)',
1547
+ border: '1px solid #334155',
1548
+ borderRadius: 8,
1549
+ padding: 16,
1550
+ color: '#e2e8f0',
1551
+ fontFamily: 'system-ui, sans-serif',
1552
+ minWidth: 280,
1553
+ }}
1554
+ >
1555
+ <h3 style={{ margin: '0 0 16px', fontSize: 14, fontWeight: 600 }}>
1556
+ Camera Controls
1557
+ </h3>
1558
+
1559
+ {/* Current angle and tilt display */}
1560
+ <div
1561
+ style={{
1562
+ display: 'flex',
1563
+ gap: 8,
1564
+ marginBottom: 16,
1565
+ }}
1566
+ >
1567
+ <div
1568
+ style={{
1569
+ flex: 1,
1570
+ background: '#1e293b',
1571
+ padding: '8px 12px',
1572
+ borderRadius: 6,
1573
+ textAlign: 'center',
1574
+ }}
1575
+ >
1576
+ <div style={{ fontSize: 11, color: '#64748b', marginBottom: 4 }}>Rotation</div>
1577
+ <div style={{ fontSize: 20, fontWeight: 600, color: '#3b82f6' }}>
1578
+ {currentAngle !== null ? `${currentAngle}°` : '—'}
1579
+ </div>
1580
+ </div>
1581
+ <div
1582
+ style={{
1583
+ flex: 1,
1584
+ background: '#1e293b',
1585
+ padding: '8px 12px',
1586
+ borderRadius: 6,
1587
+ textAlign: 'center',
1588
+ }}
1589
+ >
1590
+ <div style={{ fontSize: 11, color: '#64748b', marginBottom: 4 }}>Tilt</div>
1591
+ <div style={{ fontSize: 20, fontWeight: 600, color: '#22c55e' }}>
1592
+ {currentTilt !== null ? `${currentTilt}°` : '—'}
1593
+ </div>
1594
+ </div>
1595
+ </div>
1596
+
1597
+ {/* Duration control */}
1598
+ <div style={{ marginBottom: 16 }}>
1599
+ <div style={{ fontSize: 11, color: '#64748b', marginBottom: 8 }}>
1600
+ Animation Duration: {duration ? `${duration}ms` : 'Spring (default)'}
1601
+ </div>
1602
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
1603
+ {[undefined, 500, 1000, 2000, 3000, 5000].map((d) => (
1604
+ <button
1605
+ key={d ?? 'spring'}
1606
+ onClick={() => setDuration(d)}
1607
+ style={{
1608
+ padding: '6px 10px',
1609
+ background: duration === d ? '#3b82f6' : '#1e293b',
1610
+ border: '1px solid #475569',
1611
+ borderRadius: 4,
1612
+ color: duration === d ? '#ffffff' : '#94a3b8',
1613
+ cursor: 'pointer',
1614
+ fontSize: 11,
1615
+ }}
1616
+ >
1617
+ {d ? `${d}ms` : 'Spring'}
1618
+ </button>
1619
+ ))}
1620
+ </div>
1621
+ </div>
1622
+
1623
+ {/* Cardinal direction buttons */}
1624
+ <div style={{ marginBottom: 16 }}>
1625
+ <div style={{ fontSize: 11, color: '#64748b', marginBottom: 8 }}>Cardinal Directions</div>
1626
+ <div
1627
+ style={{
1628
+ display: 'grid',
1629
+ gridTemplateColumns: 'repeat(3, 60px)',
1630
+ gridTemplateRows: 'repeat(3, 60px)',
1631
+ gap: 4,
1632
+ justifyContent: 'center',
1633
+ }}
1634
+ >
1635
+ <div /> {/* Empty top-left */}
1636
+ <button
1637
+ onClick={() => rotateCameraTo('north', getRotateOptions())}
1638
+ style={directionButtonStyle}
1639
+ title="North (180°)"
1640
+ >
1641
+ <span style={{ fontSize: 16 }}>N</span>
1642
+ <span style={{ fontSize: 9, color: '#64748b' }}>180°</span>
1643
+ </button>
1644
+ <div /> {/* Empty top-right */}
1645
+
1646
+ <button
1647
+ onClick={() => rotateCameraTo('west', getRotateOptions())}
1648
+ style={directionButtonStyle}
1649
+ title="West (90°)"
1650
+ >
1651
+ <span style={{ fontSize: 16 }}>W</span>
1652
+ <span style={{ fontSize: 9, color: '#64748b' }}>90°</span>
1653
+ </button>
1654
+ <button
1655
+ onClick={() => resetCamera()}
1656
+ style={{
1657
+ ...directionButtonStyle,
1658
+ background: '#1e293b',
1659
+ }}
1660
+ title="Reset Camera"
1661
+ >
1662
+ <span style={{ fontSize: 12 }}>Reset</span>
1663
+ </button>
1664
+ <button
1665
+ onClick={() => rotateCameraTo('east', getRotateOptions())}
1666
+ style={directionButtonStyle}
1667
+ title="East (270°)"
1668
+ >
1669
+ <span style={{ fontSize: 16 }}>E</span>
1670
+ <span style={{ fontSize: 9, color: '#64748b' }}>270°</span>
1671
+ </button>
1672
+
1673
+ <div /> {/* Empty bottom-left */}
1674
+ <button
1675
+ onClick={() => rotateCameraTo('south', getRotateOptions())}
1676
+ style={directionButtonStyle}
1677
+ title="South (0°)"
1678
+ >
1679
+ <span style={{ fontSize: 16 }}>S</span>
1680
+ <span style={{ fontSize: 9, color: '#64748b' }}>0°</span>
1681
+ </button>
1682
+ <div /> {/* Empty bottom-right */}
1683
+ </div>
1684
+ </div>
1685
+
1686
+ {/* Custom angle input */}
1687
+ <div style={{ marginBottom: 16 }}>
1688
+ <div style={{ fontSize: 11, color: '#64748b', marginBottom: 8 }}>Custom Angle</div>
1689
+ <div style={{ display: 'flex', gap: 8 }}>
1690
+ <input
1691
+ type="range"
1692
+ min={0}
1693
+ max={360}
1694
+ value={customAngle}
1695
+ onChange={e => setCustomAngle(Number(e.target.value))}
1696
+ style={{ flex: 1 }}
1697
+ />
1698
+ <input
1699
+ type="number"
1700
+ min={0}
1701
+ max={360}
1702
+ value={customAngle}
1703
+ onChange={e => setCustomAngle(Number(e.target.value))}
1704
+ style={{
1705
+ width: 60,
1706
+ padding: '4px 8px',
1707
+ background: '#1e293b',
1708
+ border: '1px solid #475569',
1709
+ borderRadius: 4,
1710
+ color: '#e2e8f0',
1711
+ fontSize: 13,
1712
+ textAlign: 'center',
1713
+ }}
1714
+ />
1715
+ <button
1716
+ onClick={() => rotateCameraTo(customAngle, getRotateOptions())}
1717
+ style={{ ...buttonStyle, minWidth: 50 }}
1718
+ >
1719
+ Go
1720
+ </button>
1721
+ </div>
1722
+ </div>
1723
+
1724
+ {/* Quick angle presets */}
1725
+ <div style={{ marginBottom: 16 }}>
1726
+ <div style={{ fontSize: 11, color: '#64748b', marginBottom: 8 }}>Quick Angles (shortest path)</div>
1727
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
1728
+ {[0, 45, 90, 135, 180, 225, 270, 315].map(angle => (
1729
+ <button
1730
+ key={angle}
1731
+ onClick={() => rotateCameraTo(angle, getRotateOptions())}
1732
+ style={{
1733
+ padding: '6px 10px',
1734
+ background: '#1e293b',
1735
+ border: '1px solid #475569',
1736
+ borderRadius: 4,
1737
+ color: '#94a3b8',
1738
+ cursor: 'pointer',
1739
+ fontSize: 11,
1740
+ }}
1741
+ >
1742
+ {angle}°
1743
+ </button>
1744
+ ))}
1745
+ </div>
1746
+ </div>
1747
+
1748
+ {/* Relative rotation */}
1749
+ <div style={{ marginBottom: 16 }}>
1750
+ <div style={{ fontSize: 11, color: '#64748b', marginBottom: 8 }}>Rotate (horizontal arc)</div>
1751
+ <div style={{ display: 'flex', gap: 6 }}>
1752
+ <button
1753
+ onClick={() => rotateCameraBy(-90, getRotateOptions())}
1754
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1755
+ title="Counter-clockwise 90°"
1756
+ >
1757
+ -90° CCW
1758
+ </button>
1759
+ <button
1760
+ onClick={() => rotateCameraBy(-45, getRotateOptions())}
1761
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1762
+ title="Counter-clockwise 45°"
1763
+ >
1764
+ -45°
1765
+ </button>
1766
+ <button
1767
+ onClick={() => rotateCameraBy(45, getRotateOptions())}
1768
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1769
+ title="Clockwise 45°"
1770
+ >
1771
+ +45°
1772
+ </button>
1773
+ <button
1774
+ onClick={() => rotateCameraBy(90, getRotateOptions())}
1775
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1776
+ title="Clockwise 90°"
1777
+ >
1778
+ +90° CW
1779
+ </button>
1780
+ </div>
1781
+ <div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
1782
+ <button
1783
+ onClick={() => rotateCameraBy(-180, getRotateOptions())}
1784
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1785
+ title="Counter-clockwise 180°"
1786
+ >
1787
+ -180° CCW
1788
+ </button>
1789
+ <button
1790
+ onClick={() => rotateCameraBy(180, getRotateOptions())}
1791
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1792
+ title="Clockwise 180°"
1793
+ >
1794
+ +180° CW
1795
+ </button>
1796
+ <button
1797
+ onClick={() => rotateCameraBy(360, getRotateOptions())}
1798
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1799
+ title="Full rotation clockwise"
1800
+ >
1801
+ +360° Full
1802
+ </button>
1803
+ </div>
1804
+ </div>
1805
+
1806
+ {/* Tilt presets */}
1807
+ <div style={{ marginBottom: 16 }}>
1808
+ <div style={{ fontSize: 11, color: '#64748b', marginBottom: 8 }}>Tilt Presets (vertical arc)</div>
1809
+ <div style={{ display: 'flex', gap: 6 }}>
1810
+ <button
1811
+ onClick={() => tiltCameraTo('top', getRotateOptions())}
1812
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1813
+ title="Top-down view (15°)"
1814
+ >
1815
+ Top (15°)
1816
+ </button>
1817
+ <button
1818
+ onClick={() => tiltCameraTo('high', getRotateOptions())}
1819
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1820
+ title="High angle (35°)"
1821
+ >
1822
+ High (35°)
1823
+ </button>
1824
+ <button
1825
+ onClick={() => tiltCameraTo('low', getRotateOptions())}
1826
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1827
+ title="Low angle (60°)"
1828
+ >
1829
+ Low (60°)
1830
+ </button>
1831
+ <button
1832
+ onClick={() => tiltCameraTo('level', getRotateOptions())}
1833
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1834
+ title="Level view (80°)"
1835
+ >
1836
+ Level (80°)
1837
+ </button>
1838
+ </div>
1839
+ </div>
1840
+
1841
+ {/* Relative tilt */}
1842
+ <div style={{ marginBottom: 16 }}>
1843
+ <div style={{ fontSize: 11, color: '#64748b', marginBottom: 8 }}>Tilt By (+ = down, - = up)</div>
1844
+ <div style={{ display: 'flex', gap: 6 }}>
1845
+ <button
1846
+ onClick={() => tiltCameraBy(-30, getRotateOptions())}
1847
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1848
+ title="Tilt up 30°"
1849
+ >
1850
+ -30° Up
1851
+ </button>
1852
+ <button
1853
+ onClick={() => tiltCameraBy(-15, getRotateOptions())}
1854
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1855
+ title="Tilt up 15°"
1856
+ >
1857
+ -15°
1858
+ </button>
1859
+ <button
1860
+ onClick={() => tiltCameraBy(15, getRotateOptions())}
1861
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1862
+ title="Tilt down 15°"
1863
+ >
1864
+ +15°
1865
+ </button>
1866
+ <button
1867
+ onClick={() => tiltCameraBy(30, getRotateOptions())}
1868
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1869
+ title="Tilt down 30°"
1870
+ >
1871
+ +30° Down
1872
+ </button>
1873
+ </div>
1874
+ </div>
1875
+
1876
+ {/* Camera target */}
1877
+ <div style={{ marginBottom: 16 }}>
1878
+ <div style={{ fontSize: 11, color: '#64748b', marginBottom: 8 }}>
1879
+ Camera Target: {currentTarget ? `(${currentTarget.x}, ${currentTarget.y}, ${currentTarget.z})` : '—'}
1880
+ </div>
1881
+ <div style={{ display: 'flex', gap: 6 }}>
1882
+ <button
1883
+ onClick={() => setCameraTarget(0, 0, 0, getRotateOptions())}
1884
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1885
+ title="Look at center"
1886
+ >
1887
+ Center
1888
+ </button>
1889
+ <button
1890
+ onClick={() => setCameraTarget(-40, 0, -40, getRotateOptions())}
1891
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1892
+ title="Look at top-left area"
1893
+ >
1894
+ Top-Left
1895
+ </button>
1896
+ <button
1897
+ onClick={() => setCameraTarget(40, 0, 40, getRotateOptions())}
1898
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1899
+ title="Look at bottom-right area"
1900
+ >
1901
+ Bottom-Right
1902
+ </button>
1903
+ </div>
1904
+ <div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
1905
+ <button
1906
+ onClick={() => setCameraTarget(20, 0, 0, getRotateOptions())}
1907
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1908
+ title="Look at right side"
1909
+ >
1910
+ Right (+X)
1911
+ </button>
1912
+ <button
1913
+ onClick={() => setCameraTarget(-20, 0, 0, getRotateOptions())}
1914
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1915
+ title="Look at left side"
1916
+ >
1917
+ Left (-X)
1918
+ </button>
1919
+ <button
1920
+ onClick={() => setCameraTarget(0, 0, 30, getRotateOptions())}
1921
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1922
+ title="Look at front"
1923
+ >
1924
+ Front (+Z)
1925
+ </button>
1926
+ </div>
1927
+ </div>
1928
+
1929
+ {/* Move to coordinates */}
1930
+ <div>
1931
+ <div style={{ fontSize: 11, color: '#64748b', marginBottom: 8 }}>Move to Position</div>
1932
+ <div style={{ display: 'flex', gap: 6 }}>
1933
+ <button
1934
+ onClick={() => moveCameraTo(0, 0, 50)}
1935
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1936
+ >
1937
+ Center
1938
+ </button>
1939
+ <button
1940
+ onClick={() => moveCameraTo(-50, -50, 40)}
1941
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1942
+ >
1943
+ Top-Left
1944
+ </button>
1945
+ <button
1946
+ onClick={() => moveCameraTo(50, 50, 40)}
1947
+ style={{ ...buttonStyle, flex: 1, fontSize: 11 }}
1948
+ >
1949
+ Bottom-Right
1950
+ </button>
1951
+ </div>
1952
+ </div>
1953
+ </div>
1954
+
1955
+ {/* Angle reference */}
1956
+ <div
1957
+ style={{
1958
+ position: 'absolute',
1959
+ bottom: 16,
1960
+ right: 16,
1961
+ zIndex: 100,
1962
+ background: 'rgba(15, 23, 42, 0.9)',
1963
+ border: '1px solid #334155',
1964
+ borderRadius: 8,
1965
+ padding: 12,
1966
+ color: '#94a3b8',
1967
+ fontFamily: 'monospace',
1968
+ fontSize: 11,
1969
+ maxWidth: 200,
1970
+ }}
1971
+ >
1972
+ <div style={{ fontWeight: 600, marginBottom: 8, color: '#3b82f6' }}>Rotation (horizontal)</div>
1973
+ <div>0° = South (default)</div>
1974
+ <div>90° = West</div>
1975
+ <div>180° = North</div>
1976
+ <div>270° = East</div>
1977
+ <div style={{ marginTop: 8, borderTop: '1px solid #334155', paddingTop: 8 }}>
1978
+ <div style={{ fontWeight: 600, marginBottom: 4, color: '#22c55e' }}>Tilt (vertical)</div>
1979
+ <div>0° = Top-down</div>
1980
+ <div>45° = Diagonal</div>
1981
+ <div>90° = Level/Horizontal</div>
1982
+ </div>
1983
+ <div style={{ marginTop: 8, borderTop: '1px solid #334155', paddingTop: 8 }}>
1984
+ <div style={{ color: '#e2e8f0', marginBottom: 4 }}>Direction</div>
1985
+ <div>Rotate: + = CW, - = CCW</div>
1986
+ <div>Tilt: + = down, - = up</div>
1987
+ </div>
1988
+ </div>
1989
+ </div>
1990
+ );
1991
+ };
1992
+
1993
+ export const CameraControls: Story = {
1994
+ render: () => <CameraControlsTemplate />,
1995
+ parameters: {
1996
+ docs: {
1997
+ description: {
1998
+ story:
1999
+ 'Test programmatic camera rotation and movement. Use cardinal direction buttons, custom angles, or position buttons to control the camera.',
2000
+ },
2001
+ },
2002
+ },
2003
+ };