@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.
- package/dist/builder/cityDataUtils.d.ts +15 -0
- package/dist/builder/cityDataUtils.d.ts.map +1 -0
- package/dist/builder/cityDataUtils.js +348 -0
- package/dist/components/ArchitectureMapHighlightLayers.d.ts +63 -0
- package/dist/components/ArchitectureMapHighlightLayers.d.ts.map +1 -0
- package/dist/components/ArchitectureMapHighlightLayers.js +1040 -0
- package/dist/components/CityViewWithReactFlow.d.ts +14 -0
- package/dist/components/CityViewWithReactFlow.d.ts.map +1 -0
- package/dist/components/CityViewWithReactFlow.js +266 -0
- package/dist/config/files.json +996 -0
- package/dist/hooks/useCodeCityData.d.ts +21 -0
- package/dist/hooks/useCodeCityData.d.ts.map +1 -0
- package/dist/hooks/useCodeCityData.js +57 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/render/client/drawLayeredBuildings.d.ts +51 -0
- package/dist/render/client/drawLayeredBuildings.d.ts.map +1 -0
- package/dist/render/client/drawLayeredBuildings.js +650 -0
- package/dist/stories/ArchitectureMapGridLayout.stories.d.ts +73 -0
- package/dist/stories/ArchitectureMapGridLayout.stories.d.ts.map +1 -0
- package/dist/stories/ArchitectureMapGridLayout.stories.js +345 -0
- package/dist/stories/ArchitectureMapHighlightLayers.stories.d.ts +78 -0
- package/dist/stories/ArchitectureMapHighlightLayers.stories.d.ts.map +1 -0
- package/dist/stories/ArchitectureMapHighlightLayers.stories.js +270 -0
- package/dist/stories/CityViewWithReactFlow.stories.d.ts +24 -0
- package/dist/stories/CityViewWithReactFlow.stories.d.ts.map +1 -0
- package/dist/stories/CityViewWithReactFlow.stories.js +778 -0
- package/dist/stories/sample-data.d.ts +4 -0
- package/dist/stories/sample-data.d.ts.map +1 -0
- package/dist/stories/sample-data.js +268 -0
- package/dist/types/react-types.d.ts +17 -0
- package/dist/types/react-types.d.ts.map +1 -0
- package/dist/types/react-types.js +4 -0
- package/dist/utils/fileColorHighlightLayers.d.ts +86 -0
- package/dist/utils/fileColorHighlightLayers.d.ts.map +1 -0
- package/dist/utils/fileColorHighlightLayers.js +283 -0
- package/package.json +49 -0
- package/src/builder/cityDataUtils.ts +430 -0
- package/src/components/ArchitectureMapHighlightLayers.tsx +1518 -0
- package/src/components/CityViewWithReactFlow.tsx +365 -0
- package/src/config/files.json +996 -0
- package/src/hooks/useCodeCityData.ts +82 -0
- package/src/index.ts +64 -0
- package/src/render/client/drawLayeredBuildings.ts +946 -0
- package/src/stories/ArchitectureMapGridLayout.stories.tsx +410 -0
- package/src/stories/ArchitectureMapHighlightLayers.stories.tsx +312 -0
- package/src/stories/CityViewWithReactFlow.stories.tsx +787 -0
- package/src/stories/sample-data.ts +301 -0
- package/src/types/react-types.ts +18 -0
- 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
|
+
}
|