@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.
@@ -155,21 +155,48 @@ function getColorForFile(building) {
155
155
  return getConfigForFile(building).color;
156
156
  }
157
157
  /**
158
- * Check if a path is highlighted by any enabled layer.
158
+ * Get ALL layer matches for a path, sorted by priority (highest first).
159
+ * Returns array to support multiple layers rendering together (e.g., fill + border).
159
160
  */
160
- function getHighlightForPath(path, layers) {
161
+ function getLayerMatchesForPath(path, layers) {
162
+ const matches = [];
161
163
  for (const layer of layers) {
162
164
  if (!layer.enabled)
163
165
  continue;
164
166
  for (const item of layer.items) {
167
+ let isMatch = false;
165
168
  if (item.type === 'file' && item.path === path) {
166
- return { color: layer.color, opacity: layer.opacity ?? 1 };
169
+ isMatch = true;
170
+ }
171
+ else if (item.type === 'directory' && (path === item.path || path.startsWith(item.path + '/'))) {
172
+ isMatch = true;
167
173
  }
168
- if (item.type === 'directory' && (path === item.path || path.startsWith(item.path + '/'))) {
169
- return { color: layer.color, opacity: layer.opacity ?? 1 };
174
+ if (isMatch) {
175
+ matches.push({
176
+ layer,
177
+ item,
178
+ color: layer.color,
179
+ opacity: layer.opacity ?? 1,
180
+ borderWidth: layer.borderWidth,
181
+ renderStrategy: item.renderStrategy || 'border', // Default from 2D renderer
182
+ });
170
183
  }
171
184
  }
172
185
  }
186
+ // Sort by priority (highest first)
187
+ return matches.sort((a, b) => (b.layer.priority ?? 0) - (a.layer.priority ?? 0));
188
+ }
189
+ /**
190
+ * Get the highest-priority fill color for a path (backward compatibility).
191
+ * Returns the first matching layer with 'fill' strategy.
192
+ */
193
+ function getHighlightForPath(path, layers) {
194
+ const matches = getLayerMatchesForPath(path, layers);
195
+ // Find first fill match
196
+ const fillMatch = matches.find(m => m.renderStrategy === 'fill');
197
+ if (fillMatch) {
198
+ return { color: fillMatch.color, opacity: fillMatch.opacity };
199
+ }
173
200
  return null;
174
201
  }
175
202
  function hasActiveHighlights(layers) {
@@ -233,6 +260,177 @@ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springD
233
260
  return null;
234
261
  return (_jsxs("instancedMesh", { ref: meshRef, args: [undefined, undefined, numEdges], frustumCulled: false, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx("meshBasicMaterial", { color: "#1a1a2e", transparent: true, opacity: 0.7 })] }));
235
262
  }
263
+ function BorderHighlights({ buildings, centerOffset, highlightLayers, growProgress, minHeight, baseOffset, springDuration, heightMultipliersRef, heightScaling, linearScale, flatPatterns, staggerIndices, animationConfig, }) {
264
+ const meshRef = useRef(null);
265
+ const startTimeRef = useRef(null);
266
+ const tempObject = useMemo(() => new THREE.Object3D(), []);
267
+ const tempColor = useMemo(() => new THREE.Color(), []);
268
+ // Pre-compute border edge data from buildings with border highlights
269
+ const borderEdgeData = useMemo(() => {
270
+ const edges = [];
271
+ buildings.forEach((building, buildingIndex) => {
272
+ const matches = getLayerMatchesForPath(building.path, highlightLayers);
273
+ // Find border matches
274
+ const borderMatches = matches.filter(m => m.renderStrategy === 'border');
275
+ if (borderMatches.length === 0)
276
+ return;
277
+ // Use highest priority border match
278
+ const borderMatch = borderMatches[0];
279
+ const [width, , depth] = building.dimensions;
280
+ const fullHeight = calculateBuildingHeight(building, heightScaling, linearScale, flatPatterns);
281
+ const x = building.position.x - centerOffset.x;
282
+ const z = building.position.z - centerOffset.z;
283
+ const staggerIndex = staggerIndices[buildingIndex] ?? buildingIndex;
284
+ const staggerDelayMs = (animationConfig.staggerDelay || 15) * staggerIndex;
285
+ const halfW = width / 2;
286
+ const halfD = depth / 2;
287
+ // Create 4 vertical corner edges
288
+ const corners = [
289
+ { x: x - halfW, z: z - halfD },
290
+ { x: x + halfW, z: z - halfD },
291
+ { x: x - halfW, z: z + halfD },
292
+ { x: x + halfW, z: z + halfD },
293
+ ];
294
+ corners.forEach(corner => {
295
+ edges.push({
296
+ x: corner.x,
297
+ z: corner.z,
298
+ fullHeight,
299
+ buildingIndex,
300
+ staggerDelayMs,
301
+ color: borderMatch.color,
302
+ opacity: borderMatch.opacity,
303
+ borderWidth: borderMatch.borderWidth ?? 2,
304
+ edgeType: 'vertical',
305
+ });
306
+ });
307
+ // Create 4 horizontal edges on top (roof outline)
308
+ // Two edges along X axis (front and back)
309
+ edges.push({
310
+ x: x,
311
+ z: z - halfD,
312
+ fullHeight,
313
+ buildingIndex,
314
+ staggerDelayMs,
315
+ color: borderMatch.color,
316
+ opacity: borderMatch.opacity,
317
+ borderWidth: borderMatch.borderWidth ?? 2,
318
+ edgeType: 'horizontal-x',
319
+ width,
320
+ });
321
+ edges.push({
322
+ x: x,
323
+ z: z + halfD,
324
+ fullHeight,
325
+ buildingIndex,
326
+ staggerDelayMs,
327
+ color: borderMatch.color,
328
+ opacity: borderMatch.opacity,
329
+ borderWidth: borderMatch.borderWidth ?? 2,
330
+ edgeType: 'horizontal-x',
331
+ width,
332
+ });
333
+ // Two edges along Z axis (left and right)
334
+ edges.push({
335
+ x: x - halfW,
336
+ z: z,
337
+ fullHeight,
338
+ buildingIndex,
339
+ staggerDelayMs,
340
+ color: borderMatch.color,
341
+ opacity: borderMatch.opacity,
342
+ borderWidth: borderMatch.borderWidth ?? 2,
343
+ edgeType: 'horizontal-z',
344
+ depth,
345
+ });
346
+ edges.push({
347
+ x: x + halfW,
348
+ z: z,
349
+ fullHeight,
350
+ buildingIndex,
351
+ staggerDelayMs,
352
+ color: borderMatch.color,
353
+ opacity: borderMatch.opacity,
354
+ borderWidth: borderMatch.borderWidth ?? 2,
355
+ edgeType: 'horizontal-z',
356
+ depth,
357
+ });
358
+ });
359
+ return edges;
360
+ }, [
361
+ buildings,
362
+ centerOffset,
363
+ highlightLayers,
364
+ heightScaling,
365
+ linearScale,
366
+ flatPatterns,
367
+ staggerIndices,
368
+ animationConfig.staggerDelay,
369
+ ]);
370
+ // Animate border edges
371
+ useFrame(({ clock }) => {
372
+ if (!meshRef.current || borderEdgeData.length === 0)
373
+ return;
374
+ if (startTimeRef.current === null && growProgress > 0) {
375
+ startTimeRef.current = clock.elapsedTime * 1000;
376
+ }
377
+ const currentTime = clock.elapsedTime * 1000;
378
+ const animStartTime = startTimeRef.current ?? currentTime;
379
+ borderEdgeData.forEach((edge, idx) => {
380
+ const { x, z, fullHeight, staggerDelayMs, buildingIndex, color, opacity, borderWidth, edgeType, width, depth } = edge;
381
+ // Get height multiplier from shared ref (for collapse animation)
382
+ const heightMultiplier = heightMultipliersRef.current?.[buildingIndex] ?? 1;
383
+ // Calculate per-building animation progress
384
+ const elapsed = currentTime - animStartTime - staggerDelayMs;
385
+ let animProgress = growProgress;
386
+ if (growProgress > 0 && elapsed >= 0) {
387
+ const t = Math.min(elapsed / springDuration, 1);
388
+ const eased = 1 - Math.pow(1 - t, 3);
389
+ animProgress = eased * growProgress;
390
+ }
391
+ else if (growProgress > 0 && elapsed < 0) {
392
+ animProgress = 0;
393
+ }
394
+ // Apply both grow animation and collapse multiplier
395
+ const height = animProgress * fullHeight * heightMultiplier + minHeight;
396
+ // Fixed thickness based on borderWidth (don't scale with building size)
397
+ const thickness = Math.max(0.2, borderWidth * 0.1); // Convert pixels to world units
398
+ if (edgeType === 'vertical') {
399
+ // Vertical corner edges
400
+ const yPosition = height / 2 + baseOffset;
401
+ tempObject.position.set(x, yPosition, z);
402
+ tempObject.rotation.set(0, 0, 0);
403
+ tempObject.scale.set(thickness, height, thickness);
404
+ }
405
+ else if (edgeType === 'horizontal-x') {
406
+ // Horizontal edges along X axis (front/back of roof)
407
+ const yPosition = height + baseOffset;
408
+ tempObject.position.set(x, yPosition, z);
409
+ tempObject.rotation.set(0, 0, Math.PI / 2); // Rotate to horizontal along X
410
+ tempObject.scale.set(thickness, width, thickness);
411
+ }
412
+ else if (edgeType === 'horizontal-z') {
413
+ // Horizontal edges along Z axis (left/right of roof)
414
+ const yPosition = height + baseOffset;
415
+ tempObject.position.set(x, yPosition, z);
416
+ tempObject.rotation.set(Math.PI / 2, 0, 0); // Rotate to horizontal along Z
417
+ tempObject.scale.set(thickness, depth, thickness);
418
+ }
419
+ tempObject.updateMatrix();
420
+ meshRef.current.setMatrixAt(idx, tempObject.matrix);
421
+ // Set per-instance color with opacity
422
+ tempColor.set(color);
423
+ meshRef.current.setColorAt(idx, tempColor);
424
+ });
425
+ meshRef.current.instanceMatrix.needsUpdate = true;
426
+ if (meshRef.current.instanceColor) {
427
+ meshRef.current.instanceColor.needsUpdate = true;
428
+ }
429
+ });
430
+ if (borderEdgeData.length === 0)
431
+ return null;
432
+ return (_jsxs("instancedMesh", { ref: meshRef, args: [undefined, undefined, borderEdgeData.length], frustumCulled: false, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx("meshBasicMaterial", { transparent: true, opacity: 0.9, vertexColors: true })] }));
433
+ }
236
434
  // Helper to check if a path is inside a directory
