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

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.
@@ -168,6 +168,8 @@ export interface FileCity3DProps {
168
168
  textColor?: string;
169
169
  /** Currently selected building (controlled by host) */
170
170
  selectedBuilding?: CityBuilding | null;
171
+ /** When true, camera height adjusts based on tallest building when grown */
172
+ adaptCameraToBuildings?: boolean;
171
173
  }
172
174
  /**
173
175
  * FileCity3D - 3D visualization of codebase structure
@@ -175,6 +177,6 @@ export interface FileCity3DProps {
175
177
  * Renders CityData as an interactive 3D city where buildings represent files
176
178
  * and their height corresponds to line count or file size.
177
179
  */
178
- export declare function FileCity3D({ cityData, width, height, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls, highlightLayers, isolationMode, dimOpacity: _dimOpacity, isLoading, loadingMessage, emptyMessage, heightScaling, linearScale, focusDirectory, focusColor, onDirectorySelect: _onDirectorySelect, backgroundColor, textColor, selectedBuilding, }: FileCity3DProps): import("react/jsx-runtime").JSX.Element;
180
+ export declare function FileCity3D({ cityData, width, height, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls, highlightLayers, isolationMode, dimOpacity: _dimOpacity, isLoading, loadingMessage, emptyMessage, heightScaling, linearScale, focusDirectory, focusColor, onDirectorySelect: _onDirectorySelect, backgroundColor, textColor, selectedBuilding, adaptCameraToBuildings, }: FileCity3DProps): import("react/jsx-runtime").JSX.Element;
179
181
  export default FileCity3D;
180
182
  //# sourceMappingURL=FileCity3D.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"FileCity3D.d.ts","sourceRoot":"","sources":["../../../src/components/FileCity3D/FileCity3D.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAA4D,MAAM,OAAO,CAAC;AAMjF,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAEb,MAAM,iCAAiC,CAAC;AAEzC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAGxD,OAAO,QAAQ,OAAO,CAAC;IAErB,UAAU,GAAG,CAAC;QAEZ,UAAU,iBAAkB,SAAQ,aAAa;SAAG;KACrD;CACF;AAGD,YAAY,EAAE,QAAQ,EAAE,YAAY,EAAE,YAAY,EAAE,CAAC;AAGrD,MAAM,WAAW,cAAc;IAC7B,wBAAwB;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,8BAA8B;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,4BAA4B;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,yBAAyB;IACzB,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,6BAA6B;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,mBAAmB;IACnB,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;CAC5B;AAED,gDAAgD;AAChD,MAAM,MAAM,aAAa,GACrB,MAAM,GACN,aAAa,GACb,UAAU,GACV,MAAM,CAAC;AAGX,MAAM,WAAW,eAAe;IAC9B,0CAA0C;IAC1C,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,mFAAmF;IACnF,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,2CAA2C;IAC3C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gDAAgD;IAChD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,wCAAwC;AACxC,MAAM,MAAM,aAAa,GAAG,aAAa,GAAG,QAAQ,CAAC;AAq4BrD,MAAM,WAAW,aAAa;IAC5B,qFAAqF;IACrF,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAmBD,wBAAgB,WAAW,SAE1B;AAED,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,QAE/D;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,QAEvF;AAED;;GAEG;AACH,wBAAgB,eAAe;OA9BA,MAAM;OAAK,MAAM;OAAK,MAAM;SAgC1D;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,gBAAgB,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,EAC9D,OAAO,CAAC,EAAE,aAAa,QAGxB;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,QAEtE;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,KAAK,EAChD,OAAO,CAAC,EAAE,aAAa,QAGxB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,QAEpE;AAED,wBAAgB,iBAAiB;OAhFA,MAAM;OAAK,MAAM;OAAK,MAAM;SAkF5D;AAED;;;GAGG;AACH,wBAAgB,cAAc,kBAE7B;AAED;;;GAGG;AACH,wBAAgB,aAAa,kBAE5B;AAs7BD,MAAM,WAAW,eAAe;IAC9B,uCAAuC;IACvC,QAAQ,EAAE,QAAQ,CAAC;IACnB,6BAA6B;IAC7B,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,8BAA8B;IAC9B,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,0CAA0C;IAC1C,eAAe,CAAC,EAAE,CAAC,QAAQ,EAAE,YAAY,KAAK,IAAI,CAAC;IACnD,qBAAqB;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oBAAoB;IACpB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAC5B,8BAA8B;IAC9B,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,wEAAwE;IACxE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,uCAAuC;IACvC,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IAC1C,2BAA2B;IAC3B,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,kEAAkE;IAClE,eAAe,CAAC,EAAE,cAAc,EAAE,CAAC;IACnC,yEAAyE;IACzE,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,6DAA6D;IAC7D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wCAAwC;IACxC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,uCAAuC;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,8CAA8C;IAC9C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,+DAA+D;IAC/D,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,mEAAmE;IACnE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mEAAmE;IACnE,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,2EAA2E;IAC3E,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,0DAA0D;IAC1D,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACvD,gDAAgD;IAChD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,gBAAgB,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;CACxC;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,KAAc,EACd,MAAY,EACZ,eAAe,EACf,SAAS,EACT,KAAK,EACL,SAAS,EACT,OAAO,EAAE,eAAe,EACxB,YAAY,EACZ,YAAmB,EACnB,eAAoB,EACpB,aAA6B,EAC7B,UAAU,EAAE,WAAkB,EAC9B,SAAiB,EACjB,cAAuC,EACvC,YAA4C,EAC5C,aAA6B,EAC7B,WAAkB,EAClB,cAAqB,EACrB,UAAiB,EACjB,iBAAiB,EAAE,kBAAkB,EACrC,eAA2B,EAC3B,SAAqB,EACrB,gBAAuB,GACxB,EAAE,eAAe,2CA2HjB;AAED,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"FileCity3D.d.ts","sourceRoot":"","sources":["../../../src/components/FileCity3D/FileCity3D.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAA4D,MAAM,OAAO,CAAC;AAMjF,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAEb,MAAM,iCAAiC,CAAC;AAEzC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAGxD,OAAO,QAAQ,OAAO,CAAC;IAErB,UAAU,GAAG,CAAC;QAEZ,UAAU,iBAAkB,SAAQ,aAAa;SAAG;KACrD;CACF;AAGD,YAAY,EAAE,QAAQ,EAAE,YAAY,EAAE,YAAY,EAAE,CAAC;AAGrD,MAAM,WAAW,cAAc;IAC7B,wBAAwB;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,8BAA8B;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,4BAA4B;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,yBAAyB;IACzB,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,6BAA6B;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,mBAAmB;IACnB,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;CAC5B;AAED,gDAAgD;AAChD,MAAM,MAAM,aAAa,GACrB,MAAM,GACN,aAAa,GACb,UAAU,GACV,MAAM,CAAC;AAGX,MAAM,WAAW,eAAe;IAC9B,0CAA0C;IAC1C,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,mFAAmF;IACnF,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,2CAA2C;IAC3C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gDAAgD;IAChD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,wCAAwC;AACxC,MAAM,MAAM,aAAa,GAAG,aAAa,GAAG,QAAQ,CAAC;AAq6BrD,MAAM,WAAW,aAAa;IAC5B,qFAAqF;IACrF,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAmBD,wBAAgB,WAAW,SAE1B;AAED,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,QAE/D;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,QAEvF;AAED;;GAEG;AACH,wBAAgB,eAAe;OA9BA,MAAM;OAAK,MAAM;OAAK,MAAM;SAgC1D;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,gBAAgB,EAAE,MAAM,GAAG,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,EAC9D,OAAO,CAAC,EAAE,aAAa,QAGxB;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,QAEtE;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,KAAK,EAChD,OAAO,CAAC,EAAE,aAAa,QAGxB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,QAEpE;AAED,wBAAgB,iBAAiB;OAhFA,MAAM;OAAK,MAAM;OAAK,MAAM;SAkF5D;AAED;;;GAGG;AACH,wBAAgB,cAAc,kBAE7B;AAED;;;GAGG;AACH,wBAAgB,aAAa,kBAE5B;AAs8BD,MAAM,WAAW,eAAe;IAC9B,uCAAuC;IACvC,QAAQ,EAAE,QAAQ,CAAC;IACnB,6BAA6B;IAC7B,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,8BAA8B;IAC9B,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,0CAA0C;IAC1C,eAAe,CAAC,EAAE,CAAC,QAAQ,EAAE,YAAY,KAAK,IAAI,CAAC;IACnD,qBAAqB;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oBAAoB;IACpB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAC5B,8BAA8B;IAC9B,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,wEAAwE;IACxE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,uCAAuC;IACvC,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IAC1C,2BAA2B;IAC3B,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,kEAAkE;IAClE,eAAe,CAAC,EAAE,cAAc,EAAE,CAAC;IACnC,yEAAyE;IACzE,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,6DAA6D;IAC7D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wCAAwC;IACxC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,uCAAuC;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,8CAA8C;IAC9C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,+DAA+D;IAC/D,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,mEAAmE;IACnE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mEAAmE;IACnE,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,2EAA2E;IAC3E,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,0DAA0D;IAC1D,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACvD,gDAAgD;IAChD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,gBAAgB,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;IACvC,4EAA4E;IAC5E,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,KAAc,EACd,MAAY,EACZ,eAAe,EACf,SAAS,EACT,KAAK,EACL,SAAS,EACT,OAAO,EAAE,eAAe,EACxB,YAAY,EACZ,YAAmB,EACnB,eAAoB,EACpB,aAA6B,EAC7B,UAAU,EAAE,WAAkB,EAC9B,SAAiB,EACjB,cAAuC,EACvC,YAA4C,EAC5C,aAA6B,EAC7B,WAAkB,EAClB,cAAqB,EACrB,UAAiB,EACjB,iBAAiB,EAAE,kBAAkB,EACrC,eAA2B,EAC3B,SAAqB,EACrB,gBAAuB,EACvB,sBAA8B,GAC/B,EAAE,eAAe,2CA4HjB;AAED,eAAe,UAAU,CAAC"}
