@principal-ai/file-city-react 0.5.34 → 0.5.36

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.
@@ -18,6 +18,9 @@ import type {
18
18
  CityBuilding,
19
19
  CityDistrict,
20
20
  FileConfigResult,
21
+ HighlightLayer as BuilderHighlightLayer,
22
+ LayerItem,
23
+ LayerRenderStrategy,
21
24
  } from '@principal-ai/file-city-builder';
22
25
  import * as THREE from 'three';
23
26
  import type { ThreeElements } from '@react-three/fiber';
@@ -33,34 +36,8 @@ declare module 'react' {
33
36
  }
34
37
 
35
38
  // Re-export types for convenience
36
- export type { CityData, CityBuilding, CityDistrict };
37
-
38
- // Highlight layer types
39
- export interface HighlightLayer {
40
- /** Unique identifier */
41
- id: string;
42
- /** Display name */
43
- name: string;
44
- /** Whether layer is active */
45
- enabled: boolean;
46
- /** Highlight color (hex) */
47
- color: string;
48
- /** Items to highlight */
49
- items: HighlightItem[];
50
- /** Opacity for highlighted items (0-1) */
51
- opacity?: number;
52
- /** Rendering priority (higher renders on top) */
53
- priority?: number;
54
- /** Border width in pixels */
55
- borderWidth?: number;
56
- }
57
-
58
- export interface HighlightItem {
59
- /** File or directory path */
60
- path: string;
61
- /** Type of item */
62
- type: 'file' | 'directory';
63
- }
39
+ export type { CityData, CityBuilding, CityDistrict, LayerItem, LayerRenderStrategy };
40
+ export type HighlightLayer = BuilderHighlightLayer;
64
41
 
65
42
  /** What to do with non-highlighted buildings */
66
43
  export type IsolationMode =
@@ -273,25 +250,71 @@ function getColorForFile(building: CityBuilding): string {
273
250
  return getConfigForFile(building).color;
274
251
  }
275
252
 
253
+ interface LayerMatch {
254
+ layer: HighlightLayer;
255
+ item: LayerItem;
256
+ color: string;
257
+ opacity: number;
258
+ borderWidth?: number;
259
+ renderStrategy: LayerRenderStrategy;
260
+ }
261
+
276
262
  /**
277
- * Check if a path is highlighted by any enabled layer.
263
+ * Get ALL layer matches for a path, sorted by priority (highest first).
264
+ * Returns array to support multiple layers rendering together (e.g., fill + border).
278
265
  */