237
435
  function isPathInDirectory(path, directory) {
238
436
  if (!directory)
@@ -313,9 +511,10 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
313
511
  return buildings.map((building, index) => {
314
512
  const [width, , depth] = building.dimensions;
315
513
  const fullHeight = calculateBuildingHeight(building, heightScaling, linearScale, flatPatterns);
316
- // Use highlight layer color if available, otherwise fall back to file config color
317
- const highlight = getHighlightForPath(building.path, highlightLayers);
318
- const color = highlight?.color ?? getColorForFile(building);
514
+ // Get all layer matches and find first fill match for building color
515
+ const matches = getLayerMatchesForPath(building.path, highlightLayers);
516
+ const fillMatch = matches.find(m => m.renderStrategy === 'fill');
517
+ const color = fillMatch?.color ?? getColorForFile(building);
319
518
  const x = building.position.x - centerOffset.x;
320
519
  const z = building.position.z - centerOffset.z;
321
520
  const staggerIndex = staggerIndices[index] ?? index;
@@ -470,7 +669,7 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
470
669
  z: d.z,
471
670
  staggerDelayMs: d.staggerDelayMs,
472
671
  buildingIndex: d.index,
473
- })), growProgress: growProgress, minHeight: minHeight, baseOffset: baseOffset, springDuration: springDuration, heightMultipliersRef: heightMultipliersRef })] }));
672
+ })), growProgress: growProgress, minHeight: minHeight, baseOffset: baseOffset, springDuration: springDuration, heightMultipliersRef: heightMultipliersRef }), _jsx(BorderHighlights, { buildings: buildings, centerOffset: centerOffset, highlightLayers: highlightLayers, growProgress: growProgress, minHeight: minHeight, baseOffset: baseOffset, springDuration: springDuration, heightMultipliersRef: heightMultipliersRef, heightScaling: heightScaling, linearScale: linearScale, flatPatterns: flatPatterns, staggerIndices: staggerIndices, animationConfig: animationConfig })] }));
474
673
  }