@@ -439,11 +439,11 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
439
439
  })), growProgress: growProgress, minHeight: minHeight, baseOffset: baseOffset, springDuration: springDuration, heightMultipliersRef: heightMultipliersRef })] }));
440
440
  }
441
441
  function AnimatedIcon({ x, z, targetHeight, iconSize, texture, opacity, growProgress, staggerDelayMs, springDuration, }) {
442
- const spriteRef = useRef(null);
442
+ const meshRef = useRef(null);
443
443
  const startTimeRef = useRef(null);
444
444
  const materialRef = useRef(null);
445
445
  useFrame(({ clock }) => {
446
- if (!spriteRef.current)
446
+ if (!meshRef.current)
447
447
  return;
448
448
  if (startTimeRef.current === null && growProgress > 0) {
449
449
  startTimeRef.current = clock.elapsedTime * 1000;
@@ -465,13 +465,26 @@ function AnimatedIcon({ x, z, targetHeight, iconSize, texture, opacity, growProg
465
465
  const baseOffset = 0.2;
466
466
  const height = animProgress * targetHeight + minHeight;
467
467
  const buildingTop = height + baseOffset;
468
- const yPosition = buildingTop + iconSize / 2 + 2;
469
- spriteRef.current.position.y = yPosition;
468
+ // When flat (animProgress=0): icon lies flat at ground level
469
+ // When grown (animProgress=1): icon floats above building
470
+ const flatY = minHeight + baseOffset + 0.5;
471
+ const grownY = buildingTop + iconSize / 2 + 2;
472
+ const yPosition = flatY + (grownY - flatY) * animProgress;
473
+ meshRef.current.position.y = yPosition;
474
+ // Rotate from flat (facing up) to upright (facing camera-ish)
475
+ // Flat: -Math.PI / 2 (facing up)
476
+ // Grown: 0 (facing forward)
477
+ const flatRotationX = -Math.PI / 2;
478
+ const grownRotationX = 0;
479
+ meshRef.current.rotation.x = flatRotationX + (grownRotationX - flatRotationX) * animProgress;
470
480
  if (materialRef.current) {
471
- materialRef.current.opacity = opacity * animProgress;
481
+ // Show icons even when flat, fade out only slightly
482
+ const minOpacity = 0.8;
483
+ const effectiveOpacity = minOpacity + (1 - minOpacity) * animProgress;
484
+ materialRef.current.opacity = opacity * effectiveOpacity;
472
485
  }
473
486
  });
474
- return (_jsx("sprite", { ref: spriteRef, position: [x, 0, z], scale: [iconSize, iconSize, 1], raycast: () => null, children: _jsx("spriteMaterial", { ref: materialRef, map: texture, transparent: true, opacity: 0, depthTest: true, depthWrite: false }) }));
487
+ return (_jsxs("mesh", { ref: meshRef, position: [x, 0, z], scale: [iconSize, iconSize, 1], raycast: () => null, children: [_jsx("planeGeometry", { args: [1, 1] }), _jsx("meshBasicMaterial", { ref: materialRef, map: texture, transparent: true, opacity: 0.8, depthTest: true, depthWrite: false, side: THREE.DoubleSide })] }));
475
488
  }
476
489
  function BuildingIcons({ buildings, centerOffset, growProgress, heightScaling, linearScale, highlightLayers, isolationMode, hasActiveHighlights, staggerIndices, springDuration, staggerDelay, }) {
477
490
  // Pre-compute buildings with icons
@@ -516,9 +529,7 @@ function BuildingIcons({ buildings, centerOffset, growProgress, heightScaling, l
516
529
  staggerIndices,
517
530
  staggerDelay,
518
531
  ]);
519
- // Don't render if no progress yet
520
- if (growProgress < 0.1)
521
- return null;
532
+ // Icons are now always rendered (flat or grown)
522
533
  return (_jsx(_Fragment, { children: buildingsWithIcons.map(({ building, config, x, z, targetHeight, shouldDim, staggerDelayMs }) => {
523
534
  const icon = config.icon;
524
535
  const texture = getIconTexture(icon.name, icon.color || '#ffffff');
@@ -526,14 +537,14 @@ function BuildingIcons({ buildings, centerOffset, growProgress, heightScaling, l
526
537
  return null;
527
538
  // Icon size based on building dimensions
528
539
  const [width] = building.dimensions;
529
- const baseSize = Math.max(width * 0.8, 6);
540
+ const baseSize = Math.max(width * 1.2, 8);
530
541
  const heightBoost = Math.min(targetHeight / 20, 3);
531
542
  const iconSize = (baseSize + heightBoost) * (icon.size || 1);
532
543
  const opacity = shouldDim && isolationMode === 'transparent' ? 0.3 : 1;
533
544
  return (_jsx(AnimatedIcon, { x: x, z: z, targetHeight: targetHeight, iconSize: iconSize, texture: texture, opacity: opacity, growProgress: growProgress, staggerDelayMs: staggerDelayMs, springDuration: springDuration }, building.path));
534
545
  }) }));
535
546
  }
536
- function DistrictFloor({ district, centerOffset, highlightColor }) {
547
+ function DistrictFloor({ district, centerOffset, highlightColor, growProgress }) {
537
548
  const { worldBounds } = district;
538
549
  const width = worldBounds.maxX - worldBounds.minX;
539
550
  const depth = worldBounds.maxZ - worldBounds.minZ;
@@ -545,7 +556,19 @@ function DistrictFloor({ district, centerOffset, highlightColor }) {
545
556
  const borderColor = highlightColor || '#475569';
546
557
  const lineWidth = highlightColor ? 3 : 1;
547
558
  const labelColor = highlightColor || '#cbd5e1';
548
- return (_jsxs("group", { position: [centerX, 0, centerZ], children: [_jsxs("lineSegments", { rotation: [-Math.PI / 2, 0, 0], position: [0, floorY, 0], renderOrder: -1, children: [_jsx("edgesGeometry", { args: [new THREE.PlaneGeometry(width, depth)], attach: "geometry" }), _jsx("lineBasicMaterial", { color: borderColor, linewidth: lineWidth, depthWrite: false })] }), highlightColor && (_jsxs("mesh", { rotation: [-Math.PI / 2, 0, 0], position: [0, floorY - 0.1, 0], renderOrder: -2, children: [_jsx("planeGeometry", { args: [width, depth] }), _jsx("meshBasicMaterial", { color: highlightColor, transparent: true, opacity: 0.15, depthWrite: false })] })), district.label && (_jsx(Text, { position: [0, 1.5, depth / 2 + 2], rotation: [-Math.PI / 6, 0, 0], fontSize: Math.min(3, width / 6), color: labelColor, anchorX: "center", anchorY: "middle", outlineWidth: 0.1, outlineColor: "#0f172a", children: dirName }))] }));
559
+ // Interpolate text rotation and position based on growProgress
560
+ // Flat: -Math.PI / 2 (facing up), positioned at center of district
561
+ // Grown: -Math.PI / 6 (angled), positioned at edge of district
562
+ const flatRotationX = -Math.PI / 2;
563
+ const grownRotationX = -Math.PI / 6;
564
+ const textRotationX = flatRotationX + (grownRotationX - flatRotationX) * growProgress;
565
+ const flatY = 0.5;
566
+ const grownY = 1.5;
567
+ const textY = flatY + (grownY - flatY) * growProgress;
568
+ const flatZ = 0; // Center of district when flat
569
+ const grownZ = depth / 2 + 2; // Edge of district when grown
570
+ const textZ = flatZ + (grownZ - flatZ) * growProgress;
571
+ return (_jsxs("group", { position: [centerX, 0, centerZ], children: [_jsxs("lineSegments", { rotation: [-Math.PI / 2, 0, 0], position: [0, floorY, 0], renderOrder: -1, children: [_jsx("edgesGeometry", { args: [new THREE.PlaneGeometry(width, depth)], attach: "geometry" }), _jsx("lineBasicMaterial", { color: borderColor, linewidth: lineWidth, depthWrite: false })] }), highlightColor && (_jsxs("mesh", { rotation: [-Math.PI / 2, 0, 0], position: [0, floorY - 0.1, 0], renderOrder: -2, children: [_jsx("planeGeometry", { args: [width, depth] }), _jsx("meshBasicMaterial", { color: highlightColor, transparent: true, opacity: 0.15, depthWrite: false })] })), district.label && (_jsx(Text, { position: [0, textY, textZ], rotation: [textRotationX, 0, 0], fontSize: Math.min(3, width / 6), color: labelColor, anchorX: "center", anchorY: "middle", outlineWidth: 0.1, outlineColor: "#0f172a", children: dirName }))] }));
549
572
  }
550
573
  let cameraApi = null;
551
574
  export function resetCamera() {
@@ -623,7 +646,7 @@ export function getCameraAngle() {
623
646
  export function getCameraTilt() {
624
647
  return cameraApi?.getCurrentTilt() ?? null;
625
648
  }
626
- function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }) {
649
+ function AnimatedCamera({ citySize, isFlat, focusTarget, maxBuildingHeight = 0 }) {
627
650
  const { camera } = useThree();
628
651
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
629
652
  const controlsRef = useRef(null);
@@ -632,6 +655,7 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }) {
632
655
  const hasAppliedInitial = useRef(false);
633
656
  const frameCount = useRef(0);
634
657
  // Compute target camera position
658
+ // When flat, use a more top-down view; when grown, use an angled view
635
659
  const targetPos = useMemo(() => {
636
660
  if (focusTarget) {
637
661
  const distance = Math.max(focusTarget.size * 2, 50);
@@ -645,9 +669,15 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }) {
645
669
  targetZ: focusTarget.z,
646
670
  };
647
671
  }
648
- // Default overview
649
- const targetHeight = citySize * 1.1;
650
- const targetZ = citySize * 1.3;
672
+ // Default overview - adjust angle based on flat/grown state
673
+ // Flat: directly overhead (90 degrees, looking straight down)
674
+ // Grown: angled view to see building heights (optionally based on max building height)
675
+ const baseHeight = citySize * 1.1;
676
+ const buildingAwareHeight = maxBuildingHeight > 0
677
+ ? Math.max(baseHeight, maxBuildingHeight * 2.5)
678
+ : baseHeight;
679
+ const targetHeight = isFlat ? citySize * 2.0 : buildingAwareHeight;
680
+ const targetZ = isFlat ? 0.001 : citySize * 1.3; // Near-zero for top-down (tiny offset to avoid gimbal lock)
651
681
  return {
652
682
  x: 0,
653
683
  y: targetHeight,
@@ -656,7 +686,7 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }) {
656
686
  targetY: 0,
657
687
  targetZ: 0,
658
688
  };
659
- }, [focusTarget, citySize]);
689
+ }, [focusTarget, citySize, isFlat, maxBuildingHeight]);
660
690
  // Spring animation for camera movement
661
691
  const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => ({
662
692
  camX: targetPos.x,
@@ -1074,12 +1104,18 @@ function ControlsOverlay({ isFlat, onToggle, onResetCamera }) {
1074
1104
  gap: 8,
1075
1105
  }, children: [_jsx("button", { onClick: onResetCamera, style: buttonStyle, children: "Reset View" }), _jsx("button", { onClick: onToggle, style: buttonStyle, children: isFlat ? 'Grow to 3D' : 'Flatten to 2D' })] }));
1076
1106
  }
1077
- function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, focusColor, }) {
1107
+ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding, selectedBuilding, growProgress, animationConfig, highlightLayers, isolationMode, heightScaling, linearScale, focusDirectory, focusColor, adaptCameraToBuildings = false, }) {
1078
1108
  const centerOffset = useMemo(() => ({
1079
1109
  x: (cityData.bounds.minX + cityData.bounds.maxX) / 2,
1080
1110
  z: (cityData.bounds.minZ + cityData.bounds.maxZ) / 2,
1081
1111
  }), [cityData.bounds]);
1082
1112
  const citySize = Math.max(cityData.bounds.maxX - cityData.bounds.minX, cityData.bounds.maxZ - cityData.bounds.minZ);
1113
+ // Calculate max building height for camera positioning (when adaptCameraToBuildings is true)
1114
+ const maxBuildingHeight = useMemo(() => {
1115
+ if (!adaptCameraToBuildings)
1116
+ return 0;
1117
+ return Math.max(...cityData.buildings.map(b => b.dimensions[1]), 0);
1118
+ }, [adaptCameraToBuildings, cityData.buildings]);
1083
1119
  const activeHighlights = useMemo(() => hasActiveHighlights(highlightLayers), [highlightLayers]);
1084
1120
  // Helper to check if a path is inside a directory
1085
1121
  const isPathInDirectory = useCallback((path, directory) => {
@@ -1262,7 +1298,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
1262
1298
  const tension = animationConfig.tension || 120;
1263
1299
  const friction = animationConfig.friction || 14;
1264
1300
  const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
1265
- return (_jsxs(_Fragment, { children: [_jsx(AnimatedCamera, { citySize: citySize, isFlat: growProgress === 0, focusTarget: focusTarget }), _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 => {
1301
+ return (_jsxs(_Fragment, { children: [_jsx(AnimatedCamera, { citySize: citySize, isFlat: growProgress === 0, focusTarget: focusTarget, maxBuildingHeight: maxBuildingHeight }), _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 => {
1266
1302
  // Check if district matches focusDirectory
1267
1303
  const isFocused = buildingFocusDirectory
1268
1304
  ? district.path === buildingFocusDirectory
@@ -1284,7 +1320,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
1284
1320
  // Use buildingFocusColor (synced with animation) instead of focusColor prop
1285
1321
  // Focus color takes priority, then highlight layer color
1286
1322
  const districtColor = (isFocused && buildingFocusColor) ? buildingFocusColor : highlightLayerColor;
1287
- return (_jsx(DistrictFloor, { district: district, centerOffset: centerOffset, opacity: 1, highlightColor: districtColor }, district.path));
1323
+ return (_jsx(DistrictFloor, { district: district, centerOffset: centerOffset, opacity: 1, highlightColor: districtColor, growProgress: growProgress }, district.path));
1288
1324
  }), _jsx(InstancedBuildings, { buildings: cityData.buildings, centerOffset: centerOffset, onHover: onBuildingHover, onClick: onBuildingClick, hoveredIndex: hoveredIndex, selectedIndex: selectedIndex, growProgress: growProgress, animationConfig: animationConfig, heightScaling: heightScaling, linearScale: linearScale, staggerIndices: staggerIndices, focusDirectory: buildingFocusDirectory, highlightLayers: highlightLayers, isolationMode: isolationMode }), _jsx(BuildingIcons, { buildings: cityData.buildings, centerOffset: centerOffset, growProgress: growProgress, heightScaling: heightScaling, linearScale: linearScale, highlightLayers: highlightLayers, isolationMode: isolationMode, hasActiveHighlights: activeHighlights, staggerIndices: staggerIndices, springDuration: springDuration, staggerDelay: animationConfig.staggerDelay || 15 })] }));
1289
1325
  }
1290
1326
  /**
@@ -1293,7 +1329,7 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
1293
1329
  * Renders CityData as an interactive 3D city where buildings represent files
1294
1330
  * and their height corresponds to line count or file size.
1295
1331
  */
1296
- export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls = true, highlightLayers = [], isolationMode = 'transparent', dimOpacity: _dimOpacity = 0.15, isLoading = false, loadingMessage = 'Loading file city...', emptyMessage = 'No file tree data available', heightScaling = 'logarithmic', linearScale = 0.05, focusDirectory = null, focusColor = null, onDirectorySelect: _onDirectorySelect, backgroundColor = '#0f172a', textColor = '#94a3b8', selectedBuilding = null, }) {
1332
+ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingClick, className, style, animation, isGrown: externalIsGrown, onGrowChange, showControls = true, highlightLayers = [], isolationMode = 'transparent', dimOpacity: _dimOpacity = 0.15, isLoading = false, loadingMessage = 'Loading file city...', emptyMessage = 'No file tree data available', heightScaling = 'logarithmic', linearScale = 0.05, focusDirectory = null, focusColor = null, onDirectorySelect: _onDirectorySelect, backgroundColor = '#0f172a', textColor = '#94a3b8', selectedBuilding = null, adaptCameraToBuildings = false, }) {
1297
1333
  const [hoveredBuilding, setHoveredBuilding] = useState(null);
1298
1334
  const [internalIsGrown, setInternalIsGrown] = useState(false);
1299
1335
  const animationConfig = useMemo(() => ({ ...DEFAULT_ANIMATION, ...animation }), [animation]);
@@ -1364,6 +1400,6 @@ export function FileCity3D({ cityData, width = '100%', height = 600, onBuildingC
1364
1400
  left: 0,
1365
1401
  width: '100%',
1366
1402
  height: '100%',
1367
- }, children: _jsx(CityScene, { cityData: cityData, onBuildingHover: setHoveredBuilding, onBuildingClick: onBuildingClick, hoveredBuilding: hoveredBuilding, selectedBuilding: selectedBuilding, growProgress: growProgress, animationConfig: animationConfig, highlightLayers: highlightLayers, isolationMode: isolationMode, heightScaling: heightScaling, linearScale: linearScale, focusDirectory: focusDirectory, focusColor: focusColor }) }), _jsx(InfoPanel, { building: selectedBuilding || hoveredBuilding }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
1403
+ }, children: _jsx(CityScene, { cityData: cityData, onBuildingHover: setHoveredBuilding, onBuildingClick: onBuildingClick, hoveredBuilding: hoveredBuilding, selectedBuilding: selectedBuilding, growProgress: growProgress, animationConfig: animationConfig, highlightLayers: highlightLayers, isolationMode: isolationMode, heightScaling: heightScaling, linearScale: linearScale, focusDirectory: focusDirectory, focusColor: focusColor, adaptCameraToBuildings: adaptCameraToBuildings }) }), _jsx(InfoPanel, { building: selectedBuilding || hoveredBuilding }), showControls && (_jsx(ControlsOverlay, { isFlat: !isGrown, onToggle: handleToggle, onResetCamera: resetCamera }))] }));
1368
1404
  }
