@principal-ai/file-city-react 0.5.7 → 0.5.8

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.
@@ -1 +1 @@
1
- {"version":3,"file":"FileCity3D.d.ts","sourceRoot":"","sources":["../../../src/components/FileCity3D/FileCity3D.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"FileCity3D.d.ts","sourceRoot":"","sources":["../../../src/components/FileCity3D/FileCity3D.tsx"],"names":[],"mappings":"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"}
@@ -95,6 +95,66 @@ function calculateBuildingHeight(building, scaling = 'logarithmic', linearScale
95
95
  // Fallback to dimension height if no metrics available
96
96
  return building.dimensions[1];
97
97
  }
98
+ // ============================================================================
99
+ // Icon Texture Generation - Lucide icon SVG paths
100
+ // ============================================================================
101
+ // Lucide icon paths (from lucide.dev)
102
+ const LUCIDE_ICONS = {
103
+ Atom: '<circle cx="12" cy="12" r="1"/><path d="M20.2 20.2c2.04-2.03.02-7.36-4.5-11.9-4.54-4.52-9.87-6.54-11.9-4.5-2.04 2.03-.02 7.36 4.5 11.9 4.54 4.52 9.87 6.54 11.9 4.5Z"/><path d="M15.7 15.7c4.52-4.54 6.54-9.87 4.5-11.9-2.03-2.04-7.36-.02-11.9 4.5-4.52 4.54-6.54 9.87-4.5 11.9 2.03 2.04 7.36.02 11.9-4.5Z"/>',
104
+ Lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
105
+ EyeOff: '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>',
106
+ Key: '<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/>',
107
+ GitBranch: '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
108
+ TestTube: '<path d="M14.5 2v17.5c0 1.4-1.1 2.5-2.5 2.5c-1.4 0-2.5-1.1-2.5-2.5V2"/><path d="M8.5 2h7"/><path d="M14.5 16h-5"/>',
109
+ FlaskConical: '<path d="M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2"/><path d="M8.5 2h7"/><path d="M7 16h10"/>',
110
+ BookText: '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/><path d="M8 11h8"/><path d="M8 7h6"/>',
111
+ BookOpen: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
112
+ ScrollText: '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
113
+ Settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
114
+ Home: '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
115
+ };
116
+ // Cache for icon textures
117
+ const iconTextureCache = new Map();
118
+ /**
119
+ * Generate a texture from a Lucide icon
120
+ */
121
+ function getIconTexture(iconName, color = '#ffffff') {
122
+ const cacheKey = `${iconName}-${color}`;
123
+ if (iconTextureCache.has(cacheKey)) {
124
+ return iconTextureCache.get(cacheKey);
125
+ }
126
+ const iconPath = LUCIDE_ICONS[iconName];
127
+ if (!iconPath) {
128
+ // Icon not in our subset, skip silently
129
+ return null;
130
+ }
131
+ const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">${iconPath}</svg>`;
132
+ // Create canvas and draw SVG
133
+ const canvas = document.createElement('canvas');
134
+ canvas.width = 128;
135
+ canvas.height = 128;
136
+ const ctx = canvas.getContext('2d');
137
+ if (!ctx)
138
+ return null;
139
+ // Create image from SVG
140
+ const img = new Image();
141
+ const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
142
+ const url = URL.createObjectURL(svgBlob);
143
+ // Create texture (will update when image loads)
144
+ const texture = new THREE.Texture(canvas);
145
+ texture.colorSpace = THREE.SRGBColorSpace;
146
+ img.onload = () => {
147
+ // Clear canvas with transparent background
148
+ ctx.clearRect(0, 0, 128, 128);
149
+ // Draw centered icon
150
+ ctx.drawImage(img, 32, 32, 64, 64);
151
+ texture.needsUpdate = true;
152
+ URL.revokeObjectURL(url);
153
+ };
154
+ img.src = url;
155
+ iconTextureCache.set(cacheKey, texture);
156
+ return texture;
157
+ }
98
158
  // Get full file config from centralized file-city-builder lookup
99
159
  function getConfigForFile(building) {
100
160
  if (building.color) {
@@ -135,6 +195,61 @@ function hasActiveHighlights(layers) {
135
195
  }
136
196
  // Animated RoundedBox wrapper
137
197
  const AnimatedRoundedBox = animated(RoundedBox);
198
+ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springDuration }) {
199
+ const meshRef = useRef(null);
200
+ const startTimeRef = useRef(null);
201
+ const tempObject = useMemo(() => new THREE.Object3D(), []);
202
+ // 4 corner edges per building
203
+ const numEdges = buildings.length * 4;
204
+ // Pre-compute edge data
205
+ const edgeData = useMemo(() => {
206
+ return buildings.flatMap((data) => {
207
+ const { width, depth, x, z, targetHeight, staggerDelayMs } = data;
208
+ const halfW = width / 2;
209
+ const halfD = depth / 2;
210
+ return [
211
+ { x: x - halfW, z: z - halfD, targetHeight, staggerDelayMs },
212
+ { x: x + halfW, z: z - halfD, targetHeight, staggerDelayMs },
213
+ { x: x - halfW, z: z + halfD, targetHeight, staggerDelayMs },
214
+ { x: x + halfW, z: z + halfD, targetHeight, staggerDelayMs },
215
+ ];
216
+ });
217
+ }, [buildings]);
218
+ // Animate edges
219
+ useFrame(({ clock }) => {
220
+ if (!meshRef.current || edgeData.length === 0)
221
+ return;
222
+ if (startTimeRef.current === null && growProgress > 0) {
223
+ startTimeRef.current = clock.elapsedTime * 1000;
224
+ }
225
+ const currentTime = clock.elapsedTime * 1000;
226
+ const animStartTime = startTimeRef.current ?? currentTime;
227
+ edgeData.forEach((edge, idx) => {
228
+ const { x, z, targetHeight, staggerDelayMs } = edge;
229
+ // Calculate per-building animation progress
230
+ const elapsed = currentTime - animStartTime - staggerDelayMs;
231
+ let animProgress = growProgress;
232
+ if (growProgress > 0 && elapsed >= 0) {
233
+ const t = Math.min(elapsed / springDuration, 1);
234
+ const eased = 1 - Math.pow(1 - t, 3);
235
+ animProgress = eased * growProgress;
236
+ }
237
+ else if (growProgress > 0 && elapsed < 0) {
238
+ animProgress = 0;
239
+ }
240
+ const height = animProgress * targetHeight + minHeight;
241
+ const yPosition = height / 2 + baseOffset;
242
+ tempObject.position.set(x, yPosition, z);
243
+ tempObject.scale.set(0.3, height, 0.3); // Thin box for edge
244
+ tempObject.updateMatrix();
245
+ meshRef.current.setMatrixAt(idx, tempObject.matrix);
246
+ });
247
+ meshRef.current.instanceMatrix.needsUpdate = true;
248
+ });
249
+ if (numEdges === 0)
250
+ return null;
251
+ 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 })] }));
252
+ }
138
253
  function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hoveredIndex, growProgress, animationConfig, highlightLayers, isolationMode, hasActiveHighlights, dimOpacity, heightScaling, linearScale, staggerIndices, }) {
139
254
  const meshRef = useRef(null);
140
255
  const startTimeRef = useRef(null);
@@ -279,7 +394,92 @@ function InstancedBuildings({ buildings, centerOffset, onHover, onClick, hovered
279
394
  }, [visibleBuildings, onClick]);
280
395
  if (visibleBuildings.length === 0)
281
396
  return null;
282
- return (_jsxs("instancedMesh", { ref: meshRef, args: [undefined, undefined, visibleBuildings.length], onPointerMove: handlePointerMove, onPointerOut: handlePointerOut, onClick: handleClick, frustumCulled: false, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx("meshStandardMaterial", { metalness: 0.1, roughness: 0.35 })] }));
397
+ return (_jsxs("group", { children: [_jsxs("instancedMesh", { ref: meshRef, args: [undefined, undefined, visibleBuildings.length], onPointerMove: handlePointerMove, onPointerOut: handlePointerOut, onClick: handleClick, frustumCulled: false, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx("meshStandardMaterial", { metalness: 0.1, roughness: 0.35 })] }), _jsx(BuildingEdges, { buildings: visibleBuildings, growProgress: growProgress, minHeight: minHeight, baseOffset: baseOffset, springDuration: springDuration })] }));
398
+ }
399
+ function AnimatedIcon({ x, z, targetHeight, iconSize, texture, opacity, growProgress, staggerDelayMs, springDuration, }) {
400
+ const spriteRef = useRef(null);
401
+ const startTimeRef = useRef(null);
402
+ const materialRef = useRef(null);
403
+ useFrame(({ clock }) => {
404
+ if (!spriteRef.current)
405
+ return;
406
+ if (startTimeRef.current === null && growProgress > 0) {
407
+ startTimeRef.current = clock.elapsedTime * 1000;
408
+ }
409
+ const currentTime = clock.elapsedTime * 1000;
410
+ const animStartTime = startTimeRef.current ?? currentTime;
411
+ // Calculate per-icon animation progress
412
+ const elapsed = currentTime - animStartTime - staggerDelayMs;
413
+ let animProgress = growProgress;
414
+ if (growProgress > 0 && elapsed >= 0) {
415
+ const t = Math.min(elapsed / springDuration, 1);
416
+ const eased = 1 - Math.pow(1 - t, 3);
417
+ animProgress = eased * growProgress;
418
+ }
419
+ else if (growProgress > 0 && elapsed < 0) {
420
+ animProgress = 0;
421
+ }
422
+ const minHeight = 0.3;
423
+ const baseOffset = 0.2;
424
+ const height = animProgress * targetHeight + minHeight;
425
+ const buildingTop = height + baseOffset;
426
+ const yPosition = buildingTop + iconSize / 2 + 2;
427
+ spriteRef.current.position.y = yPosition;
428
+ if (materialRef.current) {
429
+ materialRef.current.opacity = opacity * animProgress;
430
+ }
431
+ });
432
+ return (_jsx("sprite", { ref: spriteRef, position: [x, 0, z], scale: [iconSize, iconSize, 1], children: _jsx("spriteMaterial", { ref: materialRef, map: texture, transparent: true, opacity: 0, depthTest: true, depthWrite: false }) }));
433
+ }
434
+ function BuildingIcons({ buildings, centerOffset, growProgress, heightScaling, linearScale, highlightLayers, isolationMode, hasActiveHighlights, staggerIndices, springDuration, staggerDelay, }) {
435
+ // Pre-compute buildings with icons
436
+ const buildingsWithIcons = useMemo(() => {
437
+ return buildings
438
+ .map((building, index) => {
439
+ const config = getConfigForFile(building);
440
+ if (!config.icon)
441
+ return null;
442
+ const highlight = getHighlightForPath(building.path, highlightLayers);
443
+ const isHighlighted = highlight !== null;
444
+ const shouldDim = hasActiveHighlights && !isHighlighted;
445
+ const shouldHide = shouldDim && isolationMode === 'hide';
446
+ const shouldCollapse = shouldDim && isolationMode === 'collapse';
447
+ if (shouldHide)
448
+ return null;
449
+ const fullHeight = calculateBuildingHeight(building, heightScaling, linearScale);
450
+ const targetHeight = shouldCollapse ? 0.5 : fullHeight;
451
+ const x = building.position.x - centerOffset.x;
452
+ const z = building.position.z - centerOffset.z;
453
+ const staggerIndex = staggerIndices[index] ?? index;
454
+ const staggerDelayMs = staggerDelay * staggerIndex;
455
+ return {
456
+ building,
457
+ config,
458
+ x,
459
+ z,
460
+ targetHeight,
461
+ shouldDim,
462
+ staggerDelayMs,
463
+ };
464
+ })
465
+ .filter(Boolean);
466
+ }, [buildings, centerOffset, highlightLayers, isolationMode, hasActiveHighlights, heightScaling, linearScale, staggerIndices, staggerDelay]);
467
+ // Don't render if no progress yet
468
+ if (growProgress < 0.1)
469
+ return null;
470
+ return (_jsx(_Fragment, { children: buildingsWithIcons.map(({ building, config, x, z, targetHeight, shouldDim, staggerDelayMs }) => {
471
+ const icon = config.icon;
472
+ const texture = getIconTexture(icon.name, icon.color || '#ffffff');
473
+ if (!texture)
474
+ return null;
475
+ // Icon size based on building dimensions
476
+ const [width] = building.dimensions;
477
+ const baseSize = Math.max(width * 0.8, 6);
478
+ const heightBoost = Math.min(targetHeight / 20, 3);
479
+ const iconSize = (baseSize + heightBoost) * (icon.size || 1);
480
+ const opacity = shouldDim && isolationMode === 'transparent' ? 0.3 : 1;
481
+ return (_jsx(AnimatedIcon, { x: x, z: z, targetHeight: targetHeight, iconSize: iconSize, texture: texture, opacity: opacity, growProgress: growProgress, staggerDelayMs: staggerDelayMs, springDuration: springDuration }, building.path));
482
+ }) }));
283
483
  }
