@principal-ai/file-city-react 0.3.0 → 0.4.0

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.
@@ -46,6 +46,11 @@ export interface ArchitectureMapHighlightLayersProps {
46
46
  onFileClick?: (path: string, type: 'file' | 'directory') => void;
47
47
  enableZoom?: boolean;
48
48
 
49
+ // Animated zoom to directory
50
+ zoomToPath?: string | null; // When set, animates zoom to frame this directory/file
51
+ onZoomComplete?: () => void; // Called when zoom animation completes
52
+ zoomAnimationSpeed?: number; // Animation easing factor (0-1), default 0.12
53
+
49
54
  // Display options
50
55
  fullSize?: boolean;
51
56
  showGrid?: boolean;
@@ -213,6 +218,9 @@ function ArchitectureMapHighlightLayersInner({
213
218
  onDirectorySelect,
214
219
  onFileClick,
215
220
  enableZoom = false,
221
+ zoomToPath = null,
222
+ onZoomComplete,
223
+ zoomAnimationSpeed = 0.12,
216
224
  fullSize = false,
217
225
  showGrid = false,
218
226
  showFileNames = false,
@@ -257,6 +265,16 @@ function ArchitectureMapHighlightLayersInner({
257
265
  hasMouseMoved: false,
258
266
  });
259
267
 
268
+ // Target zoom state for animated transitions
269
+ const [targetZoom, setTargetZoom] = useState<{
270
+ scale: number;
271
+ offsetX: number;
272
+ offsetY: number;
273
+ } | null>(null);
274
+
275
+ // Track the last zoomToPath to detect changes
276
+ const lastZoomToPathRef = useRef<string | null>(null);
277
+
260
278
  useEffect(() => {
261
279
  if (!enableZoom) {
262
280
  setZoomState(prev => ({
@@ -267,9 +285,53 @@ function ArchitectureMapHighlightLayersInner({
267
285
  isDragging: false,
268
286
  hasMouseMoved: false,
269
287
  }));
288
+ setTargetZoom(null);
270
289
  }
271
290
  }, [enableZoom]);
272
291
 
292
+ // Animation loop for smooth zoom transitions
293
+ useEffect(() => {
294
+ if (!targetZoom) return;
295
+
296
+ const animate = () => {
297
+ setZoomState(prev => {
298
+ const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
299
+ const easing = zoomAnimationSpeed;
300
+
301
+ const newScale = lerp(prev.scale, targetZoom.scale, easing);
302
+ const newOffsetX = lerp(prev.offsetX, targetZoom.offsetX, easing);
303
+ const newOffsetY = lerp(prev.offsetY, targetZoom.offsetY, easing);
304
+
305
+ // Check if close enough to target (within 0.1% for scale, 0.5px for offset)
306
+ const scaleDone = Math.abs(newScale - targetZoom.scale) < 0.001;
307
+ const offsetXDone = Math.abs(newOffsetX - targetZoom.offsetX) < 0.5;
308
+ const offsetYDone = Math.abs(newOffsetY - targetZoom.offsetY) < 0.5;
309
+
310
+ if (scaleDone && offsetXDone && offsetYDone) {
311
+ // Animation complete - set exact target values
312
+ setTargetZoom(null);
313
+ onZoomComplete?.();
314
+ return {
315
+ ...prev,
316
+ scale: targetZoom.scale,
317
+ offsetX: targetZoom.offsetX,
318
+ offsetY: targetZoom.offsetY,
319
+ };
320
+ }
321
+
322
+ return {
323
+ ...prev,
324
+ scale: newScale,
325
+ offsetX: newOffsetX,
326
+ offsetY: newOffsetY,
327
+ };
328
+ });
329
+ };
330
+
331
+ const frameId = requestAnimationFrame(animate);
332
+ return () => cancelAnimationFrame(frameId);
333
+ }, [targetZoom, zoomState, zoomAnimationSpeed, onZoomComplete]);
334
+
273
335
  const [hitTestCache, setHitTestCache] = useState<HitTestCache | null>(null);
274
336
 
275
337
  const calculateCanvasResolution = (
@@ -329,6 +391,120 @@ function ArchitectureMapHighlightLayersInner({
329
391
  return filteredCityData;
330
392
  }, [subdirectoryMode?.enabled, subdirectoryMode?.autoCenter, cityData, filteredCityData]);
331
393
 
394
+ // Handle zoomToPath changes - calculate target zoom to frame the specified path
395
+ useEffect(() => {
396
+ // Skip if zoom is not enabled or path hasn't changed
397
+ if (!enableZoom || zoomToPath === lastZoomToPathRef.current) {
398
+ return;
399
+ }
400
+
401
+ lastZoomToPathRef.current = zoomToPath;
402
+
403
+ // If zoomToPath is null, reset to default view
404
+ if (zoomToPath === null) {
405
+ setTargetZoom({
406
+ scale: 1,
407
+ offsetX: 0,
408
+ offsetY: 0,
409
+ });
410
+ return;
411
+ }
412
+
413
+ // Need city data and canvas ref to calculate zoom
414
+ if (!filteredCityData || !canvasRef.current) {
415
+ return;
416
+ }
417
+
418
+ // Get actual display size - the canvas is resized to match this during render
419
+ // so canvas coordinates = display coordinates
420
+ const displayWidth = canvasRef.current.clientWidth || canvasSize.width;
421
+ const displayHeight = canvasRef.current.clientHeight || canvasSize.height;
422
+
423
+ if (!displayWidth || !displayHeight) {
424
+ return;
425
+ }
426
+
427
+ // Find the target - first check districts, then buildings
428
+ const normalizedPath = zoomToPath.replace(/^\/+|\/+$/g, '');
429
+ const targetDistrict = filteredCityData.districts.find(
430
+ d => d.path === normalizedPath || d.path === zoomToPath,
431
+ );
432
+ const targetBuilding = filteredCityData.buildings.find(
433
+ b => b.path === normalizedPath || b.path === zoomToPath,
434
+ );
435
+
436
+ if (!targetDistrict && !targetBuilding) {
437
+ return;
438
+ }
439
+
440
+ // Get the bounds to zoom to
441
+ let targetBounds: { minX: number; maxX: number; minZ: number; maxZ: number };
442
+
443
+ if (targetDistrict) {
444
+ targetBounds = targetDistrict.worldBounds;
445
+ } else if (targetBuilding) {
446
+ // Create bounds around the building with some padding
447
+ const [width, , depth] = targetBuilding.dimensions;
448
+ const padding = Math.max(width, depth) * 2;
449
+ targetBounds = {
450
+ minX: targetBuilding.position.x - width / 2 - padding,
451
+ maxX: targetBuilding.position.x + width / 2 + padding,
452
+ minZ: targetBuilding.position.z - depth / 2 - padding,
453
+ maxZ: targetBuilding.position.z + depth / 2 + padding,
454
+ };
455
+ } else {
456
+ return;
457
+ }
458
+
459
+ // Use the same coordinate system as rendering
460
+ const coordinateData = canvasSizingData || filteredCityData;
461
+ const { scale: baseScale, offsetX: baseOffsetX, offsetZ: baseOffsetZ } = calculateScaleAndOffset(
462
+ coordinateData,
463
+ displayWidth,
464
+ displayHeight,
465
+ displayOptions.padding,
466
+ );
467
+
468
+ // Calculate target center in world coordinates
469
+ const targetCenterX = (targetBounds.minX + targetBounds.maxX) / 2;
470
+ const targetCenterZ = (targetBounds.minZ + targetBounds.maxZ) / 2;
471
+
472
+ // Calculate target size in screen coordinates (at base zoom)
473
+ const targetScreenWidth = (targetBounds.maxX - targetBounds.minX) * baseScale;
474
+ const targetScreenHeight = (targetBounds.maxZ - targetBounds.minZ) * baseScale;
475
+
476
+ // Calculate zoom scale to fit target with padding (80% of display)
477
+ const paddingFactor = 0.8;
478
+ const scaleToFitWidth = (displayWidth * paddingFactor) / targetScreenWidth;
479
+ const scaleToFitHeight = (displayHeight * paddingFactor) / targetScreenHeight;
480
+ const newZoomScale = Math.min(scaleToFitWidth, scaleToFitHeight, 5); // Cap at 5x
481
+
482
+ // Calculate the base screen position of target center (before zoom transform)
483
+ // This matches the worldToCanvas formula: ((x - bounds.minX) * scale + offsetX)
484
+ const baseScreenX = (targetCenterX - coordinateData.bounds.minX) * baseScale + baseOffsetX;
485
+ const baseScreenY = (targetCenterZ - coordinateData.bounds.minZ) * baseScale + baseOffsetZ;
486
+
487
+ // Calculate offset to center the target
488
+ // Full formula: screenPos = baseScreenPos * zoomScale + zoomOffset
489
+ // To center: displayCenter = baseScreen * zoomScale + zoomOffset
490
+ // Therefore: zoomOffset = displayCenter - baseScreen * zoomScale
491
+ const newOffsetX = displayWidth / 2 - baseScreenX * newZoomScale;
492
+ const newOffsetY = displayHeight / 2 - baseScreenY * newZoomScale;
493
+
494
+ setTargetZoom({
495
+ scale: newZoomScale,
496
+ offsetX: newOffsetX,
497
+ offsetY: newOffsetY,
498
+ });
499
+ }, [
500
+ zoomToPath,
501
+ enableZoom,
502
+ filteredCityData,
503
+ canvasSizingData,
504
+ canvasSize,
505
+ displayOptions.padding,
506
+ ]);
507
+
332
508
  // Build hit test cache with spatial indexing
333
509
  const buildHitTestCache = useCallback(
334
510
  (
@@ -1,3 +1,4 @@
1
+ import React from 'react';
1
2
  import type { Meta, StoryObj } from '@storybook/react';
2
3
  import {
3
4
  CodeCityBuilderWithGrid,
@@ -24,7 +25,7 @@ interface CodebaseView {
24
25
  name: string;
25
26
  description: string;
26
27
  overviewPath: string;
27
- cells: {
28
+ referenceGroups: {
28
29
  [key: string]: {
29
30
  files: string[];
30
31
  coordinates: [number, number];
@@ -144,7 +145,7 @@ export const DefaultsTest: Story = {
144
145
  name: 'Test Default UI Settings',
145
146
  description: 'Tests default UI metadata settings when none are provided',
146
147
  overviewPath: 'README.md',
147
- cells: {
148
+ referenceGroups: {
148
149
  Source: {
149
150
  files: ['src', 'lib'],
150
151
  coordinates: [0, 0],
@@ -181,7 +182,7 @@ export const TwoByTwoGrid: Story = {
181
182
  name: 'Two by Two Grid Layout',
182
183
  description: 'A 2x2 grid layout organizing code into four quadrants',
183
184
  overviewPath: 'README.md',
184
- cells: {
185
+ referenceGroups: {
185
186
  Source: {
186
187
  files: ['src', 'lib'],
187
188
  coordinates: [0, 0],
@@ -227,7 +228,7 @@ export const ThreeByThreeGrid: Story = {
227
228
  name: 'Three by Three Grid Layout',
228
229
  description: 'A 3x3 grid layout for more granular organization',
229
230
  overviewPath: 'README.md',
230
- cells: {
231
+ referenceGroups: {
231
232
  Source: {
232
233
  files: ['src'],
233
234
  coordinates: [0, 0],
@@ -281,7 +282,7 @@ export const SparseGrid: Story = {
281
282
  name: 'Sparse Grid Layout',
282
283
  description: 'A grid with intentional gaps for visual organization',
283
284
  overviewPath: 'README.md',
284
- cells: {
285
+ referenceGroups: {
285
286
  Source: {
286
287
  files: ['src', 'lib'],
287
288
  coordinates: [0, 0],
@@ -315,7 +316,7 @@ export const GridWithHighlights: Story = {
315
316
  name: 'Grid with Highlights',
316
317
  description: 'Grid layout with custom highlight layers',
317
318
  overviewPath: 'README.md',
318
- cells: {
319
+ referenceGroups: {
319
320
  Source: {
320
321
  files: ['src', 'lib'],
321
322
  coordinates: [0, 0],
@@ -371,7 +372,7 @@ export const LargeGrid: Story = {
371
372
  name: 'Large 5x5 Grid',
372
373
  description: 'A large grid for complex projects',
373
374
  overviewPath: 'README.md',
374
- cells: {
375
+ referenceGroups: {
375
376
  'Core Source': { files: ['src/core'], coordinates: [0, 0] },
376
377
  Components: { files: ['src/components'], coordinates: [1, 0] },
377
378
  Utils: { files: ['src/utils'], coordinates: [2, 0] },
@@ -1,5 +1,5 @@
1
+ import React, { useState } from 'react';
1
2
  import type { Meta, StoryObj } from '@storybook/react';
2
- import { useState } from 'react';
3
3
 
4
4
  import { ArchitectureMapHighlightLayers } from '../components/ArchitectureMapHighlightLayers';
5
5
  import { HighlightLayer } from '../render/client/drawLayeredBuildings';
@@ -310,3 +310,196 @@ export const WithBorderRadius: Story = {
310
310
  enableZoom: true,
311
311
  },
312
312
  };
313
+
314
+ // Story with animated zoom to directory
315
+ export const AnimatedZoomToDirectory: Story = {
316
+ render: function RenderAnimatedZoom() {
317
+ const [zoomToPath, setZoomToPath] = useState<string | null>(null);
318
+ const [isAnimating, setIsAnimating] = useState(false);
319
+ const cityData = createSampleCityData();
320
+
321
+ // Get unique top-level directories for navigation buttons
322
+ const topLevelDirs = Array.from(
323
+ new Set(
324
+ cityData.districts
325
+ .map(d => d.path.split('/')[0])
326
+ .filter(Boolean),
327
+ ),
328
+ ).sort();
329
+
330
+ const handleZoomTo = (path: string | null) => {
331
+ setIsAnimating(true);
332
+ setZoomToPath(path);
333
+ };
334
+
335
+ const handleZoomComplete = () => {
336
+ setIsAnimating(false);
337
+ };
338
+
339
+ // Create highlight layer for the focused directory
340
+ const highlightLayers: HighlightLayer[] = zoomToPath
341
+ ? [
342
+ {
343
+ id: 'zoom-focus',
344
+ name: 'Zoom Focus',
345
+ enabled: true,
346
+ color: '#3b82f6',
347
+ priority: 1,
348
+ items: [{ path: zoomToPath, type: 'directory' }],
349
+ },
350
+ ]
351
+ : [];
352
+
353
+ return (
354
+ <div style={{ position: 'relative', width: '100%', height: '100%' }}>
355
+ <ArchitectureMapHighlightLayers
356
+ cityData={cityData}
357
+ fullSize={true}
358
+ enableZoom={true}
359
+ zoomToPath={zoomToPath}
360
+ onZoomComplete={handleZoomComplete}
361
+ zoomAnimationSpeed={0.1}
362
+ highlightLayers={highlightLayers}
363
+ onFileClick={(path, type) => {
364
+ if (type === 'directory') {
365
+ handleZoomTo(path);
366
+ }
367
+ }}
368
+ />
369
+ {/* Navigation Controls */}
370
+ <div
371
+ style={{
372
+ position: 'absolute',
373
+ top: 20,
374
+ left: 20,
375
+ zIndex: 100,
376
+ display: 'flex',
377
+ flexDirection: 'column',
378
+ gap: '8px',
379
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
380
+ padding: '16px',
381
+ borderRadius: '8px',
382
+ maxWidth: '200px',
383
+ }}
384
+ >
385
+ <div
386
+ style={{
387
+ color: 'white',
388
+ fontFamily: 'monospace',
389
+ fontSize: '12px',
390
+ marginBottom: '8px',
391
+ }}
392
+ >
393
+ {isAnimating ? '🎬 Animating...' : '📍 Click to zoom'}
394
+ </div>
395
+
396
+ {/* Reset button */}
397
+ <button
398
+ onClick={() => handleZoomTo(null)}
399
+ disabled={isAnimating}
400
+ style={{
401
+ padding: '8px 12px',
402
+ backgroundColor: zoomToPath === null ? '#3b82f6' : '#374151',
403
+ color: 'white',
404
+ border: 'none',
405
+ borderRadius: '4px',
406
+ cursor: isAnimating ? 'not-allowed' : 'pointer',
407
+ fontFamily: 'monospace',
408
+ fontSize: '12px',
409
+ opacity: isAnimating ? 0.6 : 1,
410
+ }}
411
+ >
412
+ 🏠 Reset View
413
+ </button>
414
+
415
+ {/* Directory buttons */}
416
+ {topLevelDirs.map(dir => (
417
+ <button
418
+ key={dir}
419
+ onClick={() => handleZoomTo(dir)}
420
+ disabled={isAnimating}
421
+ style={{
422
+ padding: '8px 12px',
423
+ backgroundColor: zoomToPath === dir ? '#3b82f6' : '#374151',
424
+ color: 'white',
425
+ border: 'none',
426
+ borderRadius: '4px',
427
+ cursor: isAnimating ? 'not-allowed' : 'pointer',
428
+ fontFamily: 'monospace',
429
+ fontSize: '12px',
430
+ textAlign: 'left',
431
+ opacity: isAnimating ? 0.6 : 1,
432
+ }}
433
+ >
434
+ 📁 {dir}
435
+ </button>
436
+ ))}
437
+
438
+ {/* Some nested directories */}
439
+ <div
440
+ style={{
441
+ borderTop: '1px solid #4b5563',
442
+ paddingTop: '8px',
443
+ marginTop: '4px',
444
+ }}
445
+ >
446
+ <div
447
+ style={{
448
+ color: '#9ca3af',
449
+ fontFamily: 'monospace',
450
+ fontSize: '10px',
451
+ marginBottom: '4px',
452
+ }}
453
+ >
454
+ Nested:
455
+ </div>
456
+ {['src/components', 'src/utils', 'tests/unit'].map(dir => (
457
+ <button
458
+ key={dir}
459
+ onClick={() => handleZoomTo(dir)}
460
+ disabled={isAnimating}
461
+ style={{
462
+ padding: '6px 10px',
463
+ backgroundColor: zoomToPath === dir ? '#3b82f6' : '#1f2937',
464
+ color: 'white',
465
+ border: 'none',
466
+ borderRadius: '4px',
467
+ cursor: isAnimating ? 'not-allowed' : 'pointer',
468
+ fontFamily: 'monospace',
469
+ fontSize: '11px',
470
+ textAlign: 'left',
471
+ marginBottom: '4px',
472
+ width: '100%',
473
+ opacity: isAnimating ? 0.6 : 1,
474
+ }}
475
+ >
476
+ 📂 {dir}
477
+ </button>
478
+ ))}
479
+ </div>
480
+ </div>
481
+
482
+ {/* Current zoom info */}
483
+ <div
484
+ style={{
485
+ position: 'absolute',
486
+ bottom: 20,
487
+ left: 20,
488
+ zIndex: 100,
489
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
490
+ padding: '12px',
491
+ borderRadius: '8px',
492
+ color: 'white',
493
+ fontFamily: 'monospace',
494
+ fontSize: '11px',
495
+ }}
496
+ >
497
+ <div>Current: {zoomToPath || '(root)'}</div>
498
+ <div style={{ color: '#9ca3af', marginTop: '4px' }}>
499
+ 💡 Click directories in the map or use buttons above
500
+ </div>
501
+ </div>
502
+ </div>
503
+ );
504
+ },
505
+ };