@principal-ai/file-city-react 0.3.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.
Files changed (51) hide show
  1. package/dist/builder/cityDataUtils.d.ts +15 -0
  2. package/dist/builder/cityDataUtils.d.ts.map +1 -0
  3. package/dist/builder/cityDataUtils.js +348 -0
  4. package/dist/components/ArchitectureMapHighlightLayers.d.ts +63 -0
  5. package/dist/components/ArchitectureMapHighlightLayers.d.ts.map +1 -0
  6. package/dist/components/ArchitectureMapHighlightLayers.js +1040 -0
  7. package/dist/components/CityViewWithReactFlow.d.ts +14 -0
  8. package/dist/components/CityViewWithReactFlow.d.ts.map +1 -0
  9. package/dist/components/CityViewWithReactFlow.js +266 -0
  10. package/dist/config/files.json +996 -0
  11. package/dist/hooks/useCodeCityData.d.ts +21 -0
  12. package/dist/hooks/useCodeCityData.d.ts.map +1 -0
  13. package/dist/hooks/useCodeCityData.js +57 -0
  14. package/dist/index.d.ts +14 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +29 -0
  17. package/dist/render/client/drawLayeredBuildings.d.ts +51 -0
  18. package/dist/render/client/drawLayeredBuildings.d.ts.map +1 -0
  19. package/dist/render/client/drawLayeredBuildings.js +650 -0
  20. package/dist/stories/ArchitectureMapGridLayout.stories.d.ts +73 -0
  21. package/dist/stories/ArchitectureMapGridLayout.stories.d.ts.map +1 -0
  22. package/dist/stories/ArchitectureMapGridLayout.stories.js +345 -0
  23. package/dist/stories/ArchitectureMapHighlightLayers.stories.d.ts +78 -0
  24. package/dist/stories/ArchitectureMapHighlightLayers.stories.d.ts.map +1 -0
  25. package/dist/stories/ArchitectureMapHighlightLayers.stories.js +270 -0
  26. package/dist/stories/CityViewWithReactFlow.stories.d.ts +24 -0
  27. package/dist/stories/CityViewWithReactFlow.stories.d.ts.map +1 -0
  28. package/dist/stories/CityViewWithReactFlow.stories.js +778 -0
  29. package/dist/stories/sample-data.d.ts +4 -0
  30. package/dist/stories/sample-data.d.ts.map +1 -0
  31. package/dist/stories/sample-data.js +268 -0
  32. package/dist/types/react-types.d.ts +17 -0
  33. package/dist/types/react-types.d.ts.map +1 -0
  34. package/dist/types/react-types.js +4 -0
  35. package/dist/utils/fileColorHighlightLayers.d.ts +86 -0
  36. package/dist/utils/fileColorHighlightLayers.d.ts.map +1 -0
  37. package/dist/utils/fileColorHighlightLayers.js +283 -0
  38. package/package.json +49 -0
  39. package/src/builder/cityDataUtils.ts +430 -0
  40. package/src/components/ArchitectureMapHighlightLayers.tsx +1518 -0
  41. package/src/components/CityViewWithReactFlow.tsx +365 -0
  42. package/src/config/files.json +996 -0
  43. package/src/hooks/useCodeCityData.ts +82 -0
  44. package/src/index.ts +64 -0
  45. package/src/render/client/drawLayeredBuildings.ts +946 -0
  46. package/src/stories/ArchitectureMapGridLayout.stories.tsx +410 -0
  47. package/src/stories/ArchitectureMapHighlightLayers.stories.tsx +312 -0
  48. package/src/stories/CityViewWithReactFlow.stories.tsx +787 -0
  49. package/src/stories/sample-data.ts +301 -0
  50. package/src/types/react-types.ts +18 -0
  51. package/src/utils/fileColorHighlightLayers.ts +378 -0