284
484
  function DistrictFloor({ district, centerOffset, opacity, }) {
285
485
  const { worldBounds } = district;
@@ -398,7 +598,11 @@ function CityScene({ cityData, onBuildingHover, onBuildingClick, hoveredBuilding
398
598
  return null;
399
599
  return cityData.buildings.findIndex((b) => b.path === hoveredBuilding.path);
400
600
  }, [hoveredBuilding, cityData.buildings]);
401
- return (_jsxs(_Fragment, { children: [_jsx(AnimatedCamera, { citySize: citySize, isFlat: growProgress === 0 }), _jsx("ambientLight", { intensity: 0.4 }), _jsx("directionalLight", { position: [citySize, citySize, citySize * 0.5], intensity: 1, castShadow: true, "shadow-mapSize": [2048, 2048] }), _jsx("directionalLight", { position: [-citySize * 0.5, citySize * 0.5, -citySize * 0.5], intensity: 0.3 }), cityData.districts.map((district) => (_jsx(DistrictFloor, { district: district, centerOffset: centerOffset, opacity: 1 }, district.path))), _jsx(InstancedBuildings, { buildings: cityData.buildings, centerOffset: centerOffset, onHover: onBuildingHover, onClick: onBuildingClick, hoveredIndex: hoveredIndex, growProgress: growProgress, animationConfig: animationConfig, highlightLayers: highlightLayers, isolationMode: isolationMode, hasActiveHighlights: activeHighlights, dimOpacity: dimOpacity, heightScaling: heightScaling, linearScale: linearScale, staggerIndices: staggerIndices })] }));
601
+ // Calculate spring duration for animation sync
602
+ const tension = animationConfig.tension || 120;
603
+ const friction = animationConfig.friction || 14;
604
+ const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
605
+ return (_jsxs(_Fragment, { children: [_jsx(AnimatedCamera, { citySize: citySize, isFlat: growProgress === 0 }), _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) => (_jsx(DistrictFloor, { district: district, centerOffset: centerOffset, opacity: 1 }, district.path))), _jsx(InstancedBuildings, { buildings: cityData.buildings, centerOffset: centerOffset, onHover: onBuildingHover, onClick: onBuildingClick, hoveredIndex: hoveredIndex, growProgress: growProgress, animationConfig: animationConfig, highlightLayers: highlightLayers, isolationMode: isolationMode, hasActiveHighlights: activeHighlights, dimOpacity: dimOpacity, heightScaling: heightScaling, linearScale: linearScale, staggerIndices: staggerIndices }), _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 })] }));
402
606
  }