279
- function getHighlightForPath(
266
+ function getLayerMatchesForPath(
280
267
  path: string,
281
268
  layers: HighlightLayer[],
282
- ): { color: string; opacity: number } | null {
269
+ ): LayerMatch[] {
270
+ const matches: LayerMatch[] = [];
271
+
283
272
  for (const layer of layers) {
284
273
  if (!layer.enabled) continue;
285
274
 
286
275
  for (const item of layer.items) {
276
+ let isMatch = false;
277
+
287
278
  if (item.type === 'file' && item.path === path) {
288
- return { color: layer.color, opacity: layer.opacity ?? 1 };
279
+ isMatch = true;
280
+ } else if (item.type === 'directory' && (path === item.path || path.startsWith(item.path + '/'))) {
281
+ isMatch = true;
289
282
  }
290
- if (item.type === 'directory' && (path === item.path || path.startsWith(item.path + '/'))) {
291
- return { color: layer.color, opacity: layer.opacity ?? 1 };
283
+
284
+ if (isMatch) {
285
+ matches.push({
286
+ layer,
287
+ item,
288
+ color: layer.color,
289
+ opacity: layer.opacity ?? 1,
290
+ borderWidth: layer.borderWidth,
291
+ renderStrategy: item.renderStrategy || 'border', // Default from 2D renderer
292
+ });
292
293
  }
293
294
  }
294
295
  }
296
+
297
+ // Sort by priority (highest first)
298
+ return matches.sort((a, b) => (b.layer.priority ?? 0) - (a.layer.priority ?? 0));
299
+ }
300
+
301
+ /**
302
+ * Get the highest-priority fill color for a path (backward compatibility).
303
+ * Returns the first matching layer with 'fill' strategy.
304
+ */
305
+ function getHighlightForPath(
306
+ path: string,
307
+ layers: HighlightLayer[],
308
+ ): { color: string; opacity: number } | null {
309
+ const matches = getLayerMatchesForPath(path, layers);
310
+
311
+ // Find first fill match
312
+ const fillMatch = matches.find(m => m.renderStrategy === 'fill');
313
+
314
+ if (fillMatch) {
315
+ return { color: fillMatch.color, opacity: fillMatch.opacity };
316
+ }
317
+
295
318
  return null;
296
319
  }
297
320
 
@@ -406,6 +429,252 @@ function BuildingEdges({
406
429
  );
407
430
  }
408
431
 
432
+ // ============================================================================
433
+ // Border Highlights - Colored edge outlines for highlighted buildings
434
+ // ============================================================================
435
+
436
+ interface BorderEdgeData {
437
+ x: number;
438
+ z: number;
439
+ fullHeight: number;
440
+ buildingIndex: number;
441
+ staggerDelayMs: number;
442
+ color: string;
443
+ opacity: number;
444
+ borderWidth: number;
445
+ edgeType: 'vertical' | 'horizontal-x' | 'horizontal-z'; // Edge orientation
446
+ width?: number; // For horizontal edges (length along X axis)
447
+ depth?: number; // For horizontal edges (length along Z axis)
448
+ }
449
+
450
+ interface BorderHighlightsProps {
451
+ buildings: CityBuilding[];
452
+ centerOffset: { x: number; z: number };
453
+ highlightLayers: HighlightLayer[];
454
+ growProgress: number;
455
+ minHeight: number;
456
+ baseOffset: number;
457
+ springDuration: number;
458
+ heightMultipliersRef: React.MutableRefObject<Float32Array | null>;
459
+ heightScaling: HeightScaling;
460
+ linearScale: number;
461
+ flatPatterns: FlatPattern[];
462
+ staggerIndices: number[];
463
+ animationConfig: AnimationConfig;
464
+ }
465
+
466
+ function BorderHighlights({
467
+ buildings,
468
+ centerOffset,
469
+ highlightLayers,
470
+ growProgress,
471
+ minHeight,
472
+ baseOffset,
473
+ springDuration,
474
+ heightMultipliersRef,
475
+ heightScaling,
476
+ linearScale,
477
+ flatPatterns,
478
+ staggerIndices,
479
+ animationConfig,
480
+ }: BorderHighlightsProps) {
481
+ const meshRef = useRef<THREE.InstancedMesh>(null);
482
+ const startTimeRef = useRef<number | null>(null);
483
+ const tempObject = useMemo(() => new THREE.Object3D(), []);
484
+ const tempColor = useMemo(() => new THREE.Color(), []);
485
+
486
+ // Pre-compute border edge data from buildings with border highlights
487
+ const borderEdgeData = useMemo(() => {
488
+ const edges: BorderEdgeData[] = [];
489
+
490
+ buildings.forEach((building, buildingIndex) => {
491
+ const matches = getLayerMatchesForPath(building.path, highlightLayers);
492
+
493
+ // Find border matches
494
+ const borderMatches = matches.filter(m => m.renderStrategy === 'border');
495
+
496
+ if (borderMatches.length === 0) return;
497
+
498
+ // Use highest priority border match
499
+ const borderMatch = borderMatches[0];
500
+
501
+ const [width, , depth] = building.dimensions;
502
+ const fullHeight = calculateBuildingHeight(building, heightScaling, linearScale, flatPatterns);
503
+ const x = building.position.x - centerOffset.x;
504
+ const z = building.position.z - centerOffset.z;
505
+ const staggerIndex = staggerIndices[buildingIndex] ?? buildingIndex;
506
+ const staggerDelayMs = (animationConfig.staggerDelay || 15) * staggerIndex;
507
+
508
+ const halfW = width / 2;
509
+ const halfD = depth / 2;
510
+
511
+ // Create 4 vertical corner edges
512
+ const corners = [
513
+ { x: x - halfW, z: z - halfD },
514
+ { x: x + halfW, z: z - halfD },
515
+ { x: x - halfW, z: z + halfD },
516
+ { x: x + halfW, z: z + halfD },
517
+ ];
518
+
519
+ corners.forEach(corner => {
520
+ edges.push({
521
+ x: corner.x,
522
+ z: corner.z,
523
+ fullHeight,
524
+ buildingIndex,
525
+ staggerDelayMs,
526
+ color: borderMatch.color,
527
+ opacity: borderMatch.opacity,
528
+ borderWidth: borderMatch.borderWidth ?? 2,
529
+ edgeType: 'vertical',
530
+ });
531
+ });
532
+
533
+ // Create 4 horizontal edges on top (roof outline)
534
+ // Two edges along X axis (front and back)
535
+ edges.push({
536
+ x: x,
537
+ z: z - halfD,
538
+ fullHeight,
539
+ buildingIndex,
540
+ staggerDelayMs,
541
+ color: borderMatch.color,
542
+ opacity: borderMatch.opacity,
543
+ borderWidth: borderMatch.borderWidth ?? 2,
544
+ edgeType: 'horizontal-x',
545
+ width,
546
+ });
547
+ edges.push({
548
+ x: x,
549
+ z: z + halfD,
550
+ fullHeight,
551
+ buildingIndex,
552
+ staggerDelayMs,
553
+ color: borderMatch.color,
554
+ opacity: borderMatch.opacity,
555
+ borderWidth: borderMatch.borderWidth ?? 2,
556
+ edgeType: 'horizontal-x',
557
+ width,
558
+ });
559
+
560
+ // Two edges along Z axis (left and right)
561
+ edges.push({
562
+ x: x - halfW,
563
+ z: z,
564
+ fullHeight,
565
+ buildingIndex,
566
+ staggerDelayMs,
567
+ color: borderMatch.color,
568
+ opacity: borderMatch.opacity,
569
+ borderWidth: borderMatch.borderWidth ?? 2,
570
+ edgeType: 'horizontal-z',
571
+ depth,
572
+ });
573
+ edges.push({
574
+ x: x + halfW,
575
+ z: z,
576
+ fullHeight,
577
+ buildingIndex,
578
+ staggerDelayMs,
579
+ color: borderMatch.color,
580
+ opacity: borderMatch.opacity,
581
+ borderWidth: borderMatch.borderWidth ?? 2,
582
+ edgeType: 'horizontal-z',
583
+ depth,
584
+ });
585
+ });
586
+
587
+ return edges;
588
+ }, [
589
+ buildings,
590
+ centerOffset,
591
+ highlightLayers,
592
+ heightScaling,
593
+ linearScale,
594
+ flatPatterns,
595
+ staggerIndices,
596
+ animationConfig.staggerDelay,
597
+ ]);
598
+
599
+ // Animate border edges
600
+ useFrame(({ clock }) => {
601
+ if (!meshRef.current || borderEdgeData.length === 0) return;
602
+
603
+ if (startTimeRef.current === null && growProgress > 0) {
604
+ startTimeRef.current = clock.elapsedTime * 1000;
605
+ }
606
+
607
+ const currentTime = clock.elapsedTime * 1000;
608
+ const animStartTime = startTimeRef.current ?? currentTime;
609
+
610
+ borderEdgeData.forEach((edge, idx) => {
611
+ const { x, z, fullHeight, staggerDelayMs, buildingIndex, color, opacity, borderWidth, edgeType, width, depth } = edge;
612
+
613
+ // Get height multiplier from shared ref (for collapse animation)
614
+ const heightMultiplier = heightMultipliersRef.current?.[buildingIndex] ?? 1;
615
+
616
+ // Calculate per-building animation progress
617
+ const elapsed = currentTime - animStartTime - staggerDelayMs;
618
+ let animProgress = growProgress;
619
+
620
+ if (growProgress > 0 && elapsed >= 0) {
621
+ const t = Math.min(elapsed / springDuration, 1);
622
+ const eased = 1 - Math.pow(1 - t, 3);
623
+ animProgress = eased * growProgress;
624
+ } else if (growProgress > 0 && elapsed < 0) {
625
+ animProgress = 0;
626
+ }
627
+
628
+ // Apply both grow animation and collapse multiplier
629
+ const height = animProgress * fullHeight * heightMultiplier + minHeight;
630
+
631
+ // Fixed thickness based on borderWidth (don't scale with building size)
632
+ const thickness = Math.max(0.2, borderWidth * 0.1); // Convert pixels to world units
633
+
634
+ if (edgeType === 'vertical') {
635
+ // Vertical corner edges
636
+ const yPosition = height / 2 + baseOffset;
637
+ tempObject.position.set(x, yPosition, z);
638
+ tempObject.rotation.set(0, 0, 0);
639
+ tempObject.scale.set(thickness, height, thickness);
640
+ } else if (edgeType === 'horizontal-x') {
641
+ // Horizontal edges along X axis (front/back of roof)
642
+ const yPosition = height + baseOffset;
643
+ tempObject.position.set(x, yPosition, z);
644
+ tempObject.rotation.set(0, 0, Math.PI / 2); // Rotate to horizontal along X
645
+ tempObject.scale.set(thickness, width!, thickness);
646
+ } else if (edgeType === 'horizontal-z') {
647
+ // Horizontal edges along Z axis (left/right of roof)
648
+ const yPosition = height + baseOffset;
649
+ tempObject.position.set(x, yPosition, z);
650
+ tempObject.rotation.set(Math.PI / 2, 0, 0); // Rotate to horizontal along Z
651
+ tempObject.scale.set(thickness, depth!, thickness);
652
+ }
653
+
654
+ tempObject.updateMatrix();
655
+ meshRef.current!.setMatrixAt(idx, tempObject.matrix);
656
+
657
+ // Set per-instance color with opacity
658
+ tempColor.set(color);
659
+ meshRef.current!.setColorAt(idx, tempColor);
660
+ });
661
+
662
+ meshRef.current.instanceMatrix.needsUpdate = true;
663
+ if (meshRef.current.instanceColor) {
664
+ meshRef.current.instanceColor.needsUpdate = true;
665
+ }
666
+ });
667
+
668
+ if (borderEdgeData.length === 0) return null;
669
+
670
+ return (
671
+ <instancedMesh ref={meshRef} args={[undefined, undefined, borderEdgeData.length]} frustumCulled={false}>
672
+ <boxGeometry args={[1, 1, 1]} />
673
+ <meshBasicMaterial transparent opacity={0.9} vertexColors />
674
+ </instancedMesh>
675
+ );
676
+ }
677
+
409
678
  // ============================================================================
410
679
  // Instanced Buildings - High performance rendering for large scenes
411
680
  // ============================================================================
@@ -532,9 +801,10 @@ function InstancedBuildings({
532
801
  return buildings.map((building, index) => {
533
802
  const [width, , depth] = building.dimensions;
534
803
  const fullHeight = calculateBuildingHeight(building, heightScaling, linearScale, flatPatterns);
535
- // Use highlight layer color if available, otherwise fall back to file config color
536
- const highlight = getHighlightForPath(building.path, highlightLayers);
537
- const color = highlight?.color ?? getColorForFile(building);
804
+ // Get all layer matches and find first fill match for building color
805
+ const matches = getLayerMatchesForPath(building.path, highlightLayers);
806
+ const fillMatch = matches.find(m => m.renderStrategy === 'fill');
807
+ const color = fillMatch?.color ?? getColorForFile(building);
538
808
 
539
809
  const x = building.position.x - centerOffset.x;
540
810
  const z = building.position.z - centerOffset.z;
@@ -751,6 +1021,23 @@ function InstancedBuildings({
751
1021
  springDuration={springDuration}
752
1022
  heightMultipliersRef={heightMultipliersRef}
753
1023
  />
1024
+
1025
+ {/* Border highlights (colored, layer-driven) */}
1026
+ <BorderHighlights
1027
+ buildings={buildings}
1028
+ centerOffset={centerOffset}
1029
+ highlightLayers={highlightLayers}
1030
+ growProgress={growProgress}
1031
+ minHeight={minHeight}
1032
+ baseOffset={baseOffset}
1033
+ springDuration={springDuration}
1034
+ heightMultipliersRef={heightMultipliersRef}
1035
+ heightScaling={heightScaling}
1036
+ linearScale={linearScale}
1037
+ flatPatterns={flatPatterns}
1038
+ staggerIndices={staggerIndices}
1039
+ animationConfig={animationConfig}
1040
+ />
754
1041
  </group>
755
1042
  );
756
1043
  }
@@ -1156,101 +1443,56 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
1156
1443
  const frameCount = useRef(0);
1157
1444
  const hasNotifiedReady = useRef(false);
1158
1445
  const prevIsFlatRef = useRef(isFlat); // Track previous isFlat to detect actual state changes
1159
- const isSyncingInitial = useRef(false); // Flag to prevent onStart from triggering during Frame 3 sync
1160
1446
 
1161
- // Calculate camera height to fit city in viewport (for top-down view)
1447
+ // Helper to calculate flat camera height with known FOV (50) and aspect ratio
1162
1448
  // Formula: height = citySize / (2 * tan(fov/2) * min(1, aspect))
1163
1449
  // Padding factor adds space around the city to match 2D component
1164
- const calculateFlatCameraHeight = useCallback(() => {
1165
- const perspCam = camera as THREE.PerspectiveCamera;
1166
- const fovRad = (perspCam.fov * Math.PI) / 180;
1450
+ const calculateFlatCameraHeight = useCallback((aspect: number) => {
1451
+ const fov = 50; // Known FOV that will be set on PerspectiveCamera
1452
+ const fovRad = (fov * Math.PI) / 180;
1167
1453
  const tanHalfFov = Math.tan(fovRad / 2);
1168
- const aspect = perspCam.aspect || 1;
1169
1454
  // Use min(1, aspect) to handle both landscape and portrait viewports
1170
1455
  const effectiveAspect = Math.min(1, aspect);
1171
1456
  const baseHeight = citySize / (2 * tanHalfFov * effectiveAspect);
1172
1457
  // Add padding to match 2D component's default padding
1173
1458
  const paddingFactor = 1.08;
1174
- const result = baseHeight * paddingFactor;
1175
- console.log('[calculateFlatCameraHeight]', {
1176
- fov: perspCam.fov,
1177
- aspect,
1178
- effectiveAspect,
1179
- citySize,
1180
- baseHeight,
1181
- result,
1182
- });
1183
- return result;
1184
- }, [camera, citySize]);
1185
-
1186
- // Compute target camera position
1187
- // When flat, always use top-down view (ignore focusTarget)
1188
- // When grown, use focusTarget if available, otherwise angled overview
1189
- const targetPos = useMemo(() => {
1190
- // Flat state: always top-down, ignore any focus
1191
- // Height calculated mathematically to match 2D view zoom level
1192
- if (isFlat) {
1193
- return {
1194
- x: 0,
1195
- y: calculateFlatCameraHeight(),
1196
- z: 0.001, // Near-zero for top-down (tiny offset to avoid gimbal lock)
1197
- targetX: 0,
1198
- targetY: 0,
1199
- targetZ: 0,
1200
- };
1201
- }
1459
+ return baseHeight * paddingFactor;
1460
+ }, [citySize]);
1202
1461
 
1203
- // Grown state: use focusTarget if available
1204
- if (focusTarget) {
1205
- const distance = Math.max(focusTarget.size * 2, 50);
1206
- const height = Math.max(focusTarget.size * 1.5, 40);
1207
- return {
1208
- x: focusTarget.x,
1209
- y: height,
1210
- z: focusTarget.z + distance,
1211
- targetX: focusTarget.x,
1212
- targetY: 0,
1213
- targetZ: focusTarget.z,
1214
- };
1215
- }
1462
+ // Calculate initial 2D position (component always starts in 2D mode)
1463
+ // We need aspect ratio from the camera, but we'll use a default until Frame 1
1464
+ const getInitial2DPosition = useCallback(() => {
1465
+ const perspCam = camera as THREE.PerspectiveCamera;
1466
+ const aspect = perspCam.aspect || 1;
1467
+ const height = calculateFlatCameraHeight(aspect);
1216
1468
 
1217
- // Grown state without focus: angled overview
1218
- const baseHeight = citySize * 1.1;
1219
- const buildingAwareHeight = maxBuildingHeight > 0
1220
- ? Math.max(baseHeight, maxBuildingHeight * 2.5)
1221
- : baseHeight;
1222
1469
  return {
1223
1470
  x: 0,
1224
- y: buildingAwareHeight,
1225
- z: citySize * 1.3,
1471
+ y: height,
1472
+ z: 0.001, // Near-zero for top-down (tiny offset to avoid gimbal lock)
1226
1473
  targetX: 0,
1227
1474
  targetY: 0,
1228
1475
  targetZ: 0,
1229
1476
  };
1230
- }, [focusTarget, citySize, isFlat, maxBuildingHeight, calculateFlatCameraHeight]);
1231
-
1232
- // Freeze initial camera position on Frame 1 (not during render)
1233
- // This ensures we capture the correct calculation after canvas is initialized
1234
- const initialPosRef = useRef<typeof targetPos | null>(null);
1477
+ }, [camera, calculateFlatCameraHeight]);
1235
1478
 
1236
1479
  // Spring animation for camera movement
1237
- // Initialize spring with neutral values - will be set properly in Frame 3
1480
+ // Initialize with correct 2D position from the start
1238
1481
  const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => {
1239
- console.log('[Spring init] Initializing with neutral values');
1482
+ // Calculate initial position with default aspect ratio
1483
+ // This will be corrected in Frame 1 if aspect is different
1484
+ const initialHeight = calculateFlatCameraHeight(1);
1485
+
1486
+ console.log('[Spring init] Initializing with 2D position, height:', initialHeight);
1240
1487
  return {
1241
1488
  camX: 0,
1242
- camY: 0,
1243
- camZ: 0,
1489
+ camY: initialHeight,
1490
+ camZ: 0.001,
1244
1491
  lookX: 0,
1245
1492
  lookY: 0,
1246
1493
  lookZ: 0,
1247
1494
  config: { tension: 60, friction: 20 },
1248
1495
  onStart: () => {
1249
- // Block animations during initial sync in Frame 3
1250
- if (isSyncingInitial.current) {
1251
- console.log('[Spring onStart] Blocked - syncing initial position');
1252
- return;
1253
- }
1254
1496
  // Only allow animations after initial setup is complete
1255
1497
  if (hasAppliedInitial.current) {
1256
1498
  console.log('[Spring onStart] Animation starting - camY:', camY.get());
@@ -1308,42 +1550,31 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
1308
1550
  azimuthAngle: number; // horizontal angle to maintain
1309
1551
  } | null>(null);
1310
1552
 
1311
- // When isFlat changes after initial setup, animate to new position
1312
- // We track isFlat explicitly rather than targetPos to avoid spurious animations
1313
- // from aspect ratio changes or other recalculations
1553
+ // When isFlat changes from true to false, animate to 3D view
1554
+ // Component always starts in 2D, so we only animate the 2D→3D transition
1314
1555
  useEffect(() => {
1315
- console.log('[useEffect] Called - hasAppliedInitial:', hasAppliedInitial.current, 'isFlat:', isFlat, 'prevIsFlat:', prevIsFlatRef.current);
1316
- console.log('[useEffect] Dependency check - focusTarget:', focusTarget, 'citySize:', citySize, 'maxBuildingHeight:', maxBuildingHeight);
1556
+ console.log('[useEffect] isFlat:', isFlat, 'prevIsFlat:', prevIsFlatRef.current, 'hasAppliedInitial:', hasAppliedInitial.current);
1317
1557
 
1318
1558
  // Skip until camera is initialized
1319
- if (!hasAppliedInitial.current || !initialPosRef.current) {
1559
+ if (!hasAppliedInitial.current) {
1320
1560
  console.log('[useEffect] Skipping - not initialized yet');
1321
1561
  return;
1322
1562
  }
1323
1563
 
1324
- // Only animate if isFlat actually changed (flat <-> grown transition)
1564
+ // Only animate if isFlat changed from true to false (2D 3D transition)
1325
1565
  const isFlatChanged = prevIsFlatRef.current !== isFlat;
1326
1566
 
1327
1567
  if (!isFlatChanged) {
1328
- // isFlat didn't change, don't update anything
1329
- // Use initialPosRef to prevent updates from targetPos recalculations
1330
- console.log('[useEffect] No isFlat change - skipping (prevIsFlat:', prevIsFlatRef.current, 'isFlat:', isFlat, ')');
1568
+ console.log('[useEffect] No isFlat change - skipping');
1331
1569
  return;
1332
1570
  }
1333
1571
 
1334
1572
  console.log('[useEffect] isFlat changed from', prevIsFlatRef.current, 'to', isFlat, '- animating transition');
1335
1573
  prevIsFlatRef.current = isFlat;
1336
1574
 
1337
- // isFlat changed - recalculate target position and animate
1575
+ // Calculate target position for 3D view
1338
1576
  const newPos = isFlat
1339
- ? {
1340
- x: 0,
1341
- y: calculateFlatCameraHeight(),
1342
- z: 0.001,
1343
- targetX: 0,
1344
- targetY: 0,
1345
- targetZ: 0,
1346
- }
1577
+ ? getInitial2DPosition() // Going back to 2D
1347
1578
  : focusTarget
1348
1579
  ? {
1349
1580
  x: focusTarget.x,
@@ -1362,10 +1593,7 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
1362
1593
  targetZ: 0,
1363
1594
  };
1364
1595
 
1365
- // Update frozen position for future reference
1366
- initialPosRef.current = newPos;
1367
-
1368
- console.log('[useEffect] Starting animation to:', newPos);
1596
+ console.log('[useEffect] Animating to:', newPos);
1369
1597
  api.start({
1370
1598
  camX: newPos.x,
1371
1599
  camY: newPos.y,
@@ -1373,21 +1601,18 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
1373
1601
  lookX: newPos.targetX,
1374
1602
  lookY: newPos.targetY,
1375
1603
  lookZ: newPos.targetZ,
1376
- onRest: () => {
1377
- console.log('[useEffect animation] onRest called');
1378
- isAnimatingRef.current = false;
1379
- },
1380
1604
  });
1381
- }, [api, isFlat, focusTarget, citySize, maxBuildingHeight, calculateFlatCameraHeight]);
1605
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1606
+ }, [isFlat]); // Only animate when isFlat changes, not when focusTarget/citySize/etc change
1382
1607
 
1383
1608
  // Update camera each frame
1384
1609
  useFrame(() => {
1385
1610
  frameCount.current++;
1386
1611
 
1387
- // Capture and apply initial position on first frame
1388
- // This prevents the camera from starting at (0,0,0) and causing a flicker
1612
+ // On Frame 1: Set camera to initial 2D position and mark as ready
1613
+ // Component always starts in 2D mode, so we just need to set the correct position once
1389
1614
  if (frameCount.current === 1) {
1390
- // Ensure camera FOV is set correctly (it defaults to 75 before the prop applies)
1615
+ // Ensure camera FOV is correct (defaults to 75 before prop applies)
1391
1616
  const perspCam = camera as THREE.PerspectiveCamera;
1392
1617
  if (perspCam.fov !== 50) {
1393
1618
  console.log('[Frame 1] Correcting FOV from', perspCam.fov, 'to 50');
@@ -1395,99 +1620,41 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
1395
1620
  perspCam.updateProjectionMatrix();
1396
1621
  }
1397
1622
 
1398
- // Calculate position directly on Frame 1 when camera is properly initialized
1399
- // Don't rely on memoized targetPos which may have been calculated with stale camera info
1400
- if (!initialPosRef.current) {
1401
- console.log('[Frame 1] Calculating initial position, isFlat:', isFlat);
1402
- // Recalculate fresh to ensure we have correct camera aspect ratio and FOV
1403
- const freshPos = isFlat
1404
- ? {
1405
- x: 0,
1406
- y: calculateFlatCameraHeight(),
1407
- z: 0.001,
1408
- targetX: 0,
1409
- targetY: 0,
1410
- targetZ: 0,
1411
- }
1412
- : focusTarget
1413
- ? {
1414
- x: focusTarget.x,
1415
- y: Math.max(focusTarget.size * 1.5, 40),
1416
- z: focusTarget.z + Math.max(focusTarget.size * 2, 50),
1417
- targetX: focusTarget.x,
1418
- targetY: 0,
1419
- targetZ: focusTarget.z,
1420
- }
1421
- : {
1422
- x: 0,
1423
- y: maxBuildingHeight > 0 ? Math.max(citySize * 1.1, maxBuildingHeight * 2.5) : citySize * 1.1,
1424
- z: citySize * 1.3,
1425
- targetX: 0,
1426
- targetY: 0,
1427
- targetZ: 0,
1428
- };
1429
- console.log('[Frame 1] Calculated position:', freshPos);
1430
- initialPosRef.current = freshPos;
1623
+ // Calculate initial 2D position with correct aspect ratio
1624
+ const initialPos = getInitial2DPosition();
1625
+ console.log('[Frame 1] Setting camera to initial 2D position:', initialPos);
1626
+
1627
+ camera.position.set(initialPos.x, initialPos.y, initialPos.z);
1628
+
1629
+ // Wait for controls to be ready, then set target and sync spring
1630
+ if (controlsRef.current) {
1631
+ controlsRef.current.target.set(initialPos.targetX, initialPos.targetY, initialPos.targetZ);
1632
+ controlsRef.current.update();
1633
+
1634
+ // Sync spring to match camera position (use immediate to avoid animation)
1635
+ api.start({
1636
+ camX: initialPos.x,
1637
+ camY: initialPos.y,
1638
+ camZ: initialPos.z,
1639
+ lookX: initialPos.targetX,
1640
+ lookY: initialPos.targetY,
1641
+ lookZ: initialPos.targetZ,
1642
+ immediate: true,
1643
+ });
1644
+
1645
+ hasAppliedInitial.current = true;
1646
+
1647
+ // Notify parent that camera is ready
1648
+ if (!hasNotifiedReady.current && onCameraReady) {
1649
+ hasNotifiedReady.current = true;
1650
+ onCameraReady();
1651
+ }
1431
1652
  }
1432
- const pos = initialPosRef.current;
1433
- console.log('[Frame 1] Setting camera to:', pos);
1434
- camera.position.set(pos.x, pos.y, pos.z);
1435
1653
  return;
1436
1654
  }
1437
1655
 
1438
- // Skip first 2 frames to ensure OrbitControls is fully initialized
1439
- if (frameCount.current < 3) return;
1440
- if (!controlsRef.current) return;
1441
-
1442
- // Set initial target and sync spring (after OrbitControls is ready)
1443
- // Use frozen initialPosRef to match Frame 1 position
1444
- if (!hasAppliedInitial.current && initialPosRef.current) {
1445
- const pos = initialPosRef.current;
1446
- camera.position.set(pos.x, pos.y, pos.z);
1447
- controlsRef.current.target.set(pos.targetX, pos.targetY, pos.targetZ);
1448
- controlsRef.current.update();
1449
-
1450
- // Sync spring to this position so future animations start from here
1451
- console.log('[Frame 3] Syncing spring to:', pos);
1452
-
1453
- // Set flag to prevent onStart from triggering
1454
- isSyncingInitial.current = true;
1455
-
1456
- // Stop any ongoing animations first
1457
- api.stop();
1458
-
1459
- // Use api.start with immediate: true to set both current AND target values
1460
- // This ensures the spring won't try to animate back to any previous target
1461
- api.start({
1462
- camX: pos.x,
1463
- camY: pos.y,
1464
- camZ: pos.z,
1465
- lookX: pos.targetX,
1466
- lookY: pos.targetY,
1467
- lookZ: pos.targetZ,
1468
- immediate: true,
1469
- });
1470
-
1471
- // Clear the syncing flag after a small delay to ensure spring is settled
1472
- setTimeout(() => {
1473
- isSyncingInitial.current = false;
1474
- console.log('[Frame 3] Sync complete - spring ready for animations');
1475
- }, 100);
1476
-
1477
- // Ensure animation flag is off
1478
- isAnimatingRef.current = false;
1479
-
1480
- console.log('[Frame 3] Spring values after sync:', { camY: camY.get() });
1481
-
1482
- hasAppliedInitial.current = true;
1483
-
1484
- // Notify parent that camera is ready (only once)
1485
- if (!hasNotifiedReady.current && onCameraReady) {
1486
- hasNotifiedReady.current = true;
1487
- onCameraReady();
1488
- }
1489
- return;
1490
- }
1656
+ // Wait for controls and initialization to complete
1657
+ if (!controlsRef.current || !hasAppliedInitial.current) return;
1491
1658
 
1492
1659
  // Handle orbit animation (horizontal rotation along arc)
1493
1660
  if (isOrbitingRef.current && orbitParamsRef.current) {
@@ -1846,6 +2013,14 @@ const AnimatedCamera = React.memo(function AnimatedCamera({
1846
2013
  />
1847
2014
  </>
1848
2015
  );
2016
+ }, (prevProps, nextProps) => {
2017
+ // Custom comparison: only re-render if isFlat, citySize, or maxBuildingHeight changes
2018
+ // Skip re-render when only focusTarget changes (handled internally by useEffect on isFlat)
2019
+ return (
2020
+ prevProps.isFlat === nextProps.isFlat &&
2021
+ prevProps.citySize === nextProps.citySize &&
2022
+ prevProps.maxBuildingHeight === nextProps.maxBuildingHeight
2023
+ );
1849
2024
  });
1850
2025
 
1851
2026
  // Info panel overlay
@@ -1997,7 +2172,7 @@ function CityScene({
1997
2172
  [cityData.bounds],
1998
2173
  );
1999
2174
 
2000
- const citySize = Math.max(
2175
+ const citySize = Math.min(
2001
2176
  cityData.bounds.maxX - cityData.bounds.minX,
2002
2177
  cityData.bounds.maxZ - cityData.bounds.minZ,
2003
2178
  );
@@ -2154,7 +2329,7 @@ function CityScene({
2154
2329
 
2155
2330
  const centerX = (minX + maxX) / 2;
2156
2331
  const centerZ = (minZ + maxZ) / 2;
2157
- const size = Math.max(maxX - minX, maxZ - minZ);
2332
+ const size = Math.min(maxX - minX, maxZ - minZ);
2158
2333
 
2159
2334
  return { x: centerX, z: centerZ, size };
2160
2335
  }
@@ -2199,11 +2374,6 @@ function CityScene({
2199
2374
  return cityData.buildings.findIndex(b => b.path === selectedBuilding.path);
2200
2375
  }, [selectedBuilding, cityData.buildings]);
2201
2376
 
2202
- // Calculate spring duration for animation sync
2203
- const tension = animationConfig.tension || 120;
2204
- const friction = animationConfig.friction || 14;
2205
- const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
2206
-
2207
2377
  return (
2208
2378
  <>
2209
2379
  <AnimatedCamera