1369
1405
  export default FileCity3D;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * FileCity3D - 3D visualization component
3
3
  */
4
- export { FileCity3D, resetCamera, moveCameraTo, setCameraTarget, getCameraTarget, rotateCameraTo, rotateCameraBy, tiltCameraTo, tiltCameraBy, getCameraPosition, getCameraAngle, getCameraTilt } from './FileCity3D';
5
- export type { FileCity3DProps, AnimationConfig, HighlightLayer, HighlightItem, IsolationMode, HeightScaling, CityData, CityBuilding, CityDistrict, RotateOptions, } from './FileCity3D';
4
+ export { FileCity3D, resetCamera, getCameraAngle, getCameraTarget, getCameraTilt, rotateCameraTo, rotateCameraBy, tiltCameraTo, tiltCameraBy, moveCameraTo, setCameraTarget, } from './FileCity3D';
5
+ export type { FileCity3DProps, AnimationConfig, HighlightLayer, HighlightItem, IsolationMode, HeightScaling, 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,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,cAAc,EAAE,cAAc,EAAE,YAAY,EAAE,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACrN,YAAY,EACV,eAAe,EACf,eAAe,EACf,cAAc,EACd,aAAa,EACb,aAAa,EACb,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,YAAY,EACZ,aAAa,GACd,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,GAChB,MAAM,cAAc,CAAC;AACtB,YAAY,EACV,eAAe,EACf,eAAe,EACf,cAAc,EACd,aAAa,EACb,aAAa,EACb,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,YAAY,GACb,MAAM,cAAc,CAAC"}
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * FileCity3D - 3D visualization component
3
3
  */
4
- export { FileCity3D, resetCamera, moveCameraTo, setCameraTarget, getCameraTarget, rotateCameraTo, rotateCameraBy, tiltCameraTo, tiltCameraBy, getCameraPosition, getCameraAngle, getCameraTilt } from './FileCity3D';
4
+ export { FileCity3D, resetCamera, getCameraAngle, getCameraTarget, getCameraTilt, rotateCameraTo, rotateCameraBy, tiltCameraTo, tiltCameraBy, moveCameraTo, setCameraTarget, } from './FileCity3D';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principal-ai/file-city-react",
3
- "version": "0.5.16",
3
+ "version": "0.5.18",
4
4
  "type": "module",
5
5
  "description": "React components for File City visualization",
6
6
  "main": "dist/index.js",
@@ -745,12 +745,12 @@ function AnimatedIcon({
745
745
  staggerDelayMs,
746
746
  springDuration,
747
747
  }: AnimatedIconProps) {
748
- const spriteRef = useRef<THREE.Sprite>(null);
748
+ const meshRef = useRef<THREE.Mesh>(null);
749
749
  const startTimeRef = useRef<number | null>(null);
750
- const materialRef = useRef<THREE.SpriteMaterial>(null);
750
+ const materialRef = useRef<THREE.MeshBasicMaterial>(null);
751
751
 
752
752
  useFrame(({ clock }) => {
753
- if (!spriteRef.current) return;
753
+ if (!meshRef.current) return;
754
754
 
755
755
  if (startTimeRef.current === null && growProgress > 0) {
756
756
  startTimeRef.current = clock.elapsedTime * 1000;
@@ -775,31 +775,48 @@ function AnimatedIcon({
775
775
  const baseOffset = 0.2;
776
776
  const height = animProgress * targetHeight + minHeight;
777
777
  const buildingTop = height + baseOffset;
778
- const yPosition = buildingTop + iconSize / 2 + 2;
779
778
 
780
- spriteRef.current.position.y = yPosition;
779
+ // When flat (animProgress=0): icon lies flat at ground level
780
+ // When grown (animProgress=1): icon floats above building
781
+ const flatY = minHeight + baseOffset + 0.5;
782
+ const grownY = buildingTop + iconSize / 2 + 2;
783
+ const yPosition = flatY + (grownY - flatY) * animProgress;
784
+
785
+ meshRef.current.position.y = yPosition;
786
+
787
+ // Rotate from flat (facing up) to upright (facing camera-ish)
788
+ // Flat: -Math.PI / 2 (facing up)
789
+ // Grown: 0 (facing forward)
790
+ const flatRotationX = -Math.PI / 2;
791
+ const grownRotationX = 0;
792
+ meshRef.current.rotation.x = flatRotationX + (grownRotationX - flatRotationX) * animProgress;
781
793
 
782
794
  if (materialRef.current) {
783
- materialRef.current.opacity = opacity * animProgress;
795
+ // Show icons even when flat, fade out only slightly
796
+ const minOpacity = 0.8;
797
+ const effectiveOpacity = minOpacity + (1 - minOpacity) * animProgress;
798
+ materialRef.current.opacity = opacity * effectiveOpacity;
784
799
  }
785
800
  });
786
801
 
787
802
  return (
788
- <sprite
789
- ref={spriteRef}
803
+ <mesh
804
+ ref={meshRef}
790
805
  position={[x, 0, z]}
791
806
  scale={[iconSize, iconSize, 1]}
792
807
  raycast={() => null}
793
808
  >
794
- <spriteMaterial
809
+ <planeGeometry args={[1, 1]} />
810
+ <meshBasicMaterial
795
811
  ref={materialRef}
796
812
  map={texture}
797
813
  transparent
798
- opacity={0}
814
+ opacity={0.8}
799
815
  depthTest={true}
800
816
  depthWrite={false}
817
+ side={THREE.DoubleSide}
801
818
  />
802
- </sprite>
819
+ </mesh>
803
820
  );
804
821
  }
805
822
 
@@ -871,9 +888,7 @@ function BuildingIcons({
871
888
  staggerDelay,
872
889
  ]);
873
890
 
874
- // Don't render if no progress yet
875
- if (growProgress < 0.1) return null;
876
-
891
+ // Icons are now always rendered (flat or grown)
877
892
  return (
878
893
  <>
879
894
  {buildingsWithIcons.map(
@@ -884,7 +899,7 @@ function BuildingIcons({
884
899
 
885
900
  // Icon size based on building dimensions
886
901
  const [width] = building.dimensions;
887
- const baseSize = Math.max(width * 0.8, 6);
902
+ const baseSize = Math.max(width * 1.2, 8);
888
903
  const heightBoost = Math.min(targetHeight / 20, 3);
889
904
  const iconSize = (baseSize + heightBoost) * (icon.size || 1);
890
905
 
@@ -916,9 +931,10 @@ interface DistrictFloorProps {
916
931
  centerOffset: { x: number; z: number };
917
932
  opacity: number;
918
933
  highlightColor?: string | null;
934
+ growProgress: number;
919
935
  }
920
936
 
921
- function DistrictFloor({ district, centerOffset, highlightColor }: DistrictFloorProps) {
937
+ function DistrictFloor({ district, centerOffset, highlightColor, growProgress }: DistrictFloorProps) {
922
938
  const { worldBounds } = district;
923
939
  const width = worldBounds.maxX - worldBounds.minX;
924
940
  const depth = worldBounds.maxZ - worldBounds.minZ;
@@ -934,6 +950,21 @@ function DistrictFloor({ district, centerOffset, highlightColor }: DistrictFloor
934
950
  const lineWidth = highlightColor ? 3 : 1;
935
951
  const labelColor = highlightColor || '#cbd5e1';
936
952
 
953
+ // Interpolate text rotation and position based on growProgress
954
+ // Flat: -Math.PI / 2 (facing up), positioned at center of district
955
+ // Grown: -Math.PI / 6 (angled), positioned at edge of district
956
+ const flatRotationX = -Math.PI / 2;
957
+ const grownRotationX = -Math.PI / 6;
958
+ const textRotationX = flatRotationX + (grownRotationX - flatRotationX) * growProgress;
959
+
960
+ const flatY = 0.5;
961
+ const grownY = 1.5;
962
+ const textY = flatY + (grownY - flatY) * growProgress;
963
+
964
+ const flatZ = 0; // Center of district when flat
965
+ const grownZ = depth / 2 + 2; // Edge of district when grown
966
+ const textZ = flatZ + (grownZ - flatZ) * growProgress;
967
+
937
968
  return (
938
969
  <group position={[centerX, 0, centerZ]}>
939
970
  {/* Border outline */}
@@ -952,8 +983,8 @@ function DistrictFloor({ district, centerOffset, highlightColor }: DistrictFloor
952
983
 
953
984
  {district.label && (
954
985
  <Text
955
- position={[0, 1.5, depth / 2 + 2]}
956
- rotation={[-Math.PI / 6, 0, 0]}
986
+ position={[0, textY, textZ]}
987
+ rotation={[textRotationX, 0, 0]}
957
988
  fontSize={Math.min(3, width / 6)}
958
989
  color={labelColor}
959
990
  anchorX="center"
@@ -979,6 +1010,7 @@ interface AnimatedCameraProps {
979
1010
  citySize: number;
980
1011
  isFlat: boolean;
981
1012
  focusTarget?: FocusTarget | null;
1013
+ maxBuildingHeight?: number;
982
1014
  }
983
1015
 
984
1016
  // Camera rotation options
@@ -1096,7 +1128,7 @@ export function getCameraTilt() {
1096
1128
  return cameraApi?.getCurrentTilt() ?? null;
1097
1129
  }
1098
1130
 
1099
- function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }: AnimatedCameraProps) {
1131
+ function AnimatedCamera({ citySize, isFlat, focusTarget, maxBuildingHeight = 0 }: AnimatedCameraProps) {
1100
1132
  const { camera } = useThree();
1101
1133
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1102
1134
  const controlsRef = useRef<any>(null);
@@ -1106,6 +1138,7 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }: AnimatedCame
1106
1138
  const frameCount = useRef(0);
1107
1139
 
1108
1140
  // Compute target camera position
1141
+ // When flat, use a more top-down view; when grown, use an angled view
1109
1142
  const targetPos = useMemo(() => {
1110
1143
  if (focusTarget) {
1111
1144
  const distance = Math.max(focusTarget.size * 2, 50);
@@ -1119,9 +1152,15 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }: AnimatedCame
1119
1152
  targetZ: focusTarget.z,
1120
1153
  };
1121
1154
  }
1122
- // Default overview
1123
- const targetHeight = citySize * 1.1;
1124
- const targetZ = citySize * 1.3;
1155
+ // Default overview - adjust angle based on flat/grown state
1156
+ // Flat: directly overhead (90 degrees, looking straight down)
1157
+ // Grown: angled view to see building heights (optionally based on max building height)
1158
+ const baseHeight = citySize * 1.1;
1159
+ const buildingAwareHeight = maxBuildingHeight > 0
1160
+ ? Math.max(baseHeight, maxBuildingHeight * 2.5)
1161
+ : baseHeight;
1162
+ const targetHeight = isFlat ? citySize * 2.0 : buildingAwareHeight;
1163
+ const targetZ = isFlat ? 0.001 : citySize * 1.3; // Near-zero for top-down (tiny offset to avoid gimbal lock)
1125
1164
  return {
1126
1165
  x: 0,
1127
1166
  y: targetHeight,
@@ -1130,7 +1169,7 @@ function AnimatedCamera({ citySize, isFlat: _isFlat, focusTarget }: AnimatedCame
1130
1169
  targetY: 0,
1131
1170
  targetZ: 0,
1132
1171
  };
1133
- }, [focusTarget, citySize]);
1172
+ }, [focusTarget, citySize, isFlat, maxBuildingHeight]);
1134
1173
 
1135
1174
  // Spring animation for camera movement
1136
1175
  const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => ({
@@ -1695,6 +1734,7 @@ interface CitySceneProps {
1695
1734
  linearScale: number;
1696
1735
  focusDirectory: string | null;
1697
1736
  focusColor?: string | null;
1737
+ adaptCameraToBuildings?: boolean;
1698
1738
  }
1699
1739
 
1700
1740
  function CityScene({
@@ -1711,6 +1751,7 @@ function CityScene({
1711
1751
  linearScale,
1712
1752
  focusDirectory,
1713
1753
  focusColor,
1754
+ adaptCameraToBuildings = false,
1714
1755
  }: CitySceneProps) {
1715
1756
  const centerOffset = useMemo(
1716
1757
  () => ({
@@ -1725,6 +1766,12 @@ function CityScene({
1725
1766
  cityData.bounds.maxZ - cityData.bounds.minZ,
1726
1767
  );
1727
1768
 
1769
+ // Calculate max building height for camera positioning (when adaptCameraToBuildings is true)
1770
+ const maxBuildingHeight = useMemo(() => {
1771
+ if (!adaptCameraToBuildings) return 0;
1772
+ return Math.max(...cityData.buildings.map(b => b.dimensions[1]), 0);
1773
+ }, [adaptCameraToBuildings, cityData.buildings]);
1774
+
1728
1775
  const activeHighlights = useMemo(() => hasActiveHighlights(highlightLayers), [highlightLayers]);
1729
1776
 
1730
1777
  // Helper to check if a path is inside a directory
@@ -1956,7 +2003,7 @@ function CityScene({
1956
2003
 
1957
2004
  return (
1958
2005
  <>
1959
- <AnimatedCamera citySize={citySize} isFlat={growProgress === 0} focusTarget={focusTarget} />
2006
+ <AnimatedCamera citySize={citySize} isFlat={growProgress === 0} focusTarget={focusTarget} maxBuildingHeight={maxBuildingHeight} />
1960
2007
 
1961
2008
  <ambientLight intensity={1.2} />
1962
2009
  <hemisphereLight args={['#ddeeff', '#667788', 0.8]} position={[0, citySize, 0]} />
@@ -2002,6 +2049,7 @@ function CityScene({
2002
2049
  centerOffset={centerOffset}
2003
2050
  opacity={1}
2004
2051
  highlightColor={districtColor}
2052
+ growProgress={growProgress}
2005
2053
  />
2006
2054
  );
2007
2055
  })}
@@ -2093,6 +2141,8 @@ export interface FileCity3DProps {
2093
2141
  textColor?: string;
2094
2142
  /** Currently selected building (controlled by host) */
2095
2143
  selectedBuilding?: CityBuilding | null;
2144
+ /** When true, camera height adjusts based on tallest building when grown */
2145
+ adaptCameraToBuildings?: boolean;
2096
2146
  }
2097
2147
 
2098
2148
  /**
@@ -2126,6 +2176,7 @@ export function FileCity3D({
2126
2176
  backgroundColor = '#0f172a',
2127
2177
  textColor = '#94a3b8',
2128
2178
  selectedBuilding = null,
2179
+ adaptCameraToBuildings = false,
2129
2180
  }: FileCity3DProps) {
2130
2181
  const [hoveredBuilding, setHoveredBuilding] = useState<CityBuilding | null>(null);
2131
2182
  const [internalIsGrown, setInternalIsGrown] = useState(false);
@@ -2241,6 +2292,7 @@ export function FileCity3D({
2241
2292
  linearScale={linearScale}
2242
2293
  focusDirectory={focusDirectory}
2243
2294
  focusColor={focusColor}
2295
+ adaptCameraToBuildings={adaptCameraToBuildings}
2244
2296
  />
2245
2297
  </Canvas>
2246
2298
  <InfoPanel building={selectedBuilding || hoveredBuilding} />
@@ -2,7 +2,19 @@
2
2
  * FileCity3D - 3D visualization component
3
3
  */
4
4
 
5
- export { FileCity3D, resetCamera, moveCameraTo, setCameraTarget, getCameraTarget, rotateCameraTo, rotateCameraBy, tiltCameraTo, tiltCameraBy, getCameraPosition, getCameraAngle, getCameraTilt } from './FileCity3D';
5
+ export {
6
+ FileCity3D,
7
+ resetCamera,
8
+ getCameraAngle,
9
+ getCameraTarget,
10
+ getCameraTilt,
11
+ rotateCameraTo,
12
+ rotateCameraBy,
13
+ tiltCameraTo,
14
+ tiltCameraBy,
15
+ moveCameraTo,
16
+ setCameraTarget,
17
+ } from './FileCity3D';
6
18
  export type {
7
19
  FileCity3DProps,
8
20
  AnimationConfig,
@@ -13,5 +25,4 @@ export type {
13
25
  CityData,
14
26
  CityBuilding,
15
27
  CityDistrict,
16
- RotateOptions,
17
28
  } from './FileCity3D';
@@ -2001,3 +2001,134 @@ export const CameraControls: Story = {
2001
2001
  },
2002
2002
  },
2003
2003
  };
2004
+
2005
+ /**
2006
+ * 2D to 3D Transition - Tests the camera angle adjustment when transitioning from flat to grown
2007
+ * Camera starts top-down when flat, then animates to an angled view when buildings grow
2008
+ */
2009
+ const FlatToGrownTransitionTemplate: React.FC = () => {
2010
+ const [isGrown, setIsGrown] = React.useState(false);
2011
+ const [autoTransition, setAutoTransition] = React.useState(false);
2012
+ const [adaptCamera, setAdaptCamera] = React.useState(true);
2013
+
2014
+ // Auto-transition effect (simulates the CodeCityPanel behavior)
2015
+ React.useEffect(() => {
2016
+ if (!autoTransition) return;
2017
+ setIsGrown(false);
2018
+ const timer = setTimeout(() => {
2019
+ setIsGrown(true);
2020
+ }, 600);
2021
+ return () => clearTimeout(timer);
2022
+ }, [autoTransition]);
2023
+
2024
+ return (
2025
+ <div style={{ height: '100vh', display: 'flex', flexDirection: 'column', position: 'relative' }}>
2026
+ {/* 3D City */}
2027
+ <FileCity3D
2028
+ cityData={sampleCityData}
2029
+ height="100%"
2030
+ heightScaling="linear"
2031
+ linearScale={0.5}
2032
+ isGrown={isGrown}
2033
+ adaptCameraToBuildings={adaptCamera}
2034
+ animation={{
2035
+ startFlat: true,
2036
+ autoStartDelay: null, // External control
2037
+ staggerDelay: 15,
2038
+ tension: 120,
2039
+ friction: 14,
2040
+ }}
2041
+ showControls={true}
2042
+ />
2043
+
2044
+ {/* Control panel */}
2045
+ <div
2046
+ style={{
2047
+ position: 'absolute',
2048
+ bottom: 0,
2049
+ left: 0,
2050
+ right: 0,
2051
+ zIndex: 100,
2052
+ background: 'rgba(15, 23, 42, 0.95)',
2053
+ borderTop: '1px solid #334155',
2054
+ padding: '16px 24px',
2055
+ }}
2056
+ >
2057
+ <div style={{ display: 'flex', gap: '16px', alignItems: 'center', justifyContent: 'center' }}>
2058
+ <div style={{ color: '#94a3b8', fontSize: '14px' }}>
2059
+ State: <strong style={{ color: isGrown ? '#22c55e' : '#f59e0b' }}>{isGrown ? 'GROWN' : 'FLAT'}</strong>
2060
+ </div>
2061
+ <button
2062
+ onClick={() => setIsGrown(false)}
2063
+ style={{
2064
+ padding: '8px 16px',
2065
+ background: !isGrown ? '#3b82f6' : '#1e293b',
2066
+ color: 'white',
2067
+ border: '1px solid #334155',
2068
+ borderRadius: '6px',
2069
+ cursor: 'pointer',
2070
+ }}
2071
+ >
2072
+ Flatten (2D)
2073
+ </button>
2074
+ <button
2075
+ onClick={() => setIsGrown(true)}
2076
+ style={{
2077
+ padding: '8px 16px',
2078
+ background: isGrown ? '#3b82f6' : '#1e293b',
2079
+ color: 'white',
2080
+ border: '1px solid #334155',
2081
+ borderRadius: '6px',
2082
+ cursor: 'pointer',
2083
+ }}
2084
+ >
2085
+ Grow (3D)
2086
+ </button>
2087
+ <button
2088
+ onClick={() => {
2089
+ setAutoTransition(false);
2090
+ setTimeout(() => setAutoTransition(true), 10);
2091
+ }}
2092
+ style={{
2093
+ padding: '8px 16px',
2094
+ background: '#7c3aed',
2095
+ color: 'white',
2096
+ border: '1px solid #334155',
2097
+ borderRadius: '6px',
2098
+ cursor: 'pointer',
2099
+ }}
2100
+ >
2101
+ Simulate 2D→3D Transition
2102
+ </button>
2103
+ <label style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#94a3b8', fontSize: '14px', cursor: 'pointer' }}>
2104
+ <input
2105
+ type="checkbox"
2106
+ checked={adaptCamera}
2107
+ onChange={(e) => setAdaptCamera(e.target.checked)}
2108
+ style={{ cursor: 'pointer' }}
2109
+ />
2110
+ Adapt to building heights
2111
+ </label>
2112
+ </div>
2113
+ <div style={{ color: '#64748b', fontSize: '12px', textAlign: 'center', marginTop: '8px' }}>
2114
+ Camera should start top-down when flat, then animate to angled view when grown.
2115
+ {adaptCamera && ' Camera height will adjust based on tallest building.'}
2116
+ </div>
2117
+ </div>
2118
+ </div>
2119
+ );
2120
+ };
2121
+
2122
+ export const FlatToGrownTransition: Story = {
2123
+ render: () => <FlatToGrownTransitionTemplate />,
2124
+ parameters: {
2125
+ docs: {
2126
+ description: {
2127
+ story:
2128
+ 'Tests the camera angle adjustment when transitioning between flat (2D) and grown (3D) states. ' +
2129
+ 'The camera should start with a top-down view when buildings are flat, then animate to an angled view when buildings grow. ' +
2130
+ 'Use "Simulate 2D→3D Transition" to test the full transition sequence as it happens in CodeCityPanel.',
2131
+ },
2132
+ },
2133
+ },
2134
+ };