403
607
  /**
404
608
  * FileCity3D - 3D visualization of codebase structure
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principal-ai/file-city-react",
3
- "version": "0.5.7",
3
+ "version": "0.5.8",
4
4
  "type": "module",
5
5
  "description": "React components for File City visualization",
6
6
  "main": "dist/index.js",
@@ -186,6 +186,80 @@ function calculateBuildingHeight(
186
186
  return building.dimensions[1];
187
187
  }
188
188
 
189
+ // ============================================================================
190
+ // Icon Texture Generation - Lucide icon SVG paths
191
+ // ============================================================================
192
+
193
+ // Lucide icon paths (from lucide.dev)
194
+ const LUCIDE_ICONS: Record<string, string> = {
195
+ Atom: '<circle cx="12" cy="12" r="1"/><path d="M20.2 20.2c2.04-2.03.02-7.36-4.5-11.9-4.54-4.52-9.87-6.54-11.9-4.5-2.04 2.03-.02 7.36 4.5 11.9 4.54 4.52 9.87 6.54 11.9 4.5Z"/><path d="M15.7 15.7c4.52-4.54 6.54-9.87 4.5-11.9-2.03-2.04-7.36-.02-11.9 4.5-4.52 4.54-6.54 9.87-4.5 11.9 2.03 2.04 7.36.02 11.9-4.5Z"/>',
196
+ Lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
197
+ EyeOff: '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>',
198
+ Key: '<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/>',
199
+ GitBranch: '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
200
+ TestTube: '<path d="M14.5 2v17.5c0 1.4-1.1 2.5-2.5 2.5c-1.4 0-2.5-1.1-2.5-2.5V2"/><path d="M8.5 2h7"/><path d="M14.5 16h-5"/>',
201
+ FlaskConical: '<path d="M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2"/><path d="M8.5 2h7"/><path d="M7 16h10"/>',
202
+ BookText: '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/><path d="M8 11h8"/><path d="M8 7h6"/>',
203
+ BookOpen: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
204
+ ScrollText: '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
205
+ Settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
206
+ Home: '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
207
+ };
208
+
209
+ // Cache for icon textures
210
+ const iconTextureCache = new Map<string, THREE.Texture>();
211
+
212
+ /**
213
+ * Generate a texture from a Lucide icon
214
+ */
215
+ function getIconTexture(iconName: string, color: string = '#ffffff'): THREE.Texture | null {
216
+ const cacheKey = `${iconName}-${color}`;
217
+
218
+ if (iconTextureCache.has(cacheKey)) {
219
+ return iconTextureCache.get(cacheKey)!;
220
+ }
221
+
222
+ const iconPath = LUCIDE_ICONS[iconName];
223
+ if (!iconPath) {
224
+ // Icon not in our subset, skip silently
225
+ return null;
226
+ }
227
+
228
+ const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">${iconPath}</svg>`;
229
+
230
+ // Create canvas and draw SVG
231
+ const canvas = document.createElement('canvas');
232
+ canvas.width = 128;
233
+ canvas.height = 128;
234
+ const ctx = canvas.getContext('2d');
235
+ if (!ctx) return null;
236
+
237
+ // Create image from SVG
238
+ const img = new Image();
239
+ const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
240
+ const url = URL.createObjectURL(svgBlob);
241
+
242
+ // Create texture (will update when image loads)
243
+ const texture = new THREE.Texture(canvas);
244
+ texture.colorSpace = THREE.SRGBColorSpace;
245
+
246
+ img.onload = () => {
247
+ // Clear canvas with transparent background
248
+ ctx.clearRect(0, 0, 128, 128);
249
+
250
+ // Draw centered icon
251
+ ctx.drawImage(img, 32, 32, 64, 64);
252
+
253
+ texture.needsUpdate = true;
254
+ URL.revokeObjectURL(url);
255
+ };
256
+
257
+ img.src = url;
258
+
259
+ iconTextureCache.set(cacheKey, texture);
260
+ return texture;
261
+ }
262
+
189
263
  // Get full file config from centralized file-city-builder lookup
