@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.
- package/dist/components/FileCity3D/FileCity3D.d.ts +3 -26
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +278 -191
- package/dist/components/FileCity3D/index.d.ts +1 -1
- package/dist/components/FileCity3D/index.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/FileCity3D/FileCity3D.tsx +402 -232
- package/src/components/FileCity3D/index.ts +2 -1
- package/src/index.ts +0 -2
- package/src/stories/2D3DComparison.stories.tsx +2 -2
- package/src/stories/FileCity3D.stories.tsx +65 -6
|
@@ -155,21 +155,48 @@ function getColorForFile(building) {
|
|
|
155
155
|
return getConfigForFile(building).color;
|
|
156
156
|
}
|
|
157
157
|
/**
|
|
158
|
-
*
|
|
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
|
|
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
|
-
|
|
169
|
+
isMatch = true;
|
|
170
|
+
}
|
|
171
|
+
else if (item.type === 'directory' && (path === item.path || path.startsWith(item.path + '/'))) {
|
|
172
|
+
isMatch = true;
|
|
167
173
|
}
|
|
168
|
-
if (
|
|
169
|
-
|
|
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
|
-
//
|
|
317
|
-
const
|
|
318
|
-
const
|
|
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
|
-
|
|
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
|
|
675
|
-
const fovRad = (
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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:
|
|
731
|
-
z:
|
|
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
|
-
}, [
|
|
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
|
|
898
|
+
// Initialize with correct 2D position from the start
|
|
742
899
|
const [{ camX, camY, camZ, lookX, lookY, lookZ }, api] = useSpring(() => {
|
|
743
|
-
|
|
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:
|
|
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
|
|
801
|
-
//
|
|
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]
|
|
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
|
|
960
|
+
if (!hasAppliedInitial.current) {
|
|
808
961
|
console.log('[useEffect] Skipping - not initialized yet');
|
|
809
962
|
return;
|
|
810
963
|
}
|
|
811
|
-
// Only animate if isFlat
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
868
|
-
//
|
|
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
|
|
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
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
:
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
-
//
|
|
917
|
-
if (
|
|
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.
|
|
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.
|
|
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,
|
|
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,
|
|
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,
|
|
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';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|