@@ -0,0 +1,946 @@
1
+ import { CityBuilding, CityDistrict } from '@principal-ai/file-city-builder';
2
+
3
+ // Layer types and interfaces
4
+ export type LayerRenderStrategy =
5
+ | 'border'
6
+ | 'fill'
7
+ | 'glow'
8
+ | 'pattern'
9
+ | 'cover'
10
+ | 'icon'
11
+ | 'custom';
12
+
13
+ export interface LayerItem {
14
+ path: string;
15
+ type: 'file' | 'directory';
16
+ renderStrategy?: LayerRenderStrategy;
17
+ // Cover-specific options
18
+ coverOptions?: {
19
+ opacity?: number;
20
+ image?: string;
21
+ text?: string;
22
+ textSize?: number;
23
+ backgroundColor?: string;
24
+ borderRadius?: number;
25
+ icon?: string;
26
+ iconSize?: number;
27
+ };
28
+ // Custom render function
29
+ customRender?: (
30
+ ctx: CanvasRenderingContext2D,
31
+ bounds: { x: number; y: number; width: number; height: number },
32
+ scale: number,
33
+ ) => void;
34
+ }
35
+
36
+ export interface HighlightLayer {
37
+ id: string;
38
+ name: string;
39
+ enabled: boolean;
40
+ color: string;
41
+ opacity?: number;
42
+ borderWidth?: number;
43
+ priority: number; // Higher priority layers render on top
44
+ items: LayerItem[];
45
+ // Performance optimization - mark frequently changing layers as dynamic
46
+ dynamic?: boolean; // If true, this layer changes frequently (e.g., hover effects)
47
+ }
48
+
49
+ // Helper to check if a path matches a layer item
50
+ function pathMatchesItem(
51
+ path: string,
52
+ item: LayerItem,
53
+ checkType: 'exact' | 'children' = 'children',
54
+ ): boolean {
55
+ if (item.type === 'file') {
56
+ return path === item.path;
57
+ } else {
58
+ // Directory match
59
+ if (checkType === 'exact') {
60
+ // Only match the directory itself, not its children
61
+ return path === item.path;
62
+ } else {
63
+ // Match directory and all its children (original behavior)
64
+ return path === item.path || path.startsWith(item.path + '/');
65
+ }
66
+ }
67
+ }
68
+
69
+ // Helper function to draw rounded rectangles
70
+ function drawRoundedRect(
71
+ ctx: CanvasRenderingContext2D,
72
+ x: number,
73
+ y: number,
74
+ width: number,
75
+ height: number,
76
+ radius: number,
77
+ fill?: boolean,
78
+ stroke?: boolean,
79
+ ) {
80
+ ctx.beginPath();
81
+ ctx.moveTo(x + radius, y);
82
+ ctx.lineTo(x + width - radius, y);
83
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
84
+ ctx.lineTo(x + width, y + height - radius);
85
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
86
+ ctx.lineTo(x + radius, y + height);
87
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
88
+ ctx.lineTo(x, y + radius);
89
+ ctx.quadraticCurveTo(x, y, x + radius, y);
90
+ ctx.closePath();
91
+
92
+ if (fill) ctx.fill();
93
+ if (stroke) ctx.stroke();
94
+ }
95
+
96
+ // Get all layer items that apply to a given path
97
+ function getLayerItemsForPath(
98
+ path: string,
99
+ layers: HighlightLayer[],
100
+ checkType: 'exact' | 'children' = 'children',
101
+ ): Array<{ layer: HighlightLayer; item: LayerItem }> {
102
+ const matches: Array<{ layer: HighlightLayer; item: LayerItem }> = [];
103
+
104
+ for (const layer of layers) {
105
+ if (!layer.enabled) continue;
106
+
107
+ for (const item of layer.items) {
108
+ if (pathMatchesItem(path, item, checkType)) {
109
+ matches.push({ layer, item });
110
+ }
111
+ }
112
+ }
113
+
114
+ // Sort by priority (highest first)
115
+ return matches.sort((a, b) => b.layer.priority - a.layer.priority);
116
+ }
117
+
118
+ // Draw grid helper (copied from original)
119
+ export function drawGrid(
120
+ ctx: CanvasRenderingContext2D,
121
+ width: number,
122
+ height: number,
123
+ gridSize: number,
124
+ ) {
125
+ ctx.save();
126
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
127
+ ctx.lineWidth = 1;
128
+
129
+ for (let x = 0; x < width; x += gridSize) {
130
+ ctx.beginPath();
131
+ ctx.moveTo(x, 0);
132
+ ctx.lineTo(x, height);
133
+ ctx.stroke();
134
+ }
135
+
136
+ for (let y = 0; y < height; y += gridSize) {
137
+ ctx.beginPath();
138
+ ctx.moveTo(0, y);
139
+ ctx.lineTo(width, y);
140
+ ctx.stroke();
141
+ }
142
+
143
+ ctx.restore();
144
+ }
145
+
146
+ // Helper function to break text intelligently
147
+ function breakTextIntelligently(text: string): string[] {
148
+ // First try hyphen
149
+ if (text.includes('-')) {
150
+ const parts = text.split('-');
151
+ // If we get reasonable parts, use them
152
+ if (parts.length >= 2 && parts.every(p => p.length > 0)) {
153
+ // Group parts to avoid too many lines
154
+ if (parts.length > 3) {
155
+ const mid = Math.ceil(parts.length / 2);
156
+ return [parts.slice(0, mid).join('-'), parts.slice(mid).join('-')];
157
+ }
158
+ return parts;
159
+ }
160
+ }
161
+
162
+ // Then try underscore
163
+ if (text.includes('_')) {
164
+ const parts = text.split('_');
165
+ if (parts.length >= 2 && parts.every(p => p.length > 0)) {
166
+ if (parts.length > 3) {
167
+ const mid = Math.ceil(parts.length / 2);
168
+ return [parts.slice(0, mid).join('_'), parts.slice(mid).join('_')];
169
+ }
170
+ return parts;
171
+ }
172
+ }
173
+
174
+ // Then try camelCase
175
+ const camelCaseParts = text.match(/[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\b)/g);
176
+ if (camelCaseParts && camelCaseParts.length >= 2) {
177
+ if (camelCaseParts.length > 3) {
178
+ const mid = Math.ceil(camelCaseParts.length / 2);
179
+ return [camelCaseParts.slice(0, mid).join(''), camelCaseParts.slice(mid).join('')];
180
+ }
181
+ return camelCaseParts;
182
+ }
183
+
184
+ // If no good break points, try breaking at word boundaries
185
+ const words = text.split(/\s+/);
186
+ if (words.length >= 2) {
187
+ const mid = Math.ceil(words.length / 2);
188
+ return [words.slice(0, mid).join(' '), words.slice(mid).join(' ')];
189
+ }
190
+
191
+ // Last resort - break in the middle
192
+ if (text.length > 20) {
193
+ const mid = Math.floor(text.length / 2);
194
+ // Try to find a better break point near the middle
195
+ const searchRange = Math.floor(text.length * 0.2);
196
+ let breakPoint = mid;
197
+
198
+ // Look for special characters near the middle
199
+ for (let i = 0; i < searchRange; i++) {
200
+ if (mid + i < text.length && /[\W_]/.test(text[mid + i])) {
201
+ breakPoint = mid + i;
202
+ break;
203
+ }
204
+ if (mid - i >= 0 && /[\W_]/.test(text[mid - i])) {
205
+ breakPoint = mid - i;
206
+ break;
207
+ }
208
+ }
209
+
210
+ return [text.substring(0, breakPoint).trim(), text.substring(breakPoint).trim()];
211
+ }
212
+
213
+ // Return as single line if text is short
214
+ return [text];
215
+ }
216
+
217
+ // Render strategies implementation
218
+ function renderBorderStrategy(
219
+ ctx: CanvasRenderingContext2D,
220
+ bounds: { x: number; y: number; width: number; height: number },
221
+ layer: HighlightLayer,
222
+ item: LayerItem,
223
+ borderRadius: number = 0,
224
+ ) {
225
+ ctx.save();
226
+ ctx.strokeStyle = layer.color;
227
+ ctx.lineWidth = layer.borderWidth || 2;
228
+ ctx.globalAlpha = layer.opacity || 1;
229
+ ctx.setLineDash([]);
230
+
231
+ if (item.type === 'directory') {
232
+ // Sharp corners for directories
233
+ ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
234
+ } else {
235
+ // Use configurable border radius for files/buildings
236
+ const radius = Math.min(borderRadius, Math.min(bounds.width, bounds.height) / 6);
237
+ if (radius > 0) {
238
+ drawRoundedRect(ctx, bounds.x, bounds.y, bounds.width, bounds.height, radius, false, true);
239
+ } else {
240
+ ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
241
+ }
242
+ }
243
+
244
+ ctx.restore();
245
+ }
246
+
247
+ function renderFillStrategy(
248
+ ctx: CanvasRenderingContext2D,
249
+ bounds: { x: number; y: number; width: number; height: number },
250
+ layer: HighlightLayer,
251
+ item: LayerItem,
252
+ borderRadius: number = 0,
253
+ ) {
254
+ ctx.save();
255
+ ctx.fillStyle = layer.color;
256
+ ctx.globalAlpha = layer.opacity || 0.3;
257
+
258
+ if (item.type === 'directory') {
259
+ // Sharp corners for directories
260
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
261
+ } else {
262
+ // Use configurable border radius for files/buildings
263
+ const radius = Math.min(borderRadius, Math.min(bounds.width, bounds.height) / 6);
264
+ if (radius > 0) {
265
+ drawRoundedRect(ctx, bounds.x, bounds.y, bounds.width, bounds.height, radius, true, false);
266
+ } else {
267
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
268
+ }
269
+ }
270
+
271
+ ctx.restore();
272
+ }
273
+
274
+ function renderGlowStrategy(
275
+ ctx: CanvasRenderingContext2D,
276
+ bounds: { x: number; y: number; width: number; height: number },
277
+ layer: HighlightLayer,
278
+ item: LayerItem,
279
+ borderRadius: number = 0,
280
+ ) {
281
+ ctx.save();
282
+ ctx.shadowColor = layer.color;
283
+ ctx.shadowBlur = 10;
284
+ ctx.fillStyle = layer.color;
285
+ ctx.globalAlpha = layer.opacity || 0.5;
286
+
287
+ if (item.type === 'directory') {
288
+ // Sharp corners for directories
289
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
290
+ } else {
291
+ // Use configurable border radius for files/buildings
292
+ const radius = Math.min(borderRadius, Math.min(bounds.width, bounds.height) / 6);
293
+ if (radius > 0) {
294
+ drawRoundedRect(ctx, bounds.x, bounds.y, bounds.width, bounds.height, radius, true, false);
295
+ } else {
296
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
297
+ }
298
+ }
299
+
300
+ ctx.restore();
301
+ }
302
+
303
+ function renderPatternStrategy(
304
+ ctx: CanvasRenderingContext2D,
305
+ bounds: { x: number; y: number; width: number; height: number },
306
+ layer: HighlightLayer,
307
+ _item: LayerItem,
308
+ ) {
309
+ ctx.save();
310
+ ctx.strokeStyle = layer.color;
311
+ ctx.lineWidth = 1;
312
+ ctx.globalAlpha = layer.opacity || 0.5;
313
+
314
+ // Create diagonal line pattern
315
+ const spacing = 5;
316
+ ctx.beginPath();
317
+
318
+ for (let offset = -bounds.height; offset < bounds.width; offset += spacing) {
319
+ ctx.moveTo(bounds.x + offset, bounds.y);
320
+ ctx.lineTo(bounds.x + offset + bounds.height, bounds.y + bounds.height);
321
+ }
322
+
323
+ ctx.stroke();
324
+ ctx.restore();
325
+ }
326
+
327
+ function renderCoverStrategy(
328
+ ctx: CanvasRenderingContext2D,
329
+ bounds: { x: number; y: number; width: number; height: number },
330
+ layer: HighlightLayer,
331
+ item: LayerItem,
332
+ _scale: number,
333
+ ) {
334
+ const coverOptions = item.coverOptions || {};
335
+
336
+ ctx.save();
337
+
338
+ // Create clipping path for the cover area - always use sharp corners for directories
339
+ ctx.beginPath();
340
+ ctx.rect(bounds.x, bounds.y, bounds.width, bounds.height);
341
+ ctx.clip();
342
+
343
+ // Background - always use sharp corners for directory covers
344
+ ctx.fillStyle = coverOptions.backgroundColor || layer.color;
345
+ ctx.globalAlpha = coverOptions.opacity || 0.8;
346
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
347
+
348
+ // Reset alpha for text/icon
349
+ ctx.globalAlpha = 1;
350
+
351
+ // Image/SVG (takes precedence over text icon)
352
+ if (coverOptions.image) {
353
+ const img = new Image();
354
+ img.onload = () => {
355
+ const imageSize = coverOptions.iconSize || Math.min(bounds.width, bounds.height) * 0.4;
356
+ const imageX = bounds.x + bounds.width / 2 - imageSize / 2;
357
+ const imageY = coverOptions.text
358
+ ? bounds.y + bounds.height * 0.25
359
+ : bounds.y + bounds.height / 2 - imageSize / 2;
360
+
361
+ ctx.drawImage(img, imageX, imageY, imageSize, imageSize);
362
+ };
363
+ img.src = coverOptions.image;
364
+ }
365
+ // Text Icon (fallback if no image)
366
+ else if (coverOptions.icon) {
367
+ const iconSize = coverOptions.iconSize || Math.min(bounds.width, bounds.height) * 0.3;
368
+ ctx.font = `${iconSize}px Arial`;
369
+ ctx.fillStyle = '#ffffff';
370
+ ctx.textAlign = 'center';
371
+ ctx.textBaseline = 'middle';
372
+
373
+ const iconY = coverOptions.text
374
+ ? bounds.y + bounds.height * 0.35
375
+ : bounds.y + bounds.height * 0.5;
376
+
377
+ ctx.fillText(coverOptions.icon, bounds.x + bounds.width / 2, iconY);
378
+ }
379
+
380
+ // Text
381
+ if (coverOptions.text) {
382
+ let textSize = coverOptions.textSize || Math.min(bounds.width, bounds.height) * 0.15;
383
+ ctx.font = `${textSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
384
+ ctx.fillStyle = '#ffffff';
385
+ ctx.textAlign = 'center';
386
+
387
+ // Measure text and see if it fits
388
+ const padding = bounds.width * 0.1;
389
+ const maxWidth = bounds.width - padding * 2;
390
+ const textToRender = coverOptions.text;
391
+ let textWidth = ctx.measureText(textToRender).width;
392
+
393
+ // If text is too wide, try breaking it up
394
+ if (textWidth > maxWidth) {
395
+ // Try to break by common separators
396
+ const breakableText = breakTextIntelligently(coverOptions.text);
397
+
398
+ if (breakableText.length > 1) {
399
+ // Render multiple lines
400
+ const lineHeight = textSize * 1.2;
401
+ const totalHeight = lineHeight * breakableText.length;
402
+ const startY = bounds.y + (bounds.height - totalHeight) / 2 + lineHeight / 2;
403
+
404
+ // Adjust text size if needed to fit all lines
405
+ const maxLines = Math.floor((bounds.height * 0.8) / lineHeight);
406
+ if (breakableText.length > maxLines) {
407
+ textSize = textSize * (maxLines / breakableText.length);
408
+ ctx.font = `${textSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
409
+ }
410
+
411
+ breakableText.forEach((line, index) => {
412
+ const y = startY + index * lineHeight;
413
+ ctx.textBaseline = 'middle';
414
+ ctx.fillText(line, bounds.x + bounds.width / 2, y);
415
+ });
416
+ } else {
417
+ // Single line - scale down text if needed
418
+ while (textWidth > maxWidth && textSize > 8) {
419
+ textSize--;
420
+ ctx.font = `${textSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
421
+ textWidth = ctx.measureText(textToRender).width;
422
+ }
423
+
424
+ const textY = coverOptions.icon
425
+ ? bounds.y + bounds.height * 0.65
426
+ : bounds.y + bounds.height * 0.5;
427
+
428
+ ctx.textBaseline = 'middle';
429
+ ctx.fillText(textToRender, bounds.x + bounds.width / 2, textY);
430
+ }
431
+ } else {
432
+ // Text fits - render normally
433
+ const textY = coverOptions.icon
434
+ ? bounds.y + bounds.height * 0.65
435
+ : bounds.y + bounds.height * 0.5;
436
+
437
+ ctx.textBaseline = 'middle';
438
+ ctx.fillText(textToRender, bounds.x + bounds.width / 2, textY);
439
+ }
440
+ }
441
+
442
+ ctx.restore();
443
+ }
444
+
445
+ // Apply layer rendering to a specific item
446
+ function applyLayerRendering(
447
+ ctx: CanvasRenderingContext2D,
448
+ bounds: { x: number; y: number; width: number; height: number },
449
+ layer: HighlightLayer,
450
+ item: LayerItem,
451
+ scale: number,
452
+ borderRadius: number = 0,
453
+ ) {
454
+ const strategy = item.renderStrategy || 'border';
455
+
456
+ switch (strategy) {
457
+ case 'border':
458
+ renderBorderStrategy(ctx, bounds, layer, item, borderRadius);
459
+ break;
460
+ case 'fill':
461
+ renderFillStrategy(ctx, bounds, layer, item, borderRadius);
462
+ break;
463
+ case 'glow':
464
+ renderGlowStrategy(ctx, bounds, layer, item, borderRadius);
465
+ break;
466
+ case 'pattern':
467
+ renderPatternStrategy(ctx, bounds, layer, item);
468
+ break;
469
+ case 'cover':
470
+ renderCoverStrategy(ctx, bounds, layer, item, scale);
471
+ break;
472
+ case 'custom':
473
+ if (item.customRender) {
474
+ item.customRender(ctx, bounds, scale);
475
+ }
476
+ break;
477
+ }
478
+ }
479
+
480
+ // Draw districts with layer support
481
+ export function drawLayeredDistricts(
482
+ ctx: CanvasRenderingContext2D,
483
+ districts: CityDistrict[],
484
+ worldToCanvas: (x: number, z: number) => { x: number; y: number },
485
+ scale: number, // This includes the zoom scale for text proportionality
486
+ layers: HighlightLayer[],
487
+ hoveredDistrict?: CityDistrict | null,
488
+ fullSize?: boolean,
489
+ defaultDirectoryColor?: string,
490
+ layoutConfig?: {
491
+ paddingTop: number;
492
+ paddingBottom: number;
493
+ paddingLeft: number;
494
+ paddingRight: number;
495
+ },
496
+ abstractedPaths?: Set<string>, // Paths of directories that are abstracted (have covers)
497
+ showDirectoryLabels: boolean = true,
498
+ borderRadius: number = 0, // Border radius for districts (default: sharp corners)
499
+ ) {
500
+ districts.forEach(district => {
501
+ const districtPath = district.path || '';
502
+ const isRoot = !districtPath || districtPath === '';
503
+
504
+ // Check if this root district has layer matches (like covers) - if so, render it
505
+ const rootLayerMatches = getLayerItemsForPath(districtPath, layers, 'exact');
506
+ const hasLayerRendering = rootLayerMatches.length > 0;
507
+
508
+ // Skip root districts unless they have layer rendering (covers, highlights, etc.)
509
+ if (isRoot && !hasLayerRendering) return;
510
+
511
+ const canvasPos = worldToCanvas(district.worldBounds.minX, district.worldBounds.minZ);
512
+ const width = (district.worldBounds.maxX - district.worldBounds.minX) * scale;
513
+ const depth = (district.worldBounds.maxZ - district.worldBounds.minZ) * scale;
514
+
515
+ const bounds = {
516
+ x: canvasPos.x,
517
+ y: canvasPos.y,
518
+ width: width,
519
+ height: depth,
520
+ };
521
+
522
+ // Get base color
523
+
524
+ const baseColorHex = defaultDirectoryColor || '#4B5155';
525
+ const hexToRgb = (hex: string): [number, number, number] => {
526
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
527
+ return result
528
+ ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
529
+ : [75, 80, 85];
530
+ };
531
+
532
+ const baseColor = hexToRgb(baseColorHex);
533
+
534
+ // Base rendering
535
+ let opacity = 0.3;
536
+ let borderOpacity = 0.6;
537
+
538
+ // Check if district has layer highlighting - use exact matching for districts
539
+ const layerMatches = getLayerItemsForPath(districtPath, layers, 'exact');
540
+ const hasLayerHighlight = layerMatches.length > 0;
541
+
542
+ if (hasLayerHighlight) {
543
+ opacity = 0.5;
544
+ borderOpacity = 0.8;
545
+ }
546
+
547
+ const isHovered = hoveredDistrict === district;
548
+ if (isHovered) {
549
+ opacity = Math.max(opacity, 0.4);
550
+ borderOpacity = Math.max(borderOpacity, 0.7);
551
+ }
552
+
553
+ // Fill districts with configurable border radius
554
+ ctx.fillStyle = `rgba(${baseColor[0]}, ${baseColor[1]}, ${baseColor[2]}, ${opacity})`;
555
+ if (borderRadius > 0) {
556
+ drawRoundedRect(
557
+ ctx,
558
+ bounds.x,
559
+ bounds.y,
560
+ bounds.width,
561
+ bounds.height,
562
+ borderRadius,
563
+ true,
564
+ false,
565
+ );
566
+ } else {
567
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
568
+ }
569
+
570
+ // Border with configurable border radius
571
+ ctx.strokeStyle = `rgba(${Math.min(255, baseColor[0] + 40)}, ${Math.min(
572
+ 255,
573
+ baseColor[1] + 40,
574
+ )}, ${Math.min(255, baseColor[2] + 40)}, ${borderOpacity})`;
575
+ ctx.lineWidth = 1;
576
+ if (borderRadius > 0) {
577
+ drawRoundedRect(
578
+ ctx,
579
+ bounds.x,
580
+ bounds.y,
581
+ bounds.width,
582
+ bounds.height,
583
+ borderRadius,
584
+ false,
585
+ true,
586
+ );
587
+ } else {
588
+ ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
589
+ }
590
+
591
+ // Apply layer-specific rendering (covers, etc.)
592
+ for (const match of layerMatches) {
593
+ // Only apply if this is specifically a directory item or if it's a cover strategy
594
+ if (match.item.type === 'directory' || match.item.renderStrategy === 'cover') {
595
+ applyLayerRendering(ctx, bounds, match.layer, match.item, scale, borderRadius);
596
+ }
597
+ }
598
+
599
+ // Hover highlight with configurable border radius
600
+ if (isHovered) {
601
+ ctx.strokeStyle = '#ffffff';
602
+ ctx.lineWidth = 2;
603
+ if (borderRadius > 0) {
604
+ drawRoundedRect(
605
+ ctx,
606
+ bounds.x,
607
+ bounds.y,
608
+ bounds.width,
609
+ bounds.height,
610
+ borderRadius,
611
+ false,
612
+ true,
613
+ );
614
+ } else {
615
+ ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
616
+ }
617
+ }
618
+
619
+ // Check if this district has a cover layer item or is abstracted
620
+ const hasCover =
621
+ layerMatches.some(match => match.item.renderStrategy === 'cover' && match.layer.enabled) ||
622
+ (abstractedPaths && abstractedPaths.has(districtPath));
623
+
624
+ // Skip label rendering if district has a cover or is abstracted
625
+ if (showDirectoryLabels && !hasCover) {
626
+ // Check if this is a grid cell with dedicated label space
627
+ const isGridCell = district.path?.startsWith('grid-cell-') && district.label;
628
+
629
+ let districtName: string;
630
+ let labelBounds: { x: number; y: number; width: number; height: number } | undefined;
631
+
632
+ if (isGridCell && district.label) {
633
+ // Use grid cell label configuration
634
+ districtName = district.label.text;
635
+ // Transform label bounds from world space to canvas space
636
+ const labelCanvasPos = worldToCanvas(
637
+ district.label.bounds.minX,
638
+ district.label.bounds.minZ,
639
+ );
640
+ const labelCanvasWidth = (district.label.bounds.maxX - district.label.bounds.minX) * scale;
641
+ const labelCanvasHeight = (district.label.bounds.maxZ - district.label.bounds.minZ) * scale;
642
+
643
+ labelBounds = {
644
+ x: labelCanvasPos.x,
645
+ y: labelCanvasPos.y,
646
+ width: labelCanvasWidth,
647
+ height: labelCanvasHeight,
648
+ };
649
+ } else {
650
+ // Use traditional district name from path
651
+ districtName = district.path?.split('/').pop() || 'root';
652
+ }
653
+
654
+ // Calculate actual available label space
655
+ let availableLabelHeight: number;
656
+ let actualLabelHeight: number;
657
+ let labelWidth: number;
658
+ let labelX: number;
659
+ let labelY: number;
660
+
661
+ if (labelBounds) {
662
+ // For grid cells, use dedicated label space
663
+ availableLabelHeight = labelBounds.height;
664
+ actualLabelHeight = labelBounds.height * scale;
665
+ labelWidth = labelBounds.width;
666
+ labelX = labelBounds.x;
667
+ labelY = labelBounds.y;
668
+ } else {
669
+ // For traditional districts, use padding space
670
+ availableLabelHeight = layoutConfig?.paddingTop || (fullSize ? 24 : 20);
671
+ actualLabelHeight = availableLabelHeight * scale;
672
+ labelWidth = bounds.width;
673
+ labelX = bounds.x;
674
+ labelY = bounds.y;
675
+ }
676
+
677
+ // Only check absolute label space, not ratio
678
+ const minMeaningfulLabelSpace = fullSize ? 12 : 10; // Just need readable space
679
+
680
+ // For grid cells with dedicated label space, always show the label
681
+ const labelSpaceSufficient = labelBounds
682
+ ? true
683
+ : actualLabelHeight >= minMeaningfulLabelSpace;
684
+
685
+ if (labelSpaceSufficient) {
686
+ ctx.save();
687
+ ctx.fillStyle = hasLayerHighlight ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)';
688
+
689
+ // Step 1: Start with proportional sizing (for visual consistency)
690
+ // For grid cell labels, use larger base size
691
+ const baseFontSize = labelBounds ? (fullSize ? 28 : 20) : fullSize ? 18 : 13;
692
+ const scaleFactor = Math.min(Math.max(scale / 1.0, 0.5), 3.0);
693
+ const proportionalFontSize = baseFontSize * scaleFactor;
694
+
695
+ // Step 2: Clamp to readable bounds (for usability)
696
+ const minReadableSize = labelBounds ? 14 : 10; // Larger minimum for grid labels
697
+ // No max for grid labels - let them scale with cell size, regular labels still capped
698
+ const maxReadableSize = labelBounds ? Number.MAX_SAFE_INTEGER : 24;
699
+ const clampedSize = Math.min(
700
+ Math.max(proportionalFontSize, minReadableSize),
701
+ maxReadableSize,
702
+ );
703
+
704
+ // Step 3: Apply container-fit within those bounds (for proper fitting)
705
+ const horizontalPadding = 8; // Small padding from edges
706
+ const availableWidth = labelWidth - horizontalPadding * 2;
707
+
708
+ // Start with the clamped proportional size, but respect container limits
709
+ let fontSize = Math.min(clampedSize, availableLabelHeight * 0.8); // Use 80% of available height as max
710
+ // Use bold font for grid cell labels, normal for others
711
+ const fontWeight = labelBounds ? 'bold' : 'normal';
712
+ ctx.font = `${fontWeight} ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
713
+ let textWidth = ctx.measureText(districtName).width;
714
+
715
+ // Scale down if text is too wide for the container
716
+ while (textWidth > availableWidth && fontSize > minReadableSize) {
717
+ fontSize--;
718
+ ctx.font = `${fontWeight} ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
719
+ textWidth = ctx.measureText(districtName).width;
720
+ }
721
+
722
+ // For grid cells, always render the label. For others, check minimum size
723
+ if (labelBounds || fontSize >= minReadableSize) {
724
+ ctx.textAlign = 'center';
725
+
726
+ if (labelBounds) {
727
+ // For grid cells, position label in dedicated label space
728
+ // Position near bottom of label area for better visual connection to content
729
+ ctx.textBaseline = 'bottom';
730
+ ctx.fillText(
731
+ districtName,
732
+ labelX + labelWidth / 2,
733
+ labelY + labelBounds.height * 0.95, // Position at 95% down in the label area (closer to bottom)
734
+ );
735
+ } else {
736
+ // For traditional districts, position at bottom of district
737
+ ctx.textBaseline = 'bottom';
738
+ const bottomPadding = 2;
739
+ ctx.fillText(
740
+ districtName,
741
+ labelX + labelWidth / 2,
742
+ labelY + bounds.height - bottomPadding,
743
+ );
744
+ }
745
+ }
746
+
747
+ ctx.restore();
748
+ }
749
+ } // End of !hasCover check
750
+ });
751
+ }
752
+
753
+ /**
754
+ * Draw a React symbol (⚛) at the given position
755
+ */
756
+ function drawReactSymbol(
757
+ ctx: CanvasRenderingContext2D,
758
+ x: number,
759
+ y: number,
760
+ size: number,
761
+ color: string = '#00D8FF',
762
+ glow: boolean = true,
763
+ ) {
764
+ ctx.save();
765
+
766
+ // Position and setup
767
+ ctx.translate(x, y);
768
+
769
+ // Glow effect for React symbol
770
+ if (glow) {
771
+ ctx.shadowColor = color;
772
+ ctx.shadowBlur = 8;
773
+ }
774
+
775
+ // Draw the React symbol (⚛)
776
+ ctx.fillStyle = color;
777
+ ctx.font = `${size}px Arial`;
778
+ ctx.textAlign = 'center';
779
+ ctx.textBaseline = 'middle';
780
+ ctx.fillText('⚛', 0, 0);
781
+
782
+ ctx.restore();
783
+ }
784
+
785
+ /**
786
+ * Check if a file is a React file (JSX/TSX)
787
+ */
788
+ function isReactFile(fileExtension?: string): boolean {
789
+ if (!fileExtension) return false;
790
+ const ext = fileExtension.toLowerCase();
791
+ return ext === '.jsx' || ext === '.tsx';
792
+ }
793
+
794
+ // Draw buildings with layer support
795
+ export function drawLayeredBuildings(
796
+ ctx: CanvasRenderingContext2D,
797
+ buildings: CityBuilding[],
798
+ worldToCanvas: (x: number, z: number) => { x: number; y: number },
799
+ scale: number,
800
+ layers: HighlightLayer[],
801
+ hoveredBuilding?: CityBuilding | null,
802
+ defaultBuildingColor?: string,
803
+ showFileNames?: boolean,
804
+ hoverBorderColor?: string,
805
+ disableOpacityDimming?: boolean,
806
+ showFileTypeIcons?: boolean,
807
+ borderRadius: number = 0, // Border radius for buildings (default: 0 - sharp corners)
808
+ ) {
809
+ buildings.forEach(building => {
810
+ const pos = worldToCanvas(building.position.x, building.position.z);
811
+
812
+ // Calculate building dimensions
813
+ let width: number, height: number;
814
+
815
+ if (building.dimensions[0] > 50 || building.dimensions[2] > 50) {
816
+ width = building.dimensions[0] * scale * 0.95;
817
+ height = building.dimensions[2] * scale * 0.95;
818
+ } else {
819
+ const size = Math.max(
820
+ 2,
821
+ Math.max(building.dimensions[0], building.dimensions[2]) * scale * 0.9,
822
+ );
823
+ width = size;
824
+ height = size;
825
+ }
826
+
827
+ const bounds = {
828
+ x: pos.x - width / 2,
829
+ y: pos.y - height / 2,
830
+ width: width,
831
+ height: height,
832
+ };
833
+
834
+ // Get layer matches for this building - only check file items, not parent directories
835
+ const layerMatches = getLayerItemsForPath(building.path, layers).filter(
836
+ match => match.item.type === 'file',
837
+ ); // Only apply file-specific highlights to buildings
838
+ const hasLayerHighlight = layerMatches.length > 0;
839
+ const isHovered = hoveredBuilding === building;
840
+
841
+ // Building color
842
+ let color: string;
843
+ if (building.color) {
844
+ color = building.color;
845
+ } else {
846
+ color = defaultBuildingColor || '#A1A7AE';
847
+ }
848
+
849
+ // Opacity
850
+ let opacity = 1;
851
+ if (!disableOpacityDimming) {
852
+ opacity = hasLayerHighlight ? 1 : 0.3;
853
+ }
854
+
855
+ // Draw building with configurable border radius
856
+ ctx.globalAlpha = opacity;
857
+ ctx.fillStyle = color;
858
+ const actualRadius = Math.min(borderRadius, Math.min(bounds.width, bounds.height) / 6);
859
+
860
+ if (actualRadius > 0) {
861
+ drawRoundedRect(
862
+ ctx,
863
+ bounds.x,
864
+ bounds.y,
865
+ bounds.width,
866
+ bounds.height,
867
+ actualRadius,
868
+ true,
869
+ false,
870
+ );
871
+ } else {
872
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
873
+ }
874
+ ctx.globalAlpha = 1;
875
+
876
+ // Apply layer-specific rendering
877
+ for (const match of layerMatches) {
878
+ applyLayerRendering(ctx, bounds, match.layer, match.item, scale, actualRadius);
879
+ }
880
+
881
+ // Hover effect with configurable border radius
882
+ if (isHovered) {
883
+ ctx.strokeStyle = hoverBorderColor ? `${hoverBorderColor}CC` : 'rgba(255, 255, 255, 0.8)';
884
+ ctx.lineWidth = 1;
885
+ ctx.setLineDash([]);
886
+ if (actualRadius > 0) {
887
+ drawRoundedRect(
888
+ ctx,
889
+ bounds.x - 1,
890
+ bounds.y - 1,
891
+ bounds.width + 2,
892
+ bounds.height + 2,
893
+ actualRadius,
894
+ false,
895
+ true,
896
+ );
897
+ } else {
898
+ ctx.strokeRect(bounds.x - 1, bounds.y - 1, bounds.width + 2, bounds.height + 2);
899
+ }
900
+ }
901
+
902
+ // Draw React symbol for JSX/TSX files (only if enabled)
903
+ if (showFileTypeIcons && isReactFile(building.fileExtension)) {
904
+ // Position React symbol centered in the building
905
+ // Size is 75% of the smaller dimension
906
+ const reactSize = Math.min(width, height) * 0.75;
907
+ const reactX = pos.x;
908
+ const reactY = pos.y;
909
+ drawReactSymbol(ctx, reactX, reactY, reactSize);
910
+ }
911
+
912
+ // Draw filename if enabled
913
+ if (showFileNames && width > 100 && height > 30) {
914
+ const fileName = building.path.split('/').pop() || '';
915
+
916
+ ctx.save();
917
+
918
+ const fontSize = Math.min(30, Math.max(10, Math.floor(Math.min(width, height) * 0.3)));
919
+ ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
920
+ ctx.textAlign = 'center';
921
+ ctx.textBaseline = 'middle';
922
+
923
+ const textMetrics = ctx.measureText(fileName);
924
+ const textWidth = textMetrics.width;
925
+
926
+ if (textWidth < width - 8) {
927
+ // Background for contrast
928
+ const textPadding = 2;
929
+ const textHeight = fontSize * 1.2;
930
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
931
+ ctx.fillRect(
932
+ pos.x - textWidth / 2 - textPadding,
933
+ pos.y - textHeight / 2,
934
+ textWidth + textPadding * 2,
935
+ textHeight,
936
+ );
937
+
938
+ // Text
939
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
940
+ ctx.fillText(fileName, pos.x, pos.y);
941
+ }
942
+
943
+ ctx.restore();
944
+ }
945
+ });
946
+ }