190
264
  function getConfigForFile(building: CityBuilding): FileConfigResult {
191
265
  if (building.color) {
@@ -236,6 +310,100 @@ function hasActiveHighlights(layers: HighlightLayer[]): boolean {
236
310
  // Animated RoundedBox wrapper
237
311
  const AnimatedRoundedBox = animated(RoundedBox);
238
312
 
313
+ // ============================================================================
314
+ // Building Edges - Batched edge rendering for performance
315
+ // ============================================================================
316
+
317
+ interface BuildingEdgeData {
318
+ width: number;
319
+ depth: number;
320
+ targetHeight: number;
321
+ x: number;
322
+ z: number;
323
+ staggerDelayMs: number;
324
+ }
325
+
326
+ interface BuildingEdgesProps {
327
+ buildings: BuildingEdgeData[];
328
+ growProgress: number;
329
+ minHeight: number;
330
+ baseOffset: number;
331
+ springDuration: number;
332
+ }
333
+
334
+ function BuildingEdges({ buildings, growProgress, minHeight, baseOffset, springDuration }: BuildingEdgesProps) {
335
+ const meshRef = useRef<THREE.InstancedMesh>(null);
336
+ const startTimeRef = useRef<number | null>(null);
337
+ const tempObject = useMemo(() => new THREE.Object3D(), []);
338
+
339
+ // 4 corner edges per building
340
+ const numEdges = buildings.length * 4;
341
+
342
+ // Pre-compute edge data
343
+ const edgeData = useMemo(() => {
344
+ return buildings.flatMap((data) => {
345
+ const { width, depth, x, z, targetHeight, staggerDelayMs } = data;
346
+ const halfW = width / 2;
347
+ const halfD = depth / 2;
348
+
349
+ return [
350
+ { x: x - halfW, z: z - halfD, targetHeight, staggerDelayMs },
351
+ { x: x + halfW, z: z - halfD, targetHeight, staggerDelayMs },
352
+ { x: x - halfW, z: z + halfD, targetHeight, staggerDelayMs },
353
+ { x: x + halfW, z: z + halfD, targetHeight, staggerDelayMs },
354
+ ];
355
+ });
356
+ }, [buildings]);
357
+
358
+ // Animate edges
359
+ useFrame(({ clock }) => {
360
+ if (!meshRef.current || edgeData.length === 0) return;
361
+
362
+ if (startTimeRef.current === null && growProgress > 0) {
363
+ startTimeRef.current = clock.elapsedTime * 1000;
364
+ }
365
+
366
+ const currentTime = clock.elapsedTime * 1000;
367
+ const animStartTime = startTimeRef.current ?? currentTime;
368
+
369
+ edgeData.forEach((edge, idx) => {
370
+ const { x, z, targetHeight, staggerDelayMs } = edge;
371
+
372
+ // Calculate per-building animation progress
373
+ const elapsed = currentTime - animStartTime - staggerDelayMs;
374
+ let animProgress = growProgress;
375
+
376
+ if (growProgress > 0 && elapsed >= 0) {
377
+ const t = Math.min(elapsed / springDuration, 1);
378
+ const eased = 1 - Math.pow(1 - t, 3);
379
+ animProgress = eased * growProgress;
380
+ } else if (growProgress > 0 && elapsed < 0) {
381
+ animProgress = 0;
382
+ }
383
+
384
+ const height = animProgress * targetHeight + minHeight;
385
+ const yPosition = height / 2 + baseOffset;
386
+
387
+ tempObject.position.set(x, yPosition, z);
388
+ tempObject.scale.set(0.3, height, 0.3); // Thin box for edge
389
+ tempObject.updateMatrix();
390
+
391
+ meshRef.current!.setMatrixAt(idx, tempObject.matrix);
392
+ });
393
+
394
+ meshRef.current.instanceMatrix.needsUpdate = true;
395
+ });
396
+
397
+ if (numEdges === 0) return null;
398
+
399
+ return (
400
+ <instancedMesh ref={meshRef} args={[undefined, undefined, numEdges]} frustumCulled={false}>
401
+ <boxGeometry args={[1, 1, 1]} />
402
+ <meshBasicMaterial color="#1a1a2e" transparent opacity={0.7} />
403
+ </instancedMesh>
404
+ );
405
+ }
406
+
239
407
  // ============================================================================
240
408
  // Instanced Buildings - High performance rendering for large scenes
241
409
  // ============================================================================
@@ -465,17 +633,223 @@ function InstancedBuildings({
465
633
  if (visibleBuildings.length === 0) return null;
466
634
 
467
635
  return (
468
- <instancedMesh
469
- ref={meshRef}
470
- args={[undefined, undefined, visibleBuildings.length]}
471
- onPointerMove={handlePointerMove}
472
- onPointerOut={handlePointerOut}
473
- onClick={handleClick}
474
- frustumCulled={false}
636
+ <group>
637
+ {/* Main building meshes */}
638
+ <instancedMesh
639
+ ref={meshRef}
640
+ args={[undefined, undefined, visibleBuildings.length]}
641
+ onPointerMove={handlePointerMove}
642
+ onPointerOut={handlePointerOut}
643
+ onClick={handleClick}
644
+ frustumCulled={false}
645
+ >
646
+ <boxGeometry args={[1, 1, 1]} />
647
+ <meshStandardMaterial metalness={0.1} roughness={0.35} />
648
+ </instancedMesh>
649
+
650
+ {/* Building edge outlines - batched into single geometry for performance */}
651
+ <BuildingEdges
652
+ buildings={visibleBuildings}
653
+ growProgress={growProgress}
654
+ minHeight={minHeight}
655
+ baseOffset={baseOffset}
656
+ springDuration={springDuration}
657
+ />
658
+ </group>
659
+ );
660
+ }
661
+
662
+ // ============================================================================
663
+ // Building Icons - Renders icons on top of buildings
664
+ // ============================================================================
665
+
666
+ interface BuildingIconsProps {
667
+ buildings: CityBuilding[];
668
+ centerOffset: { x: number; z: number };
669
+ growProgress: number;
670
+ heightScaling: HeightScaling;
671
+ linearScale: number;
672
+ highlightLayers: HighlightLayer[];
673
+ isolationMode: IsolationMode;
674
+ hasActiveHighlights: boolean;
675
+ staggerIndices: number[];
676
+ springDuration: number;
677
+ staggerDelay: number;
678
+ }
679
+
680
+ // Individual animated icon component
681
+ interface AnimatedIconProps {
682
+ x: number;
683
+ z: number;
684
+ targetHeight: number;
685
+ iconSize: number;
686
+ texture: THREE.Texture;
687
+ opacity: number;
688
+ growProgress: number;
689
+ staggerDelayMs: number;
690
+ springDuration: number;
691
+ }
692
+
693
+ function AnimatedIcon({
694
+ x,
695
+ z,
696
+ targetHeight,
697
+ iconSize,
698
+ texture,
699
+ opacity,
700
+ growProgress,
701
+ staggerDelayMs,
702
+ springDuration,
703
+ }: AnimatedIconProps) {
704
+ const spriteRef = useRef<THREE.Sprite>(null);
705
+ const startTimeRef = useRef<number | null>(null);
706
+ const materialRef = useRef<THREE.SpriteMaterial>(null);
707
+
708
+ useFrame(({ clock }) => {
709
+ if (!spriteRef.current) return;
710
+
711
+ if (startTimeRef.current === null && growProgress > 0) {
712
+ startTimeRef.current = clock.elapsedTime * 1000;
713
+ }
714
+
715
+ const currentTime = clock.elapsedTime * 1000;
716
+ const animStartTime = startTimeRef.current ?? currentTime;
717
+
718
+ // Calculate per-icon animation progress
719
+ const elapsed = currentTime - animStartTime - staggerDelayMs;
720
+ let animProgress = growProgress;
721
+
722
+ if (growProgress > 0 && elapsed >= 0) {
723
+ const t = Math.min(elapsed / springDuration, 1);
724
+ const eased = 1 - Math.pow(1 - t, 3);
725
+ animProgress = eased * growProgress;
726
+ } else if (growProgress > 0 && elapsed < 0) {
727
+ animProgress = 0;
728
+ }
729
+
730
+ const minHeight = 0.3;
731
+ const baseOffset = 0.2;
732
+ const height = animProgress * targetHeight + minHeight;
733
+ const buildingTop = height + baseOffset;
734
+ const yPosition = buildingTop + iconSize / 2 + 2;
735
+
736
+ spriteRef.current.position.y = yPosition;
737
+
738
+ if (materialRef.current) {
739
+ materialRef.current.opacity = opacity * animProgress;
740
+ }
741
+ });
742
+
743
+ return (
744
+ <sprite
745
+ ref={spriteRef}
746
+ position={[x, 0, z]}
747
+ scale={[iconSize, iconSize, 1]}
475
748
  >
476
- <boxGeometry args={[1, 1, 1]} />
477
- <meshStandardMaterial metalness={0.1} roughness={0.35} />
478
- </instancedMesh>
749
+ <spriteMaterial
750
+ ref={materialRef}
751
+ map={texture}
752
+ transparent
753
+ opacity={0}
754
+ depthTest={true}
755
+ depthWrite={false}
756
+ />
757
+ </sprite>
758
+ );
759
+ }
760
+
761
+ function BuildingIcons({
762
+ buildings,
763
+ centerOffset,
764
+ growProgress,
765
+ heightScaling,
766
+ linearScale,
767
+ highlightLayers,
768
+ isolationMode,
769
+ hasActiveHighlights,
770
+ staggerIndices,
771
+ springDuration,
772
+ staggerDelay,
773
+ }: BuildingIconsProps) {
774
+ // Pre-compute buildings with icons
775
+ const buildingsWithIcons = useMemo(() => {
776
+ return buildings
777
+ .map((building, index) => {
778
+ const config = getConfigForFile(building);
779
+ if (!config.icon) return null;
780
+
781
+ const highlight = getHighlightForPath(building.path, highlightLayers);
782
+ const isHighlighted = highlight !== null;
783
+ const shouldDim = hasActiveHighlights && !isHighlighted;
784
+ const shouldHide = shouldDim && isolationMode === 'hide';
785
+ const shouldCollapse = shouldDim && isolationMode === 'collapse';
786
+
787
+ if (shouldHide) return null;
788
+
789
+ const fullHeight = calculateBuildingHeight(building, heightScaling, linearScale);
790
+ const targetHeight = shouldCollapse ? 0.5 : fullHeight;
791
+
792
+ const x = building.position.x - centerOffset.x;
793
+ const z = building.position.z - centerOffset.z;
794
+
795
+ const staggerIndex = staggerIndices[index] ?? index;
796
+ const staggerDelayMs = staggerDelay * staggerIndex;
797
+
798
+ return {
799
+ building,
800
+ config,
801
+ x,
802
+ z,
803
+ targetHeight,
804
+ shouldDim,
805
+ staggerDelayMs,
806
+ };
807
+ })
808
+ .filter(Boolean) as Array<{
809
+ building: CityBuilding;
810
+ config: FileConfigResult;
811
+ x: number;
812
+ z: number;
813
+ targetHeight: number;
814
+ shouldDim: boolean;
815
+ staggerDelayMs: number;
816
+ }>;
817
+ }, [buildings, centerOffset, highlightLayers, isolationMode, hasActiveHighlights, heightScaling, linearScale, staggerIndices, staggerDelay]);
818
+
819
+ // Don't render if no progress yet
820
+ if (growProgress < 0.1) return null;
821
+
822
+ return (
823
+ <>
824
+ {buildingsWithIcons.map(({ building, config, x, z, targetHeight, shouldDim, staggerDelayMs }) => {
825
+ const icon = config.icon!;
826
+ const texture = getIconTexture(icon.name, icon.color || '#ffffff');
827
+ if (!texture) return null;
828
+
829
+ // Icon size based on building dimensions
830
+ const [width] = building.dimensions;
831
+ const baseSize = Math.max(width * 0.8, 6);
832
+ const heightBoost = Math.min(targetHeight / 20, 3);
833
+ const iconSize = (baseSize + heightBoost) * (icon.size || 1);
834
+
835
+ const opacity = shouldDim && isolationMode === 'transparent' ? 0.3 : 1;
836
+
837
+ return (
838
+ <AnimatedIcon
839
+ key={building.path}
840
+ x={x}
841
+ z={z}
842
+ targetHeight={targetHeight}
843
+ iconSize={iconSize}
844
+ texture={texture}
845
+ opacity={opacity}
846
+ growProgress={growProgress}
847
+ staggerDelayMs={staggerDelayMs}
848
+ springDuration={springDuration}
849
+ />
850
+ );
851
+ })}
852
+ </>
479
853
  );
480
854
  }
481
855
 
@@ -762,20 +1136,33 @@ function CityScene({
762
1136
  return cityData.buildings.findIndex((b) => b.path === hoveredBuilding.path);
763
1137
  }, [hoveredBuilding, cityData.buildings]);
764
1138
 
1139
+ // Calculate spring duration for animation sync
1140
+ const tension = animationConfig.tension || 120;
1141
+ const friction = animationConfig.friction || 14;
1142
+ const springDuration = Math.sqrt(1 / (tension * 0.001)) * friction * 20;
1143
+
765
1144
  return (
766
1145
  <>
767
1146
  <AnimatedCamera citySize={citySize} isFlat={growProgress === 0} />
768
1147
 
769
- <ambientLight intensity={0.4} />
1148
+ <ambientLight intensity={1.2} />
1149
+ <hemisphereLight
1150
+ args={['#ddeeff', '#667788', 0.8]}
1151
+ position={[0, citySize, 0]}
1152
+ />
770
1153
  <directionalLight
771
- position={[citySize, citySize, citySize * 0.5]}
772
- intensity={1}
1154
+ position={[citySize, citySize * 1.5, citySize * 0.5]}
1155
+ intensity={2}
773
1156
  castShadow
774
1157
  shadow-mapSize={[2048, 2048]}
775
1158
  />
776
1159
  <directionalLight
777
- position={[-citySize * 0.5, citySize * 0.5, -citySize * 0.5]}
778
- intensity={0.3}
1160
+ position={[-citySize * 0.5, citySize * 0.8, -citySize * 0.5]}
1161
+ intensity={1}
1162
+ />
1163
+ <directionalLight
1164
+ position={[citySize * 0.3, citySize, citySize]}
1165
+ intensity={0.6}
779
1166
  />
780
1167
 
781
1168
  {cityData.districts.map((district) => (
@@ -803,6 +1190,20 @@ function CityScene({
803
1190
  linearScale={linearScale}
804
1191
  staggerIndices={staggerIndices}
805
1192
  />
1193
+
1194
+ <BuildingIcons
1195
+ buildings={cityData.buildings}
1196
+ centerOffset={centerOffset}
1197
+ growProgress={growProgress}
1198
+ heightScaling={heightScaling}
1199
+ linearScale={linearScale}
1200
+ highlightLayers={highlightLayers}
1201
+ isolationMode={isolationMode}
1202
+ hasActiveHighlights={activeHighlights}
1203
+ staggerIndices={staggerIndices}
1204
+ springDuration={springDuration}
1205
+ staggerDelay={animationConfig.staggerDelay || 15}
1206
+ />
806
1207
  </>
807
1208
  );
808
1209
  }
@@ -478,3 +478,65 @@ export const LinearHeightScaling: Story = {
478
478
  linearScale: 0.5,
479
479
  },
480
480
  };
481
+
482
+ // Real repository data from JSON files
483
+ import authServerCityData from '../../../../assets/auth-server-city-data.json';
484
+ import electronAppCityData from '../../../../assets/electron-app-city-data.json';
485
+ import thisRepoCityData from '../../../../assets/this-repo-city-data.json';
486
+
487
+ /**
488
+ * Auth Server - Real repository data
489
+ */
490
+ export const AuthServer: Story = {
491
+ args: {
492
+ cityData: authServerCityData as CityData,
493
+ height: '100vh',
494
+ heightScaling: 'linear',
495
+ linearScale: 0.5,
496
+ animation: {
497
+ startFlat: true,
498
+ autoStartDelay: 800,
499
+ staggerDelay: 15,
500
+ tension: 120,
501
+ friction: 14,
502
+ },
503
+ },
504
+ };
505
+
506
+ /**
507
+ * Electron App - Real repository data (larger)
508
+ */
509
+ export const ElectronApp: Story = {
510
+ args: {
511
+ cityData: electronAppCityData as CityData,
512
+ height: '100vh',
513
+ heightScaling: 'linear',
514
+ linearScale: 0.5,
515
+ animation: {
516
+ startFlat: true,
517
+ autoStartDelay: 600,
518
+ staggerDelay: 5,
519
+ tension: 150,
520
+ friction: 16,
521
+ },
522
+ },
523
+ };
524
+
525
+ /**
526
+ * This Repo - industry-themed-repository-composition-panels
527
+ */
528
+ export const ThisRepo: Story = {
529
+ args: {
530
+ cityData: thisRepoCityData as CityData,
531
+ height: '100vh',
532
+ heightScaling: 'linear',
533
+ linearScale: 0.5,
534
+ animation: {
535
+ startFlat: true,
536
+ autoStartDelay: 700,
537
+ staggerDelay: 10,
538
+ tension: 130,
539
+ friction: 14,
540
+ },
541
+ },
542
+ };