@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,650 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.drawGrid = drawGrid;
4
+ exports.drawLayeredDistricts = drawLayeredDistricts;
5
+ exports.drawLayeredBuildings = drawLayeredBuildings;
6
+ // Helper to check if a path matches a layer item
7
+ function pathMatchesItem(path, item, checkType = 'children') {
8
+ if (item.type === 'file') {
9
+ return path === item.path;
10
+ }
11
+ else {
12
+ // Directory match
13
+ if (checkType === 'exact') {
14
+ // Only match the directory itself, not its children
15
+ return path === item.path;
16
+ }
17
+ else {
18
+ // Match directory and all its children (original behavior)
19
+ return path === item.path || path.startsWith(item.path + '/');
20
+ }
21
+ }
22
+ }
23
+ // Helper function to draw rounded rectangles
24
+ function drawRoundedRect(ctx, x, y, width, height, radius, fill, stroke) {
25
+ ctx.beginPath();
26
+ ctx.moveTo(x + radius, y);
27
+ ctx.lineTo(x + width - radius, y);
28
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
29
+ ctx.lineTo(x + width, y + height - radius);
30
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
31
+ ctx.lineTo(x + radius, y + height);
32
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
33
+ ctx.lineTo(x, y + radius);
34
+ ctx.quadraticCurveTo(x, y, x + radius, y);
35
+ ctx.closePath();
36
+ if (fill)
37
+ ctx.fill();
38
+ if (stroke)
39
+ ctx.stroke();
40
+ }
41
+ // Get all layer items that apply to a given path
42
+ function getLayerItemsForPath(path, layers, checkType = 'children') {
43
+ const matches = [];
44
+ for (const layer of layers) {
45
+ if (!layer.enabled)
46
+ continue;
47
+ for (const item of layer.items) {
48
+ if (pathMatchesItem(path, item, checkType)) {
49
+ matches.push({ layer, item });
50
+ }
51
+ }
52
+ }
53
+ // Sort by priority (highest first)
54
+ return matches.sort((a, b) => b.layer.priority - a.layer.priority);
55
+ }
56
+ // Draw grid helper (copied from original)
57
+ function drawGrid(ctx, width, height, gridSize) {
58
+ ctx.save();
59
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
60
+ ctx.lineWidth = 1;
61
+ for (let x = 0; x < width; x += gridSize) {
62
+ ctx.beginPath();
63
+ ctx.moveTo(x, 0);
64
+ ctx.lineTo(x, height);
65
+ ctx.stroke();
66
+ }
67
+ for (let y = 0; y < height; y += gridSize) {
68
+ ctx.beginPath();
69
+ ctx.moveTo(0, y);
70
+ ctx.lineTo(width, y);
71
+ ctx.stroke();
72
+ }
73
+ ctx.restore();
74
+ }
75
+ // Helper function to break text intelligently
76
+ function breakTextIntelligently(text) {
77
+ // First try hyphen
78
+ if (text.includes('-')) {
79
+ const parts = text.split('-');
80
+ // If we get reasonable parts, use them
81
+ if (parts.length >= 2 && parts.every(p => p.length > 0)) {
82
+ // Group parts to avoid too many lines
83
+ if (parts.length > 3) {
84
+ const mid = Math.ceil(parts.length / 2);
85
+ return [parts.slice(0, mid).join('-'), parts.slice(mid).join('-')];
86
+ }
87
+ return parts;
88
+ }
89
+ }
90
+ // Then try underscore
91
+ if (text.includes('_')) {
92
+ const parts = text.split('_');
93
+ if (parts.length >= 2 && parts.every(p => p.length > 0)) {
94
+ if (parts.length > 3) {
95
+ const mid = Math.ceil(parts.length / 2);
96
+ return [parts.slice(0, mid).join('_'), parts.slice(mid).join('_')];
97
+ }
98
+ return parts;
99
+ }
100
+ }
101
+ // Then try camelCase
102
+ const camelCaseParts = text.match(/[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\b)/g);
103
+ if (camelCaseParts && camelCaseParts.length >= 2) {
104
+ if (camelCaseParts.length > 3) {
105
+ const mid = Math.ceil(camelCaseParts.length / 2);
106
+ return [camelCaseParts.slice(0, mid).join(''), camelCaseParts.slice(mid).join('')];
107
+ }
108
+ return camelCaseParts;
109
+ }
110
+ // If no good break points, try breaking at word boundaries
111
+ const words = text.split(/\s+/);
112
+ if (words.length >= 2) {
113
+ const mid = Math.ceil(words.length / 2);
114
+ return [words.slice(0, mid).join(' '), words.slice(mid).join(' ')];
115
+ }
116
+ // Last resort - break in the middle
117
+ if (text.length > 20) {
118
+ const mid = Math.floor(text.length / 2);
119
+ // Try to find a better break point near the middle
120
+ const searchRange = Math.floor(text.length * 0.2);
121
+ let breakPoint = mid;
122
+ // Look for special characters near the middle
123
+ for (let i = 0; i < searchRange; i++) {
124
+ if (mid + i < text.length && /[\W_]/.test(text[mid + i])) {
125
+ breakPoint = mid + i;
126
+ break;
127
+ }
128
+ if (mid - i >= 0 && /[\W_]/.test(text[mid - i])) {
129
+ breakPoint = mid - i;
130
+ break;
131
+ }
132
+ }
133
+ return [text.substring(0, breakPoint).trim(), text.substring(breakPoint).trim()];
134
+ }
135
+ // Return as single line if text is short
136
+ return [text];
137
+ }
138
+ // Render strategies implementation
139
+ function renderBorderStrategy(ctx, bounds, layer, item, borderRadius = 0) {
140
+ ctx.save();
141
+ ctx.strokeStyle = layer.color;
142
+ ctx.lineWidth = layer.borderWidth || 2;
143
+ ctx.globalAlpha = layer.opacity || 1;
144
+ ctx.setLineDash([]);
145
+ if (item.type === 'directory') {
146
+ // Sharp corners for directories
147
+ ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
148
+ }
149
+ else {
150
+ // Use configurable border radius for files/buildings
151
+ const radius = Math.min(borderRadius, Math.min(bounds.width, bounds.height) / 6);
152
+ if (radius > 0) {
153
+ drawRoundedRect(ctx, bounds.x, bounds.y, bounds.width, bounds.height, radius, false, true);
154
+ }
155
+ else {
156
+ ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
157
+ }
158
+ }
159
+ ctx.restore();
160
+ }
161
+ function renderFillStrategy(ctx, bounds, layer, item, borderRadius = 0) {
162
+ ctx.save();
163
+ ctx.fillStyle = layer.color;
164
+ ctx.globalAlpha = layer.opacity || 0.3;
165
+ if (item.type === 'directory') {
166
+ // Sharp corners for directories
167
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
168
+ }
169
+ else {
170
+ // Use configurable border radius for files/buildings
171
+ const radius = Math.min(borderRadius, Math.min(bounds.width, bounds.height) / 6);
172
+ if (radius > 0) {
173
+ drawRoundedRect(ctx, bounds.x, bounds.y, bounds.width, bounds.height, radius, true, false);
174
+ }
175
+ else {
176
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
177
+ }
178
+ }
179
+ ctx.restore();
180
+ }
181
+ function renderGlowStrategy(ctx, bounds, layer, item, borderRadius = 0) {
182
+ ctx.save();
183
+ ctx.shadowColor = layer.color;
184
+ ctx.shadowBlur = 10;
185
+ ctx.fillStyle = layer.color;
186
+ ctx.globalAlpha = layer.opacity || 0.5;
187
+ if (item.type === 'directory') {
188
+ // Sharp corners for directories
189
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
190
+ }
191
+ else {
192
+ // Use configurable border radius for files/buildings
193
+ const radius = Math.min(borderRadius, Math.min(bounds.width, bounds.height) / 6);
194
+ if (radius > 0) {
195
+ drawRoundedRect(ctx, bounds.x, bounds.y, bounds.width, bounds.height, radius, true, false);
196
+ }
197
+ else {
198
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
199
+ }
200
+ }
201
+ ctx.restore();
202
+ }
203
+ function renderPatternStrategy(ctx, bounds, layer, _item) {
204
+ ctx.save();
205
+ ctx.strokeStyle = layer.color;
206
+ ctx.lineWidth = 1;
207
+ ctx.globalAlpha = layer.opacity || 0.5;
208
+ // Create diagonal line pattern
209
+ const spacing = 5;
210
+ ctx.beginPath();
211
+ for (let offset = -bounds.height; offset < bounds.width; offset += spacing) {
212
+ ctx.moveTo(bounds.x + offset, bounds.y);
213
+ ctx.lineTo(bounds.x + offset + bounds.height, bounds.y + bounds.height);
214
+ }
215
+ ctx.stroke();
216
+ ctx.restore();
217
+ }
218
+ function renderCoverStrategy(ctx, bounds, layer, item, _scale) {
219
+ const coverOptions = item.coverOptions || {};
220
+ ctx.save();
221
+ // Create clipping path for the cover area - always use sharp corners for directories
222
+ ctx.beginPath();
223
+ ctx.rect(bounds.x, bounds.y, bounds.width, bounds.height);
224
+ ctx.clip();
225
+ // Background - always use sharp corners for directory covers
226
+ ctx.fillStyle = coverOptions.backgroundColor || layer.color;
227
+ ctx.globalAlpha = coverOptions.opacity || 0.8;
228
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
229
+ // Reset alpha for text/icon
230
+ ctx.globalAlpha = 1;
231
+ // Image/SVG (takes precedence over text icon)
232
+ if (coverOptions.image) {
233
+ const img = new Image();
234
+ img.onload = () => {
235
+ const imageSize = coverOptions.iconSize || Math.min(bounds.width, bounds.height) * 0.4;
236
+ const imageX = bounds.x + bounds.width / 2 - imageSize / 2;
237
+ const imageY = coverOptions.text
238
+ ? bounds.y + bounds.height * 0.25
239
+ : bounds.y + bounds.height / 2 - imageSize / 2;
240
+ ctx.drawImage(img, imageX, imageY, imageSize, imageSize);
241
+ };
242
+ img.src = coverOptions.image;
243
+ }
244
+ // Text Icon (fallback if no image)
245
+ else if (coverOptions.icon) {
246
+ const iconSize = coverOptions.iconSize || Math.min(bounds.width, bounds.height) * 0.3;
247
+ ctx.font = `${iconSize}px Arial`;
248
+ ctx.fillStyle = '#ffffff';
249
+ ctx.textAlign = 'center';
250
+ ctx.textBaseline = 'middle';
251
+ const iconY = coverOptions.text
252
+ ? bounds.y + bounds.height * 0.35
253
+ : bounds.y + bounds.height * 0.5;
254
+ ctx.fillText(coverOptions.icon, bounds.x + bounds.width / 2, iconY);
255
+ }
256
+ // Text
257
+ if (coverOptions.text) {
258
+ let textSize = coverOptions.textSize || Math.min(bounds.width, bounds.height) * 0.15;
259
+ ctx.font = `${textSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
260
+ ctx.fillStyle = '#ffffff';
261
+ ctx.textAlign = 'center';
262
+ // Measure text and see if it fits
263
+ const padding = bounds.width * 0.1;
264
+ const maxWidth = bounds.width - padding * 2;
265
+ const textToRender = coverOptions.text;
266
+ let textWidth = ctx.measureText(textToRender).width;
267
+ // If text is too wide, try breaking it up
268
+ if (textWidth > maxWidth) {
269
+ // Try to break by common separators
270
+ const breakableText = breakTextIntelligently(coverOptions.text);
271
+ if (breakableText.length > 1) {
272
+ // Render multiple lines
273
+ const lineHeight = textSize * 1.2;
274
+ const totalHeight = lineHeight * breakableText.length;
275
+ const startY = bounds.y + (bounds.height - totalHeight) / 2 + lineHeight / 2;
276
+ // Adjust text size if needed to fit all lines
277
+ const maxLines = Math.floor((bounds.height * 0.8) / lineHeight);
278
+ if (breakableText.length > maxLines) {
279
+ textSize = textSize * (maxLines / breakableText.length);
280
+ ctx.font = `${textSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
281
+ }
282
+ breakableText.forEach((line, index) => {
283
+ const y = startY + index * lineHeight;
284
+ ctx.textBaseline = 'middle';
285
+ ctx.fillText(line, bounds.x + bounds.width / 2, y);
286
+ });
287
+ }
288
+ else {
289
+ // Single line - scale down text if needed
290
+ while (textWidth > maxWidth && textSize > 8) {
291
+ textSize--;
292
+ ctx.font = `${textSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
293
+ textWidth = ctx.measureText(textToRender).width;
294
+ }
295
+ const textY = coverOptions.icon
296
+ ? bounds.y + bounds.height * 0.65
297
+ : bounds.y + bounds.height * 0.5;
298
+ ctx.textBaseline = 'middle';
299
+ ctx.fillText(textToRender, bounds.x + bounds.width / 2, textY);
300
+ }
301
+ }
302
+ else {
303
+ // Text fits - render normally
304
+ const textY = coverOptions.icon
305
+ ? bounds.y + bounds.height * 0.65
306
+ : bounds.y + bounds.height * 0.5;
307
+ ctx.textBaseline = 'middle';
308
+ ctx.fillText(textToRender, bounds.x + bounds.width / 2, textY);
309
+ }
310
+ }
311
+ ctx.restore();
312
+ }
313
+ // Apply layer rendering to a specific item
314
+ function applyLayerRendering(ctx, bounds, layer, item, scale, borderRadius = 0) {
315
+ const strategy = item.renderStrategy || 'border';
316
+ switch (strategy) {
317
+ case 'border':
318
+ renderBorderStrategy(ctx, bounds, layer, item, borderRadius);
319
+ break;
320
+ case 'fill':
321
+ renderFillStrategy(ctx, bounds, layer, item, borderRadius);
322
+ break;
323
+ case 'glow':
324
+ renderGlowStrategy(ctx, bounds, layer, item, borderRadius);
325
+ break;
326
+ case 'pattern':
327
+ renderPatternStrategy(ctx, bounds, layer, item);
328
+ break;
329
+ case 'cover':
330
+ renderCoverStrategy(ctx, bounds, layer, item, scale);
331
+ break;
332
+ case 'custom':
333
+ if (item.customRender) {
334
+ item.customRender(ctx, bounds, scale);
335
+ }
336
+ break;
337
+ }
338
+ }
339
+ // Draw districts with layer support
340
+ function drawLayeredDistricts(ctx, districts, worldToCanvas, scale, // This includes the zoom scale for text proportionality
341
+ layers, hoveredDistrict, fullSize, defaultDirectoryColor, layoutConfig, abstractedPaths, // Paths of directories that are abstracted (have covers)
342
+ showDirectoryLabels = true, borderRadius = 0) {
343
+ districts.forEach(district => {
344
+ const districtPath = district.path || '';
345
+ const isRoot = !districtPath || districtPath === '';
346
+ // Check if this root district has layer matches (like covers) - if so, render it
347
+ const rootLayerMatches = getLayerItemsForPath(districtPath, layers, 'exact');
348
+ const hasLayerRendering = rootLayerMatches.length > 0;
349
+ // Skip root districts unless they have layer rendering (covers, highlights, etc.)
350
+ if (isRoot && !hasLayerRendering)
351
+ return;
352
+ const canvasPos = worldToCanvas(district.worldBounds.minX, district.worldBounds.minZ);
353
+ const width = (district.worldBounds.maxX - district.worldBounds.minX) * scale;
354
+ const depth = (district.worldBounds.maxZ - district.worldBounds.minZ) * scale;
355
+ const bounds = {
356
+ x: canvasPos.x,
357
+ y: canvasPos.y,
358
+ width: width,
359
+ height: depth,
360
+ };
361
+ // Get base color
362
+ const baseColorHex = defaultDirectoryColor || '#4B5155';
363
+ const hexToRgb = (hex) => {
364
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
365
+ return result
366
+ ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
367
+ : [75, 80, 85];
368
+ };
369
+ const baseColor = hexToRgb(baseColorHex);
370
+ // Base rendering
371
+ let opacity = 0.3;
372
+ let borderOpacity = 0.6;
373
+ // Check if district has layer highlighting - use exact matching for districts
374
+ const layerMatches = getLayerItemsForPath(districtPath, layers, 'exact');
375
+ const hasLayerHighlight = layerMatches.length > 0;
376
+ if (hasLayerHighlight) {
377
+ opacity = 0.5;
378
+ borderOpacity = 0.8;
379
+ }
380
+ const isHovered = hoveredDistrict === district;
381
+ if (isHovered) {
382
+ opacity = Math.max(opacity, 0.4);
383
+ borderOpacity = Math.max(borderOpacity, 0.7);
384
+ }
385
+ // Fill districts with configurable border radius
386
+ ctx.fillStyle = `rgba(${baseColor[0]}, ${baseColor[1]}, ${baseColor[2]}, ${opacity})`;
387
+ if (borderRadius > 0) {
388
+ drawRoundedRect(ctx, bounds.x, bounds.y, bounds.width, bounds.height, borderRadius, true, false);
389
+ }
390
+ else {
391
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
392
+ }
393
+ // Border with configurable border radius
394
+ ctx.strokeStyle = `rgba(${Math.min(255, baseColor[0] + 40)}, ${Math.min(255, baseColor[1] + 40)}, ${Math.min(255, baseColor[2] + 40)}, ${borderOpacity})`;
395
+ ctx.lineWidth = 1;
396
+ if (borderRadius > 0) {
397
+ drawRoundedRect(ctx, bounds.x, bounds.y, bounds.width, bounds.height, borderRadius, false, true);
398
+ }
399
+ else {
400
+ ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
401
+ }
402
+ // Apply layer-specific rendering (covers, etc.)
403
+ for (const match of layerMatches) {
404
+ // Only apply if this is specifically a directory item or if it's a cover strategy
405
+ if (match.item.type === 'directory' || match.item.renderStrategy === 'cover') {
406
+ applyLayerRendering(ctx, bounds, match.layer, match.item, scale, borderRadius);
407
+ }
408
+ }
409
+ // Hover highlight with configurable border radius
410
+ if (isHovered) {
411
+ ctx.strokeStyle = '#ffffff';
412
+ ctx.lineWidth = 2;
413
+ if (borderRadius > 0) {
414
+ drawRoundedRect(ctx, bounds.x, bounds.y, bounds.width, bounds.height, borderRadius, false, true);
415
+ }
416
+ else {
417
+ ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
418
+ }
419
+ }
420
+ // Check if this district has a cover layer item or is abstracted
421
+ const hasCover = layerMatches.some(match => match.item.renderStrategy === 'cover' && match.layer.enabled) ||
422
+ (abstractedPaths && abstractedPaths.has(districtPath));
423
+ // Skip label rendering if district has a cover or is abstracted
424
+ if (showDirectoryLabels && !hasCover) {
425
+ // Check if this is a grid cell with dedicated label space
426
+ const isGridCell = district.path?.startsWith('grid-cell-') && district.label;
427
+ let districtName;
428
+ let labelBounds;
429
+ if (isGridCell && district.label) {
430
+ // Use grid cell label configuration
431
+ districtName = district.label.text;
432
+ // Transform label bounds from world space to canvas space
433
+ const labelCanvasPos = worldToCanvas(district.label.bounds.minX, district.label.bounds.minZ);
434
+ const labelCanvasWidth = (district.label.bounds.maxX - district.label.bounds.minX) * scale;
435
+ const labelCanvasHeight = (district.label.bounds.maxZ - district.label.bounds.minZ) * scale;
436
+ labelBounds = {
437
+ x: labelCanvasPos.x,
438
+ y: labelCanvasPos.y,
439
+ width: labelCanvasWidth,
440
+ height: labelCanvasHeight,
441
+ };
442
+ }
443
+ else {
444
+ // Use traditional district name from path
445
+ districtName = district.path?.split('/').pop() || 'root';
446
+ }
447
+ // Calculate actual available label space
448
+ let availableLabelHeight;
449
+ let actualLabelHeight;
450
+ let labelWidth;
451
+ let labelX;
452
+ let labelY;
453
+ if (labelBounds) {
454
+ // For grid cells, use dedicated label space
455
+ availableLabelHeight = labelBounds.height;
456
+ actualLabelHeight = labelBounds.height * scale;
457
+ labelWidth = labelBounds.width;
458
+ labelX = labelBounds.x;
459
+ labelY = labelBounds.y;
460
+ }
461
+ else {
462
+ // For traditional districts, use padding space
463
+ availableLabelHeight = layoutConfig?.paddingTop || (fullSize ? 24 : 20);
464
+ actualLabelHeight = availableLabelHeight * scale;
465
+ labelWidth = bounds.width;
466
+ labelX = bounds.x;
467
+ labelY = bounds.y;
468
+ }
469
+ // Only check absolute label space, not ratio
470
+ const minMeaningfulLabelSpace = fullSize ? 12 : 10; // Just need readable space
471
+ // For grid cells with dedicated label space, always show the label
472
+ const labelSpaceSufficient = labelBounds
473
+ ? true
474
+ : actualLabelHeight >= minMeaningfulLabelSpace;
475
+ if (labelSpaceSufficient) {
476
+ ctx.save();
477
+ ctx.fillStyle = hasLayerHighlight ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)';
478
+ // Step 1: Start with proportional sizing (for visual consistency)
479
+ // For grid cell labels, use larger base size
480
+ const baseFontSize = labelBounds ? (fullSize ? 28 : 20) : fullSize ? 18 : 13;
481
+ const scaleFactor = Math.min(Math.max(scale / 1.0, 0.5), 3.0);
482
+ const proportionalFontSize = baseFontSize * scaleFactor;
483
+ // Step 2: Clamp to readable bounds (for usability)
484
+ const minReadableSize = labelBounds ? 14 : 10; // Larger minimum for grid labels
485
+ // No max for grid labels - let them scale with cell size, regular labels still capped
486
+ const maxReadableSize = labelBounds ? Number.MAX_SAFE_INTEGER : 24;
487
+ const clampedSize = Math.min(Math.max(proportionalFontSize, minReadableSize), maxReadableSize);
488
+ // Step 3: Apply container-fit within those bounds (for proper fitting)
489
+ const horizontalPadding = 8; // Small padding from edges
490
+ const availableWidth = labelWidth - horizontalPadding * 2;
491
+ // Start with the clamped proportional size, but respect container limits
492
+ let fontSize = Math.min(clampedSize, availableLabelHeight * 0.8); // Use 80% of available height as max
493
+ // Use bold font for grid cell labels, normal for others
494
+ const fontWeight = labelBounds ? 'bold' : 'normal';
495
+ ctx.font = `${fontWeight} ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
496
+ let textWidth = ctx.measureText(districtName).width;
497
+ // Scale down if text is too wide for the container
498
+ while (textWidth > availableWidth && fontSize > minReadableSize) {
499
+ fontSize--;
500
+ ctx.font = `${fontWeight} ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
501
+ textWidth = ctx.measureText(districtName).width;
502
+ }
503
+ // For grid cells, always render the label. For others, check minimum size
504
+ if (labelBounds || fontSize >= minReadableSize) {
505
+ ctx.textAlign = 'center';
506
+ if (labelBounds) {
507
+ // For grid cells, position label in dedicated label space
508
+ // Position near bottom of label area for better visual connection to content
509
+ ctx.textBaseline = 'bottom';
510
+ ctx.fillText(districtName, labelX + labelWidth / 2, labelY + labelBounds.height * 0.95);
511
+ }
512
+ else {
513
+ // For traditional districts, position at bottom of district
514
+ ctx.textBaseline = 'bottom';
515
+ const bottomPadding = 2;
516
+ ctx.fillText(districtName, labelX + labelWidth / 2, labelY + bounds.height - bottomPadding);
517
+ }
518
+ }
519
+ ctx.restore();
520
+ }
521
+ } // End of !hasCover check
522
+ });
523
+ }
524
+ /**
525
+ * Draw a React symbol (⚛) at the given position
526
+ */
527
+ function drawReactSymbol(ctx, x, y, size, color = '#00D8FF', glow = true) {
528
+ ctx.save();
529
+ // Position and setup
530
+ ctx.translate(x, y);
531
+ // Glow effect for React symbol
532
+ if (glow) {
533
+ ctx.shadowColor = color;
534
+ ctx.shadowBlur = 8;
535
+ }
536
+ // Draw the React symbol (⚛)
537
+ ctx.fillStyle = color;
538
+ ctx.font = `${size}px Arial`;
539
+ ctx.textAlign = 'center';
540
+ ctx.textBaseline = 'middle';
541
+ ctx.fillText('⚛', 0, 0);
542
+ ctx.restore();
543
+ }
544
+ /**
545
+ * Check if a file is a React file (JSX/TSX)
546
+ */
547
+ function isReactFile(fileExtension) {
548
+ if (!fileExtension)
549
+ return false;
550
+ const ext = fileExtension.toLowerCase();
551
+ return ext === '.jsx' || ext === '.tsx';
552
+ }
553
+ // Draw buildings with layer support
554
+ function drawLayeredBuildings(ctx, buildings, worldToCanvas, scale, layers, hoveredBuilding, defaultBuildingColor, showFileNames, hoverBorderColor, disableOpacityDimming, showFileTypeIcons, borderRadius = 0) {
555
+ buildings.forEach(building => {
556
+ const pos = worldToCanvas(building.position.x, building.position.z);
557
+ // Calculate building dimensions
558
+ let width, height;
559
+ if (building.dimensions[0] > 50 || building.dimensions[2] > 50) {
560
+ width = building.dimensions[0] * scale * 0.95;
561
+ height = building.dimensions[2] * scale * 0.95;
562
+ }
563
+ else {
564
+ const size = Math.max(2, Math.max(building.dimensions[0], building.dimensions[2]) * scale * 0.9);
565
+ width = size;
566
+ height = size;
567
+ }
568
+ const bounds = {
569
+ x: pos.x - width / 2,
570
+ y: pos.y - height / 2,
571
+ width: width,
572
+ height: height,
573
+ };
574
+ // Get layer matches for this building - only check file items, not parent directories
575
+ const layerMatches = getLayerItemsForPath(building.path, layers).filter(match => match.item.type === 'file'); // Only apply file-specific highlights to buildings
576
+ const hasLayerHighlight = layerMatches.length > 0;
577
+ const isHovered = hoveredBuilding === building;
578
+ // Building color
579
+ let color;
580
+ if (building.color) {
581
+ color = building.color;
582
+ }
583
+ else {
584
+ color = defaultBuildingColor || '#A1A7AE';
585
+ }
586
+ // Opacity
587
+ let opacity = 1;
588
+ if (!disableOpacityDimming) {
589
+ opacity = hasLayerHighlight ? 1 : 0.3;
590
+ }
591
+ // Draw building with configurable border radius
592
+ ctx.globalAlpha = opacity;
593
+ ctx.fillStyle = color;
594
+ const actualRadius = Math.min(borderRadius, Math.min(bounds.width, bounds.height) / 6);
595
+ if (actualRadius > 0) {
596
+ drawRoundedRect(ctx, bounds.x, bounds.y, bounds.width, bounds.height, actualRadius, true, false);
597
+ }
598
+ else {
599
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
600
+ }
601
+ ctx.globalAlpha = 1;
602
+ // Apply layer-specific rendering
603
+ for (const match of layerMatches) {
604
+ applyLayerRendering(ctx, bounds, match.layer, match.item, scale, actualRadius);
605
+ }
606
+ // Hover effect with configurable border radius
607
+ if (isHovered) {
608
+ ctx.strokeStyle = hoverBorderColor ? `${hoverBorderColor}CC` : 'rgba(255, 255, 255, 0.8)';
609
+ ctx.lineWidth = 1;
610
+ ctx.setLineDash([]);
611
+ if (actualRadius > 0) {
612
+ drawRoundedRect(ctx, bounds.x - 1, bounds.y - 1, bounds.width + 2, bounds.height + 2, actualRadius, false, true);
613
+ }
614
+ else {
615
+ ctx.strokeRect(bounds.x - 1, bounds.y - 1, bounds.width + 2, bounds.height + 2);
616
+ }
617
+ }
618
+ // Draw React symbol for JSX/TSX files (only if enabled)
619
+ if (showFileTypeIcons && isReactFile(building.fileExtension)) {
620
+ // Position React symbol centered in the building
621
+ // Size is 75% of the smaller dimension
622
+ const reactSize = Math.min(width, height) * 0.75;
623
+ const reactX = pos.x;
624
+ const reactY = pos.y;
625
+ drawReactSymbol(ctx, reactX, reactY, reactSize);
626
+ }
627
+ // Draw filename if enabled
628
+ if (showFileNames && width > 100 && height > 30) {
629
+ const fileName = building.path.split('/').pop() || '';
630
+ ctx.save();
631
+ const fontSize = Math.min(30, Math.max(10, Math.floor(Math.min(width, height) * 0.3)));
632
+ ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
633
+ ctx.textAlign = 'center';
634
+ ctx.textBaseline = 'middle';
635
+ const textMetrics = ctx.measureText(fileName);
636
+ const textWidth = textMetrics.width;
637
+ if (textWidth < width - 8) {
638
+ // Background for contrast
639
+ const textPadding = 2;
640
+ const textHeight = fontSize * 1.2;
641
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
642
+ ctx.fillRect(pos.x - textWidth / 2 - textPadding, pos.y - textHeight / 2, textWidth + textPadding * 2, textHeight);
643
+ // Text
644
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
645
+ ctx.fillText(fileName, pos.x, pos.y);
646
+ }
647
+ ctx.restore();
648
+ }
649
+ });
650
+ }