475
674
  function AnimatedIcon({ x, z, targetHeight, iconSize, texture, opacity, growProgress, }) {
476
675
  const meshRef = useRef(null);
@@ -666,95 +865,51 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
666
865
  const frameCount = useRef(0);
667
866
  const hasNotifiedReady = useRef(false);
668
867
  const prevIsFlatRef = useRef(isFlat); // Track previous isFlat to detect actual state changes
669
- const isSyncingInitial = useRef(false); // Flag to prevent onStart from triggering during Frame 3 sync
670
- // Calculate camera height to fit city in viewport (for top-down view)
868
+ // Helper to calculate flat camera height with known FOV (50) and aspect ratio
671
869
  // Formula: height = citySize / (2 * tan(fov/2) * min(1, aspect))
672
870
  // Padding factor adds space around the city to match 2D component
673
- const calculateFlatCameraHeight = useCallback(() => {
674
- const perspCam = camera;
675
- const fovRad = (perspCam.fov * Math.PI) / 180;
871
+ const calculateFlatCameraHeight = useCallback((aspect) => {
872
+ const fov = 50; // Known FOV that will be set on PerspectiveCamera
873
+ const fovRad = (fov * Math.PI) / 180;
676
874
  const tanHalfFov = Math.tan(fovRad / 2);
677
- const aspect = perspCam.aspect || 1;
678
875
  // Use min(1, aspect) to handle both landscape and portrait viewports
679
876
  const effectiveAspect = Math.min(1, aspect);
680
877
  const baseHeight = citySize / (2 * tanHalfFov * effectiveAspect);
681
878
  // Add padding to match 2D component's default padding
682
879
  const paddingFactor = 1.08;
683
- const result = baseHeight * paddingFactor;
684
- console.log('[calculateFlatCameraHeight]', {
685
- fov: perspCam.fov,
686
- aspect,
687
- effectiveAspect,
688
- citySize,
689
- baseHeight,
690
- result,
691
- });
692
- return result;
693
- }, [camera, citySize]);
694
- // Compute target camera position
695
- // When flat, always use top-down view (ignore focusTarget)
696
- // When grown, use focusTarget if available, otherwise angled overview
697
- const targetPos = useMemo(() => {
698
- // Flat state: always top-down, ignore any focus
699
- // Height calculated mathematically to match 2D view zoom level
700
- if (isFlat) {
701
- return {
702
- x: 0,
703
- y: calculateFlatCameraHeight(),
704
- z: 0.001, // Near-zero for top-down (tiny offset to avoid gimbal lock)
705
- targetX: 0,
706
- targetY: 0,
707
- targetZ: 0,
708
- };
709
- }
710
- // Grown state: use focusTarget if available
711
- if (focusTarget) {
712
- const distance = Math.max(focusTarget.size * 2, 50);
713
- const height = Math.max(focusTarget.size * 1.5, 40);
714
- return {
715
- x: focusTarget.x,
716
- y: height,
717
- z: focusTarget.z + distance,
718
- targetX: focusTarget.x,
719
- targetY: 0,
720
- targetZ: focusTarget.z,
721
- };
722
- }
723
- // Grown state without focus: angled overview
724
- const baseHeight = citySize * 1.1;
725
- const buildingAwareHeight = maxBuildingHeight > 0
726
- ? Math.max(baseHeight, maxBuildingHeight * 2.5)
727
- : baseHeight;
880
+ return baseHeight * paddingFactor;
881
+ }, [citySize]);
882
+ // Calculate initial 2D position (component always starts in 2D mode)
883
+ // We need aspect ratio from the camera, but we'll use a default until Frame 1
884
+ const getInitial2DPosition = useCallback(() => {
885
+ const perspCam = camera;
886
+ const aspect = perspCam.aspect || 1;
887
+ const height = calculateFlatCameraHeight(aspect);
728
888
  return {
729
889
  x: 0,
730
- y: buildingAwareHeight,
731
- z: citySize * 1.3,
890
+ y: height,
891
+ z: 0.001, // Near-zero for top-down (tiny offset to avoid gimbal lock)
732
892
  targetX: 0,
733
893
  targetY: 0,
734
894
  targetZ: 0,
735
895
  };
736
- }, [focusTarget, citySize, isFlat, maxBuildingHeight, calculateFlatCameraHeight]);
737
- // Freeze initial camera position on Frame 1 (not during render)
738
- // This ensures we capture the correct calculation after canvas is initialized
739
- const initialPosRef = useRef(null);
896
+ }, [camera, calculateFlatCameraHeight]);
740
897
  // Spring animation for camera movement
741
- // Initialize spring with neutral values - will be set properly in Frame 3
898
+ // Initialize with correct 2D position from the start
742
899
  const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => {
743
- console.log('[Spring init] Initializing with neutral values');
900
+ // Calculate initial position with default aspect ratio
901
+ // This will be corrected in Frame 1 if aspect is different
902
+ const initialHeight = calculateFlatCameraHeight(1);
903
+ console.log('[Spring init] Initializing with 2D position, height:', initialHeight);
744
904
  return {
745
905
  camX: 0,
746
- camY: 0,
747
- camZ: 0,
906
+ camY: initialHeight,
907
+ camZ: 0.001,
748
908
  lookX: 0,
749
909
  lookY: 0,
750
910
  lookZ: 0,
751
911
  config: { tension: 60, friction: 20 },
752
912
  onStart: () => {
753
- // Block animations during initial sync in Frame 3
754
- if (isSyncingInitial.current) {
755
- console.log('[Spring onStart] Blocked - syncing initial position');
756
- return;
757
- }
758
913
  // Only allow animations after initial setup is complete
759
914
  if (hasAppliedInitial.current) {
760
915
  console.log('[Spring onStart] Animation starting - camY:', camY.get());
@@ -797,37 +952,26 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
797
952
  const orbitParamsRef = useRef(null);
798
953
  // Track tilt parameters during vertical rotation
799
954
  const tiltParamsRef = useRef(null);
800
- // When isFlat changes after initial setup, animate to new position
801
- // We track isFlat explicitly rather than targetPos to avoid spurious animations
802
- // from aspect ratio changes or other recalculations
955
+ // When isFlat changes from true to false, animate to 3D view
956
+ // Component always starts in 2D, so we only animate the 2D→3D transition
803
957
  useEffect(() => {
804
- console.log('[useEffect] Called - hasAppliedInitial:', hasAppliedInitial.current, 'isFlat:', isFlat, 'prevIsFlat:', prevIsFlatRef.current);
805
- console.log('[useEffect] Dependency check - focusTarget:', focusTarget, 'citySize:', citySize, 'maxBuildingHeight:', maxBuildingHeight);
958
+ console.log('[useEffect] isFlat:', isFlat, 'prevIsFlat:', prevIsFlatRef.current, 'hasAppliedInitial:', hasAppliedInitial.current);
806
959
  // Skip until camera is initialized
807
- if (!hasAppliedInitial.current || !initialPosRef.current) {
960
+ if (!hasAppliedInitial.current) {
808
961
  console.log('[useEffect] Skipping - not initialized yet');
809
962
  return;
810
963
  }
811
- // Only animate if isFlat actually changed (flat <-> grown transition)
964
+ // Only animate if isFlat changed from true to false (2D 3D transition)
812
965
  const isFlatChanged = prevIsFlatRef.current !== isFlat;
813
966
  if (!isFlatChanged) {
814
- // isFlat didn't change, don't update anything
815
- // Use initialPosRef to prevent updates from targetPos recalculations
816
- console.log('[useEffect] No isFlat change - skipping (prevIsFlat:', prevIsFlatRef.current, 'isFlat:', isFlat, ')');
967
+ console.log('[useEffect] No isFlat change - skipping');
817
968
  return;
818
969
  }
819
970
  console.log('[useEffect] isFlat changed from', prevIsFlatRef.current, 'to', isFlat, '- animating transition');
820
971
  prevIsFlatRef.current = isFlat;
821
- // isFlat changed - recalculate target position and animate
972
+ // Calculate target position for 3D view
822
973
  const newPos = isFlat
823
- ? {
824
- x: 0,
825
- y: calculateFlatCameraHeight(),
826
- z: 0.001,
827
- targetX: 0,
828
- targetY: 0,
829
- targetZ: 0,
830
- }
974
+ ? getInitial2DPosition() // Going back to 2D
831
975
  : focusTarget
832
976
  ? {
833
977
  x: focusTarget.x,
@@ -845,9 +989,7 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
845
989
  targetY: 0,
846
990
  targetZ: 0,
847
991
  };
848
- // Update frozen position for future reference
849
- initialPosRef.current = newPos;
850
- console.log('[useEffect] Starting animation to:', newPos);
992
+ console.log('[useEffect] Animating to:', newPos);
851
993
  api.start({
852
994
  camX: newPos.x,
853
995
  camY: newPos.y,
@@ -855,109 +997,52 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
855
997
  lookX: newPos.targetX,
856
998
  lookY: newPos.targetY,
857
999
  lookZ: newPos.targetZ,
858
- onRest: () => {
859
- console.log('[useEffect animation] onRest called');
860
- isAnimatingRef.current = false;
861
- },
862
1000
  });
863
- }, [api, isFlat, focusTarget, citySize, maxBuildingHeight, calculateFlatCameraHeight]);
1001
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1002
+ }, [isFlat]); // Only animate when isFlat changes, not when focusTarget/citySize/etc change
864
1003
  // Update camera each frame
865
1004
  useFrame(() => {
866
1005
  frameCount.current++;
867
- // Capture and apply initial position on first frame
868
- // This prevents the camera from starting at (0,0,0) and causing a flicker
1006
+ // On Frame 1: Set camera to initial 2D position and mark as ready
1007
+ // Component always starts in 2D mode, so we just need to set the correct position once
869
1008
  if (frameCount.current === 1) {
870
- // Ensure camera FOV is set correctly (it defaults to 75 before the prop applies)
1009
+ // Ensure camera FOV is correct (defaults to 75 before prop applies)
871
1010
  const perspCam = camera;
872
1011
  if (perspCam.fov !== 50) {
873
1012
  console.log('[Frame 1] Correcting FOV from', perspCam.fov, 'to 50');
874
1013
  perspCam.fov = 50;
875
1014
  perspCam.updateProjectionMatrix();
876
1015
  }
877
- // Calculate position directly on Frame 1 when camera is properly initialized
878
- // Don't rely on memoized targetPos which may have been calculated with stale camera info
879
- if (!initialPosRef.current) {
880
- console.log('[Frame 1] Calculating initial position, isFlat:', isFlat);
881
- // Recalculate fresh to ensure we have correct camera aspect ratio and FOV
882
- const freshPos = isFlat
883
- ? {
884
- x: 0,
885
- y: calculateFlatCameraHeight(),
886
- z: 0.001,
887
- targetX: 0,
888
- targetY: 0,
889
- targetZ: 0,
890
- }
891
- : focusTarget
892
- ? {
893
- x: focusTarget.x,
894
- y: Math.max(focusTarget.size * 1.5, 40),
895
- z: focusTarget.z + Math.max(focusTarget.size * 2, 50),
896
- targetX: focusTarget.x,
897
- targetY: 0,
898
- targetZ: focusTarget.z,
899
- }
900
- : {
901
- x: 0,
902
- y: maxBuildingHeight > 0 ? Math.max(citySize * 1.1, maxBuildingHeight * 2.5) : citySize * 1.1,
903
- z: citySize * 1.3,
904
- targetX: 0,
905
- targetY: 0,
906
- targetZ: 0,
907
- };
908
- console.log('[Frame 1] Calculated position:', freshPos);
909
- initialPosRef.current = freshPos;
1016
+ // Calculate initial 2D position with correct aspect ratio
1017
+ const initialPos = getInitial2DPosition();
1018
+ console.log('[Frame 1] Setting camera to initial 2D position:', initialPos);
1019
+ camera.position.set(initialPos.x, initialPos.y, initialPos.z);
1020
+ // Wait for controls to be ready, then set target and sync spring
1021
+ if (controlsRef.current) {
1022
+ controlsRef.current.target.set(initialPos.targetX, initialPos.targetY, initialPos.targetZ);
1023
+ controlsRef.current.update();
1024
+ // Sync spring to match camera position (use immediate to avoid animation)
1025
+ api.start({
1026
+ camX: initialPos.x,
1027
+ camY: initialPos.y,
1028
+ camZ: initialPos.z,
1029
+ lookX: initialPos.targetX,
1030
+ lookY: initialPos.targetY,
1031
+ lookZ: initialPos.targetZ,
1032
+ immediate: true,
1033
+ });
1034
+ hasAppliedInitial.current = true;
1035
+ // Notify parent that camera is ready
1036
+ if (!hasNotifiedReady.current && onCameraReady) {
1037
+ hasNotifiedReady.current = true;
1038
+ onCameraReady();
1039
+ }
910
1040
  }
911
- const pos = initialPosRef.current;
912
- console.log('[Frame 1] Setting camera to:', pos);
913
- camera.position.set(pos.x, pos.y, pos.z);
914
1041
  return;
915
1042
  }
916
- // Skip first 2 frames to ensure OrbitControls is fully initialized
917
- if (frameCount.current < 3)
918
- return;
919
- if (!controlsRef.current)
920
- return;
921
- // Set initial target and sync spring (after OrbitControls is ready)
922
- // Use frozen initialPosRef to match Frame 1 position
923
- if (!hasAppliedInitial.current && initialPosRef.current) {
924
- const pos = initialPosRef.current;
925
- camera.position.set(pos.x, pos.y, pos.z);
926
- controlsRef.current.target.set(pos.targetX, pos.targetY, pos.targetZ);
927
- controlsRef.current.update();
928
- // Sync spring to this position so future animations start from here
929
- console.log('[Frame 3] Syncing spring to:', pos);
930
- // Set flag to prevent onStart from triggering
931
- isSyncingInitial.current = true;
932
- // Stop any ongoing animations first
933
- api.stop();
934
- // Use api.start with immediate: true to set both current AND target values
935
- // This ensures the spring won't try to animate back to any previous target
936
- api.start({
937
- camX: pos.x,
938
- camY: pos.y,
939
- camZ: pos.z,
940
- lookX: pos.targetX,
941
- lookY: pos.targetY,
942
- lookZ: pos.targetZ,
943
- immediate: true,
944
- });
945
- // Clear the syncing flag after a small delay to ensure spring is settled
946
- setTimeout(() => {
947
- isSyncingInitial.current = false;
948
- console.log('[Frame 3] Sync complete - spring ready for animations');
949
- }, 100);
950
- // Ensure animation flag is off
951
- isAnimatingRef.current = false;
952
- console.log('[Frame 3] Spring values after sync:', { camY: camY.get() });
953
- hasAppliedInitial.current = true;
954
- // Notify parent that camera is ready (only once)
955
- if (!hasNotifiedReady.current && onCameraReady) {
956
- hasNotifiedReady.current = true;
957
- onCameraReady();
958
- }
1043
+ // Wait for controls and initialization to complete
1044
+ if (!controlsRef.current || !hasAppliedInitial.current)
959
1045
  return;
960
- }
961
1046
  // Handle orbit animation (horizontal rotation along arc)
962
1047
  if (isOrbitingRef.current && orbitParamsRef.current) {
963
1048
  const { centerX, centerZ, distance, height } = orbitParamsRef.current;
@@ -1247,6 +1332,12 @@ const AnimatedCamera = React.memo(function AnimatedCamera({ citySize, isFlat, fo
1247
1332
  };
1248
1333
  }, [resetToInitial, moveTo, setTarget, rotateTo, rotateBy, tiltTo, tiltBy, getCurrentPosition, getCurrentTarget, getCurrentAngle, getCurrentTilt]);
1249
1334
  return (_jsxs(_Fragment, { children: [_jsx(PerspectiveCamera, { makeDefault: true, fov: 50, near: 1, far: citySize * 10 }), _jsx(OrbitControls, { ref: controlsRef, enableDamping: true, dampingFactor: 0.05, minDistance: 10, maxDistance: citySize * 3, maxPolarAngle: Math.PI / 2.1 })] }));
1335
+ }, (prevProps, nextProps) => {
1336
+ // Custom comparison: only re-render if isFlat, citySize, or maxBuildingHeight changes
1337
+ // Skip re-render when only focusTarget changes (handled internally by useEffect on isFlat)
1338
+ return (prevProps.isFlat === nextProps.isFlat &&
1339
+ prevProps.citySize === nextProps.citySize &&
1340
+ prevProps.maxBuildingHeight === nextProps.maxBuildingHeight);
1250
1341
  });
1251
1342
  function InfoPanel({ building }) {
1252
1343
  if (!building)
@@ -1307,7 +1398,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
1307
1398
  x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
1308
1399
  z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
1309
1400
  }), [cityData.bounds]);
1310
- const citySize = Math.max(cityData.bounds.maxX - cityData.bounds.minX, cityData.bounds.maxZ - cityData.bounds.minZ);
1401
+ const citySize = Math.min(cityData.bounds.maxX - cityData.bounds.minX, cityData.bounds.maxZ - cityData.bounds.minZ);
1311
1402
  // Calculate max building height for camera positioning (when adaptCameraToBuildings is true)
1312
1403
  const maxBuildingHeight = useMemo(() => {
1313
1404
  if (!adaptCameraToBuildings)
@@ -1431,7 +1522,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
1431
1522
  }
1432
1523
  const centerX = (minX + maxX) / 2;
1433
1524
  const centerZ = (minZ + maxZ) / 2;
1434
- const size = Math.max(maxX - minX, maxZ - minZ);
1525
+ const size = Math.min(maxX - minX, maxZ - minZ);
1435
1526
  return { x: centerX, z: centerZ, size };
1436
1527
  }
1437
1528
  // No auto-focus on highlights - camera only moves with explicit focusDirectory
@@ -1466,10 +1557,6 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
1466
1557
  return null;
1467
1558
  return cityData.buildings.findIndex(b => b.path === selectedBuilding.path);
1468
1559
  }, [selectedBuilding, cityData.buildings]);
1469
- // Calculate spring duration for animation sync
1470
- const tension = animationConfig.tension || 120;
1471
- const friction = animationConfig.friction || 14;
1472
- const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
1473
1560
  return (_jsxs(_Fragment, { children: [_jsx(AnimatedCamera, { citySize: citySize, isFlat: growProgress === 0, focusTarget: focusTarget, maxBuildingHeight: maxBuildingHeight, onCameraReady: onCameraReady }), _jsx("ambientLight", { intensity: 1.2 }), _jsx("hemisphereLight", { args: ['#ddeeff', '#667788', 0.8], position: [0, citySize, 0] }), _jsx("directionalLight", { position: [citySize, citySize * 1.5, citySize * 0.5], intensity: 2, castShadow: true, "shadow-mapSize": [2048, 2048] }), _jsx("directionalLight", { position: [-citySize * 0.5, citySize * 0.8, -citySize * 0.5], intensity: 1 }), _jsx("directionalLight", { position: [citySize * 0.3, citySize, citySize], intensity: 0.6 }), cityData.districts.map(district => {
1474
1561
  // Check if district matches focusDirectory
1475
1562
  const isFocused = buildingFocusDirectory
@@ -2,5 +2,5 @@
2
2
  * FileCity3D - 3D visualization component
3
3
  */
4
4
  export { FileCity3D, resetCamera, getCameraAngle, getCameraTarget, getCameraTilt, rotateCameraTo, rotateCameraBy, tiltCameraTo, tiltCameraBy, moveCameraTo, setCameraTarget, DEFAULT_FLAT_PATTERNS, } from './FileCity3D';
5
- export type { FileCity3DProps, AnimationConfig, HighlightLayer, HighlightItem, IsolationMode, HeightScaling, FlatPattern, CityData, CityBuilding, CityDistrict, } from './FileCity3D';
5
+ export type { FileCity3DProps, AnimationConfig, HighlightLayer, LayerItem, LayerRenderStrategy, IsolationMode, HeightScaling, FlatPattern, CityData, CityBuilding, CityDistrict, } from './FileCity3D';
6
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/FileCity3D/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,UAAU,EACV,WAAW,EACX,cAAc,EACd,eAAe,EACf,aAAa,EACb,cAAc,EACd,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,qBAAqB,GACtB,MAAM,cAAc,CAAC;AACtB,YAAY,EACV,eAAe,EACf,eAAe,EACf,cAAc,EACd,aAAa,EACb,aAAa,EACb,aAAa,EACb,WAAW,EACX,QAAQ,EACR,YAAY,EACZ,YAAY,GACb,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/FileCity3D/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,UAAU,EACV,WAAW,EACX,cAAc,EACd,eAAe,EACf,aAAa,EACb,cAAc,EACd,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,qBAAqB,GACtB,MAAM,cAAc,CAAC;AACtB,YAAY,EACV,eAAe,EACf,eAAe,EACf,cAAc,EACd,SAAS,EACT,mBAAmB,EACnB,aAAa,EACb,aAAa,EACb,WAAW,EACX,QAAQ,EACR,YAAY,EACZ,YAAY,GACb,MAAM,cAAc,CAAC"}
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export type { FileTree } from '@principal-ai/file-city-builder';
14
14
  export { CityViewWithReactFlow, type CityViewWithReactFlowProps, } from './components/CityViewWithReactFlow';
15
15
  export { ThemeProvider, useTheme } from '@principal-ade/industry-theme';
16
16
  export { FileCity3D, resetCamera, DEFAULT_FLAT_PATTERNS } from './components/FileCity3D';
17
- export type { FileCity3DProps, AnimationConfig, HighlightLayer as FileCity3DHighlightLayer, HighlightItem as FileCity3DHighlightItem, HighlightItem, IsolationMode, HeightScaling, FlatPattern, } from './components/FileCity3D';
17
+ export type { FileCity3DProps, AnimationConfig, HighlightLayer as FileCity3DHighlightLayer, IsolationMode, HeightScaling, FlatPattern, } from './components/FileCity3D';
18
18
  export type { HighlightLayer as FileCity3DHL } from './components/FileCity3D';
19
19
  export { resolveVisualizationIntent } from './utils/visualizationResolution';
20
20
  export type { VisualizationIntent, ResolvedVisualizationState, } from './utils/visualizationResolution';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,8BAA8B,EAC9B,KAAK,mCAAmC,GACzC,MAAM,6CAA6C,CAAC;AAGrD,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,UAAU,GACX,MAAM,sCAAsC,CAAC;AAG9C,OAAO,EAAE,KAAK,mBAAmB,EAAE,KAAK,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAGvF,OAAO,EACL,gCAAgC,EAChC,6BAA6B,EAC7B,oCAAoC,GACrC,MAAM,yBAAyB,CAAC;AAGjC,OAAO,EACL,8BAA8B,EAC9B,yBAAyB,EACzB,mBAAmB,GACpB,MAAM,kCAAkC,CAAC;AAE1C,YAAY,EACV,gBAAgB,EAChB,gBAAgB,EAChB,qBAAqB,EACrB,kBAAkB,GACnB,MAAM,kCAAkC,CAAC;AAG1C,OAAO,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAGzF,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAG7F,YAAY,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EACZ,sBAAsB,EACtB,QAAQ,EACR,UAAU,GACX,MAAM,iCAAiC,CAAC;AAGzC,OAAO,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAG1E,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC1D,YAAY,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AAG7F,YAAY,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AAGhE,OAAO,EACL,qBAAqB,EACrB,KAAK,0BAA0B,GAChC,MAAM,oCAAoC,CAAC;AAG5C,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,+BAA+B,CAAC;AAGxE,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AACzF,YAAY,EACV,eAAe,EACf,eAAe,EACf,cAAc,IAAI,wBAAwB,EAC1C,aAAa,IAAI,uBAAuB,EACxC,aAAa,EACb,aAAa,EACb,aAAa,EACb,WAAW,GACZ,MAAM,yBAAyB,CAAC;AAIjC,YAAY,EAAE,cAAc,IAAI,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAI9E,OAAO,EAAE,0BAA0B,EAAE,MAAM,iCAAiC,CAAC;AAC7E,YAAY,EACV,mBAAmB,EACnB,0BAA0B,GAC3B,MAAM,iCAAiC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,8BAA8B,EAC9B,KAAK,mCAAmC,GACzC,MAAM,6CAA6C,CAAC;AAGrD,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,UAAU,GACX,MAAM,sCAAsC,CAAC;AAG9C,OAAO,EAAE,KAAK,mBAAmB,EAAE,KAAK,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAGvF,OAAO,EACL,gCAAgC,EAChC,6BAA6B,EAC7B,oCAAoC,GACrC,MAAM,yBAAyB,CAAC;AAGjC,OAAO,EACL,8BAA8B,EAC9B,yBAAyB,EACzB,mBAAmB,GACpB,MAAM,kCAAkC,CAAC;AAE1C,YAAY,EACV,gBAAgB,EAChB,gBAAgB,EAChB,qBAAqB,EACrB,kBAAkB,GACnB,MAAM,kCAAkC,CAAC;AAG1C,OAAO,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAGzF,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAG7F,YAAY,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EACZ,sBAAsB,EACtB,QAAQ,EACR,UAAU,GACX,MAAM,iCAAiC,CAAC;AAGzC,OAAO,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAG1E,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC1D,YAAY,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AAG7F,YAAY,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AAGhE,OAAO,EACL,qBAAqB,EACrB,KAAK,0BAA0B,GAChC,MAAM,oCAAoC,CAAC;AAG5C,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,+BAA+B,CAAC;AAGxE,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AACzF,YAAY,EACV,eAAe,EACf,eAAe,EACf,cAAc,IAAI,wBAAwB,EAC1C,aAAa,EACb,aAAa,EACb,WAAW,GACZ,MAAM,yBAAyB,CAAC;AAIjC,YAAY,EAAE,cAAc,IAAI,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAI9E,OAAO,EAAE,0BAA0B,EAAE,MAAM,iCAAiC,CAAC;AAC7E,YAAY,EACV,mBAAmB,EACnB,0BAA0B,GAC3B,MAAM,iCAAiC,CAAC"}