@principal-ai/file-city-react 0.5.39 → 0.5.40

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/index.d.ts CHANGED
@@ -16,6 +16,8 @@ export { ThemeProvider, useTheme } from '@principal-ade/industry-theme';
16
16
  export { FileCity3D, resetCamera, DEFAULT_FLAT_PATTERNS } from './components/FileCity3D';
17
17
  export type { FileCity3DProps, AnimationConfig, HighlightLayer as FileCity3DHighlightLayer, IsolationMode, HeightScaling, FlatPattern, ElevatedScopePanel, } from './components/FileCity3D';
18
18
  export type { HighlightLayer as FileCity3DHL } from './components/FileCity3D';
19
+ export { buildFolderElevatedPanels, buildFolderIndex, hashFolderColor, } from './utils/folderElevatedPanels';
20
+ export type { BuildFolderElevatedPanelsOptions } from './utils/folderElevatedPanels';
19
21
  export { resolveVisualizationIntent } from './utils/visualizationResolution';
20
22
  export type { VisualizationIntent, ResolvedVisualizationState, } from './utils/visualizationResolution';
21
23
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,8BAA8B,EAC9B,KAAK,mCAAmC,GACzC,MAAM,6CAA6C,CAAC;AAGrD,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,UAAU,GACX,MAAM,sCAAsC,CAAC;AAG9C,OAAO,EAAE,KAAK,mBAAmB,EAAE,KAAK,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAGvF,OAAO,EACL,gCAAgC,EAChC,6BAA6B,EAC7B,oCAAoC,GACrC,MAAM,yBAAyB,CAAC;AAGjC,OAAO,EACL,8BAA8B,EAC9B,yBAAyB,EACzB,mBAAmB,GACpB,MAAM,kCAAkC,CAAC;AAE1C,YAAY,EACV,gBAAgB,EAChB,gBAAgB,EAChB,qBAAqB,EACrB,kBAAkB,GACnB,MAAM,kCAAkC,CAAC;AAG1C,OAAO,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAGzF,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAG7F,YAAY,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EACZ,sBAAsB,EACtB,QAAQ,EACR,UAAU,GACX,MAAM,iCAAiC,CAAC;AAGzC,OAAO,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAG1E,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC1D,YAAY,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AAG7F,YAAY,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AAGhE,OAAO,EACL,qBAAqB,EACrB,KAAK,0BAA0B,GAChC,MAAM,oCAAoC,CAAC;AAG5C,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,+BAA+B,CAAC;AAGxE,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AACzF,YAAY,EACV,eAAe,EACf,eAAe,EACf,cAAc,IAAI,wBAAwB,EAC1C,aAAa,EACb,aAAa,EACb,WAAW,EACX,kBAAkB,GACnB,MAAM,yBAAyB,CAAC;AAIjC,YAAY,EAAE,cAAc,IAAI,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAI9E,OAAO,EAAE,0BAA0B,EAAE,MAAM,iCAAiC,CAAC;AAC7E,YAAY,EACV,mBAAmB,EACnB,0BAA0B,GAC3B,MAAM,iCAAiC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,8BAA8B,EAC9B,KAAK,mCAAmC,GACzC,MAAM,6CAA6C,CAAC;AAGrD,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,UAAU,GACX,MAAM,sCAAsC,CAAC;AAG9C,OAAO,EAAE,KAAK,mBAAmB,EAAE,KAAK,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAGvF,OAAO,EACL,gCAAgC,EAChC,6BAA6B,EAC7B,oCAAoC,GACrC,MAAM,yBAAyB,CAAC;AAGjC,OAAO,EACL,8BAA8B,EAC9B,yBAAyB,EACzB,mBAAmB,GACpB,MAAM,kCAAkC,CAAC;AAE1C,YAAY,EACV,gBAAgB,EAChB,gBAAgB,EAChB,qBAAqB,EACrB,kBAAkB,GACnB,MAAM,kCAAkC,CAAC;AAG1C,OAAO,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAGzF,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAG7F,YAAY,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EACZ,sBAAsB,EACtB,QAAQ,EACR,UAAU,GACX,MAAM,iCAAiC,CAAC;AAGzC,OAAO,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAG1E,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC1D,YAAY,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AAG7F,YAAY,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AAGhE,OAAO,EACL,qBAAqB,EACrB,KAAK,0BAA0B,GAChC,MAAM,oCAAoC,CAAC;AAG5C,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,+BAA+B,CAAC;AAGxE,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AACzF,YAAY,EACV,eAAe,EACf,eAAe,EACf,cAAc,IAAI,wBAAwB,EAC1C,aAAa,EACb,aAAa,EACb,WAAW,EACX,kBAAkB,GACnB,MAAM,yBAAyB,CAAC;AAIjC,YAAY,EAAE,cAAc,IAAI,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAG9E,OAAO,EACL,yBAAyB,EACzB,gBAAgB,EAChB,eAAe,GAChB,MAAM,8BAA8B,CAAC;AACtC,YAAY,EAAE,gCAAgC,EAAE,MAAM,8BAA8B,CAAC;AAIrF,OAAO,EAAE,0BAA0B,EAAE,MAAM,iCAAiC,CAAC;AAC7E,YAAY,EACV,mBAAmB,EACnB,0BAA0B,GAC3B,MAAM,iCAAiC,CAAC"}
package/dist/index.js CHANGED
@@ -20,6 +20,8 @@ export { CityViewWithReactFlow, } from './components/CityViewWithReactFlow';
20
20
  export { ThemeProvider, useTheme } from '@principal-ade/industry-theme';
21
21
  // 3D visualization component
22
22
  export { FileCity3D, resetCamera, DEFAULT_FLAT_PATTERNS } from './components/FileCity3D';
23
+ // Folder-driven elevated panels (file-tree expansion → 3D umbrella tiles)
24
+ export { buildFolderElevatedPanels, buildFolderIndex, hashFolderColor, } from './utils/folderElevatedPanels';
23
25
  // Visualization resolution utilities
24
26
  // See docs/VISUALIZATION_STATE_RESOLUTION.md for documentation
25
27
  export { resolveVisualizationIntent } from './utils/visualizationResolution';
@@ -0,0 +1,60 @@
1
+ import type { CityData } from '@principal-ai/file-city-builder';
2
+ import type { ElevatedScopePanel } from '../components/FileCity3D';
3
+ /**
4
+ * Stable color for a folder path, picked from a small palette via a string
5
+ * hash. Two folders at the same depth get visibly different colors; the
6
+ * same folder always gets the same color across renders.
7
+ */
8
+ export declare function hashFolderColor(path: string): string;
9
+ interface Bounds {
10
+ minX: number;
11
+ maxX: number;
12
+ minZ: number;
13
+ maxZ: number;
14
+ }
15
+ interface FolderIndex {
16
+ /** Parent directory → list of immediate child directories. Top-level folders live under '' (empty string). */
17
+ children: Map<string, string[]>;
18
+ /** Folder path → world bounds spanning every descendant district. */
19
+ bounds: Map<string, Bounds>;
20
+ /** Folder path → number of descendant files. */
21
+ fileCounts: Map<string, number>;
22
+ }
23
+ /**
24
+ * Precompute the data structures `buildFolderElevatedPanels` needs from a
25
+ * `CityData`. Cache this when the city data is stable to avoid redoing the
26
+ * O(districts × depth) walk on every render.
27
+ */
28
+ export declare function buildFolderIndex(cityData: CityData): FolderIndex;
29
+ export interface BuildFolderElevatedPanelsOptions {
30
+ cityData: CityData;
31
+ /**
32
+ * Set of folder paths that are currently expanded in the file tree. A folder
33
+ * not in this set is treated as collapsed.
34
+ */
35
+ expandedFolders: ReadonlySet<string>;
36
+ /** Toggle handler invoked when an umbrella tile is clicked. */
37
+ onToggleFolder?: (folderPath: string) => void;
38
+ /**
39
+ * Scale label font size by descendant file count. Default true. When false,
40
+ * the renderer's auto-sized label is used (size derived from tile footprint).
41
+ */
42
+ scaleLabelByFileCount?: boolean;
43
+ /**
44
+ * Pre-built index from `buildFolderIndex(cityData)`. Pass when you cache the
45
+ * city's index across renders to avoid recomputing it.
46
+ */
47
+ index?: FolderIndex;
48
+ }
49
+ /**
50
+ * Walks the folder hierarchy from the top-level folders of a `CityData`. For
51
+ * each folder:
52
+ * - if expanded → recurse into child folders
53
+ * - if collapsed → emit one elevated panel covering the union of every
54
+ * descendant district's world bounds, colored via `hashFolderColor`.
55
+ *
56
+ * Mirror of the scope-tree expansion behavior, applied to file-tree folders.
57
+ */
58
+ export declare function buildFolderElevatedPanels(options: BuildFolderElevatedPanelsOptions): ElevatedScopePanel[];
59
+ export {};
60
+ //# sourceMappingURL=folderElevatedPanels.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"folderElevatedPanels.d.ts","sourceRoot":"","sources":["../../src/utils/folderElevatedPanels.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AAChE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAenE;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMpD;AAED,UAAU,MAAM;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,UAAU,WAAW;IACnB,8GAA8G;IAC9G,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IAChC,qEAAqE;IACrE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,gDAAgD;IAChD,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,GAAG,WAAW,CA+ChE;AAED,MAAM,WAAW,gCAAgC;IAC/C,QAAQ,EAAE,QAAQ,CAAC;IACnB;;;OAGG;IACH,eAAe,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACrC,+DAA+D;IAC/D,cAAc,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C;;;OAGG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC;;;OAGG;IACH,KAAK,CAAC,EAAE,WAAW,CAAC;CACrB;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,gCAAgC,GACxC,kBAAkB,EAAE,CAsCtB"}
@@ -0,0 +1,127 @@
1
+ const FOLDER_PALETTE = [
2
+ '#3b82f6',
3
+ '#22c55e',
4
+ '#f59e0b',
5
+ '#ec4899',
6
+ '#8b5cf6',
7
+ '#06b6d4',
8
+ '#ef4444',
9
+ '#14b8a6',
10
+ '#a855f7',
11
+ '#eab308',
12
+ ];
13
+ /**
14
+ * Stable color for a folder path, picked from a small palette via a string
15
+ * hash. Two folders at the same depth get visibly different colors; the
16
+ * same folder always gets the same color across renders.
17
+ */
18
+ export function hashFolderColor(path) {
19
+ let h = 0;
20
+ for (let i = 0; i < path.length; i++) {
21
+ h = (h * 31 + path.charCodeAt(i)) | 0;
22
+ }
23
+ return FOLDER_PALETTE[Math.abs(h) % FOLDER_PALETTE.length];
24
+ }
25
+ /**
26
+ * Precompute the data structures `buildFolderElevatedPanels` needs from a
27
+ * `CityData`. Cache this when the city data is stable to avoid redoing the
28
+ * O(districts × depth) walk on every render.
29
+ */
30
+ export function buildFolderIndex(cityData) {
31
+ const children = new Map();
32
+ const directorySet = new Set();
33
+ for (const d of cityData.districts)
34
+ directorySet.add(d.path);
35
+ const dirs = Array.from(directorySet).sort();
36
+ for (const dir of dirs) {
37
+ const slash = dir.lastIndexOf('/');
38
+ const parent = slash >= 0 ? dir.slice(0, slash) : '';
39
+ const arr = children.get(parent);
40
+ if (arr)
41
+ arr.push(dir);
42
+ else
43
+ children.set(parent, [dir]);
44
+ }
45
+ const bounds = new Map();
46
+ for (const district of cityData.districts) {
47
+ const b = district.worldBounds;
48
+ let path = district.path;
49
+ while (path) {
50
+ const cur = bounds.get(path);
51
+ if (!cur) {
52
+ bounds.set(path, { minX: b.minX, maxX: b.maxX, minZ: b.minZ, maxZ: b.maxZ });
53
+ }
54
+ else {
55
+ if (b.minX < cur.minX)
56
+ cur.minX = b.minX;
57
+ if (b.maxX > cur.maxX)
58
+ cur.maxX = b.maxX;
59
+ if (b.minZ < cur.minZ)
60
+ cur.minZ = b.minZ;
61
+ if (b.maxZ > cur.maxZ)
62
+ cur.maxZ = b.maxZ;
63
+ }
64
+ const slash = path.lastIndexOf('/');
65
+ if (slash < 0)
66
+ break;
67
+ path = path.slice(0, slash);
68
+ }
69
+ }
70
+ const fileCounts = new Map();
71
+ for (const b of cityData.buildings) {
72
+ let path = b.path;
73
+ const slash = path.lastIndexOf('/');
74
+ path = slash >= 0 ? path.slice(0, slash) : '';
75
+ while (path) {
76
+ fileCounts.set(path, (fileCounts.get(path) ?? 0) + 1);
77
+ const s = path.lastIndexOf('/');
78
+ if (s < 0)
79
+ break;
80
+ path = path.slice(0, s);
81
+ }
82
+ }
83
+ return { children, bounds, fileCounts };
84
+ }
85
+ /**
86
+ * Walks the folder hierarchy from the top-level folders of a `CityData`. For
87
+ * each folder:
88
+ * - if expanded → recurse into child folders
89
+ * - if collapsed → emit one elevated panel covering the union of every
90
+ * descendant district's world bounds, colored via `hashFolderColor`.
91
+ *
92
+ * Mirror of the scope-tree expansion behavior, applied to file-tree folders.
93
+ */
94
+ export function buildFolderElevatedPanels(options) {
95
+ const { cityData, expandedFolders, onToggleFolder, scaleLabelByFileCount = true, } = options;
96
+ const index = options.index ?? buildFolderIndex(cityData);
97
+ const panels = [];
98
+ const walk = (folderPath) => {
99
+ if (expandedFolders.has(folderPath)) {
100
+ const kids = index.children.get(folderPath) ?? [];
101
+ for (const child of kids)
102
+ walk(child);
103
+ return;
104
+ }
105
+ const bounds = index.bounds.get(folderPath);
106
+ if (!bounds)
107
+ return;
108
+ const label = folderPath.split('/').pop() ?? folderPath;
109
+ const fileCount = index.fileCounts.get(folderPath) ?? 0;
110
+ const labelSize = scaleLabelByFileCount
111
+ ? Math.max(12, Math.min(240, 8 + Math.sqrt(fileCount) * 5))
112
+ : undefined;
113
+ panels.push({
114
+ id: `folder::${folderPath}`,
115
+ color: hashFolderColor(folderPath),
116
+ height: 4,
117
+ thickness: 2,
118
+ bounds,
119
+ label,
120
+ labelSize,
121
+ onClick: onToggleFolder ? () => onToggleFolder(folderPath) : undefined,
122
+ });
123
+ };
124
+ for (const top of index.children.get('') ?? [])
125
+ walk(top);
126
+ return panels;
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principal-ai/file-city-react",
3
- "version": "0.5.39",
3
+ "version": "0.5.40",
4
4
  "type": "module",
5
5
  "description": "React components for File City visualization",
6
6
  "main": "dist/index.js",
package/src/index.ts CHANGED
@@ -87,6 +87,14 @@ export type {
87
87
  // with the 2D HighlightLayer from drawLayeredBuildings
88
88
  export type { HighlightLayer as FileCity3DHL } from './components/FileCity3D';
89
89
 
90
+ // Folder-driven elevated panels (file-tree expansion → 3D umbrella tiles)
91
+ export {
92
+ buildFolderElevatedPanels,
93
+ buildFolderIndex,
94
+ hashFolderColor,
95
+ } from './utils/folderElevatedPanels';
96
+ export type { BuildFolderElevatedPanelsOptions } from './utils/folderElevatedPanels';
97
+
90
98
  // Visualization resolution utilities
91
99
  // See docs/VISUALIZATION_STATE_RESOLUTION.md for documentation
92
100
  export { resolveVisualizationIntent } from './utils/visualizationResolution';
@@ -14,6 +14,7 @@ import {
14
14
  type HighlightLayer,
15
15
  } from '../components/FileCity3D';
16
16
  import { createFileColorHighlightLayers } from '../utils/fileColorHighlightLayers';
17
+ import { buildFolderElevatedPanels, buildFolderIndex } from '../utils/folderElevatedPanels';
17
18
 
18
19
  import electronAppCityData from '../../../../assets/electron-app-city-data.json';
19
20
 
@@ -117,97 +118,11 @@ const ELECTRON_DISTRICTS_BY_PATH: Map<string, CityDistrict> = new Map(
117
118
  );
118
119
 
119
120
  /**
120
- * Parent directory list of immediate child directories. Top-level folders
121
- * (e.g. `electron-app`) live under the empty-string parent. Used to walk the
122
- * folder hierarchy when generating elevated panels for the file-tree tab.
121
+ * Pre-built folder index (children, bounds, file counts) for the static
122
+ * electron-app city. Cached once at module load so the story doesn't
123
+ * recompute on every render.
123
124
  */
124
- const ELECTRON_FOLDER_CHILDREN: Map<string, string[]> = (() => {
125
- const m = new Map<string, string[]>();
126
- const dirs = Array.from(ELECTRON_DIRECTORIES).sort();
127
- for (const dir of dirs) {
128
- const slash = dir.lastIndexOf('/');
129
- const parent = slash >= 0 ? dir.slice(0, slash) : '';
130
- const arr = m.get(parent);
131
- if (arr) arr.push(dir);
132
- else m.set(parent, [dir]);
133
- }
134
- return m;
135
- })();
136
-
137
- /**
138
- * Folder path → world bounds spanning every descendant district (including
139
- * the folder itself, when it is a district). A collapsed folder uses these
140
- * unioned bounds for its umbrella panel.
141
- */
142
- const ELECTRON_FOLDER_BOUNDS: Map<
143
- string,
144
- { minX: number; maxX: number; minZ: number; maxZ: number }
145
- > = (() => {
146
- const m = new Map<
147
- string,
148
- { minX: number; maxX: number; minZ: number; maxZ: number }
149
- >();
150
- for (const district of (electronAppCityData as CityData).districts) {
151
- const b = district.worldBounds;
152
- let path = district.path;
153
- while (path) {
154
- const cur = m.get(path);
155
- if (!cur) {
156
- m.set(path, { minX: b.minX, maxX: b.maxX, minZ: b.minZ, maxZ: b.maxZ });
157
- } else {
158
- if (b.minX < cur.minX) cur.minX = b.minX;
159
- if (b.maxX > cur.maxX) cur.maxX = b.maxX;
160
- if (b.minZ < cur.minZ) cur.minZ = b.minZ;
161
- if (b.maxZ > cur.maxZ) cur.maxZ = b.maxZ;
162
- }
163
- const slash = path.lastIndexOf('/');
164
- if (slash < 0) break;
165
- path = path.slice(0, slash);
166
- }
167
- }
168
- return m;
169
- })();
170
-
171
- /**
172
- * Folder path → number of descendant files. Used to scale folder-panel
173
- * label text so larger folders read first when scanning the city.
174
- */
175
- const ELECTRON_FOLDER_FILE_COUNTS: Map<string, number> = (() => {
176
- const m = new Map<string, number>();
177
- for (const b of (electronAppCityData as CityData).buildings) {
178
- let path = b.path;
179
- const slash = path.lastIndexOf('/');
180
- path = slash >= 0 ? path.slice(0, slash) : '';
181
- while (path) {
182
- m.set(path, (m.get(path) ?? 0) + 1);
183
- const s = path.lastIndexOf('/');
184
- if (s < 0) break;
185
- path = path.slice(0, s);
186
- }
187
- }
188
- return m;
189
- })();
190
-
191
- const FOLDER_PALETTE = [
192
- '#3b82f6',
193
- '#22c55e',
194
- '#f59e0b',
195
- '#ec4899',
196
- '#8b5cf6',
197
- '#06b6d4',
198
- '#ef4444',
199
- '#14b8a6',
200
- '#a855f7',
201
- '#eab308',
202
- ];
203
-
204
- function hashFolderColor(path: string): string {
205
- let h = 0;
206
- for (let i = 0; i < path.length; i++) {
207
- h = (h * 31 + path.charCodeAt(i)) | 0;
208
- }
209
- return FOLDER_PALETTE[Math.abs(h) % FOLDER_PALETTE.length];
210
- }
125
+ const ELECTRON_FOLDER_INDEX = buildFolderIndex(electronAppCityData as CityData);
211
126
 
212
127
  // ---------------------------------------------------------------------------
213
128
  // Scope tree paths
@@ -1194,40 +1109,17 @@ const SingleScopeTemplate: React.FC = () => {
1194
1109
  );
1195
1110
 
1196
1111
  // Elevated folder panels — driven by the file tree's expansion state.
1197
- // Recursively walks from the top-level folders: an expanded folder
1198
- // descends into its child folders; a collapsed folder emits one panel
1199
- // covering the union of all descendant district bounds.
1200
1112
  const folderElevatedPanels = React.useMemo<ElevatedScopePanel[] | undefined>(() => {
1201
1113
  if (activeTab !== 'files') return undefined;
1202
- const panels: ElevatedScopePanel[] = [];
1203
- const walk = (folderPath: string): void => {
1204
- if (folderTreeExpansion.expanded.has(folderPath)) {
1205
- const children = ELECTRON_FOLDER_CHILDREN.get(folderPath) ?? [];
1206
- for (const child of children) walk(child);
1207
- return;
1208
- }
1209
- const bounds = ELECTRON_FOLDER_BOUNDS.get(folderPath);
1210
- if (!bounds) return;
1211
- const label = folderPath.split('/').pop() ?? folderPath;
1212
- const fileCount = ELECTRON_FOLDER_FILE_COUNTS.get(folderPath) ?? 0;
1213
- // sqrt growth keeps ~10x file deltas inside a ~3x label-size delta;
1214
- // the renderer still clamps to the tile footprint.
1215
- const labelSize = Math.max(12, Math.min(240, 8 + Math.sqrt(fileCount) * 5));
1216
- panels.push({
1217
- id: `folder::${folderPath}`,
1218
- color: hashFolderColor(folderPath),
1219
- height: 4,
1220
- thickness: 2,
1221
- bounds,
1222
- label,
1223
- labelSize,
1224
- onClick: () => {
1225
- const item = treeModel.getItem(folderPath);
1226
- if (item?.isDirectory()) item.toggle();
1227
- },
1228
- });
1229
- };
1230
- for (const top of ELECTRON_FOLDER_CHILDREN.get('') ?? []) walk(top);
1114
+ const panels = buildFolderElevatedPanels({
1115
+ cityData: electronAppCityData as CityData,
1116
+ expandedFolders: folderTreeExpansion.expanded,
1117
+ onToggleFolder: (folderPath) => {
1118
+ const item = treeModel.getItem(folderPath);
1119
+ if (item?.isDirectory()) item.toggle();
1120
+ },
1121
+ index: ELECTRON_FOLDER_INDEX,
1122
+ });
1231
1123
  return panels.length > 0 ? panels : undefined;
1232
1124
  }, [activeTab, treeModel, folderTreeExpansion]);
1233
1125
 
@@ -1647,6 +1539,37 @@ const SingleScopeTemplate: React.FC = () => {
1647
1539
  showControls={true}
1648
1540
  />
1649
1541
 
1542
+ {/* Debug overlay — current focusDirectory */}
1543
+ <div
1544
+ style={{
1545
+ position: 'absolute',
1546
+ top: 16,
1547
+ left: 16,
1548
+ padding: '8px 12px',
1549
+ background: 'rgba(15, 23, 42, 0.92)',
1550
+ border: '1px solid #334155',
1551
+ borderRadius: 6,
1552
+ color: '#e2e8f0',
1553
+ fontFamily: 'system-ui, sans-serif',
1554
+ fontSize: 12,
1555
+ zIndex: 100,
1556
+ maxWidth: 480,
1557
+ }}
1558
+ >
1559
+ <div style={sectionLabelStyle}>focusDirectory</div>
1560
+ <div
1561
+ style={{
1562
+ marginTop: 4,
1563
+ fontFamily: 'monospace',
1564
+ fontSize: 12,
1565
+ color: focusDirectory ? '#fde68a' : '#64748b',
1566
+ wordBreak: 'break-all',
1567
+ }}
1568
+ >
1569
+ {focusDirectory ?? '(null)'}
1570
+ </div>
1571
+ </div>
1572
+
1650
1573
  {/* Info overlay — driven by scope tree selection */}
1651
1574
  {scopeInfo && <ScopeInfoOverlay info={scopeInfo} />}
1652
1575
  </div>
@@ -0,0 +1,170 @@
1
+ import type { CityData } from '@principal-ai/file-city-builder';
2
+ import type { ElevatedScopePanel } from '../components/FileCity3D';
3
+
4
+ const FOLDER_PALETTE = [
5
+ '#3b82f6',
6
+ '#22c55e',
7
+ '#f59e0b',
8
+ '#ec4899',
9
+ '#8b5cf6',
10
+ '#06b6d4',
11
+ '#ef4444',
12
+ '#14b8a6',
13
+ '#a855f7',
14
+ '#eab308',
15
+ ];
16
+
17
+ /**
18
+ * Stable color for a folder path, picked from a small palette via a string
19
+ * hash. Two folders at the same depth get visibly different colors; the
20
+ * same folder always gets the same color across renders.
21
+ */
22
+ export function hashFolderColor(path: string): string {
23
+ let h = 0;
24
+ for (let i = 0; i < path.length; i++) {
25
+ h = (h * 31 + path.charCodeAt(i)) | 0;
26
+ }
27
+ return FOLDER_PALETTE[Math.abs(h) % FOLDER_PALETTE.length];
28
+ }
29
+
30
+ interface Bounds {
31
+ minX: number;
32
+ maxX: number;
33
+ minZ: number;
34
+ maxZ: number;
35
+ }
36
+
37
+ interface FolderIndex {
38
+ /** Parent directory → list of immediate child directories. Top-level folders live under '' (empty string). */
39
+ children: Map<string, string[]>;
40
+ /** Folder path → world bounds spanning every descendant district. */
41
+ bounds: Map<string, Bounds>;
42
+ /** Folder path → number of descendant files. */
43
+ fileCounts: Map<string, number>;
44
+ }
45
+
46
+ /**
47
+ * Precompute the data structures `buildFolderElevatedPanels` needs from a
48
+ * `CityData`. Cache this when the city data is stable to avoid redoing the
49
+ * O(districts × depth) walk on every render.
50
+ */
51
+ export function buildFolderIndex(cityData: CityData): FolderIndex {
52
+ const children = new Map<string, string[]>();
53
+ const directorySet = new Set<string>();
54
+ for (const d of cityData.districts) directorySet.add(d.path);
55
+ const dirs = Array.from(directorySet).sort();
56
+ for (const dir of dirs) {
57
+ const slash = dir.lastIndexOf('/');
58
+ const parent = slash >= 0 ? dir.slice(0, slash) : '';
59
+ const arr = children.get(parent);
60
+ if (arr) arr.push(dir);
61
+ else children.set(parent, [dir]);
62
+ }
63
+
64
+ const bounds = new Map<string, Bounds>();
65
+ for (const district of cityData.districts) {
66
+ const b = district.worldBounds;
67
+ let path = district.path;
68
+ while (path) {
69
+ const cur = bounds.get(path);
70
+ if (!cur) {
71
+ bounds.set(path, { minX: b.minX, maxX: b.maxX, minZ: b.minZ, maxZ: b.maxZ });
72
+ } else {
73
+ if (b.minX < cur.minX) cur.minX = b.minX;
74
+ if (b.maxX > cur.maxX) cur.maxX = b.maxX;
75
+ if (b.minZ < cur.minZ) cur.minZ = b.minZ;
76
+ if (b.maxZ > cur.maxZ) cur.maxZ = b.maxZ;
77
+ }
78
+ const slash = path.lastIndexOf('/');
79
+ if (slash < 0) break;
80
+ path = path.slice(0, slash);
81
+ }
82
+ }
83
+
84
+ const fileCounts = new Map<string, number>();
85
+ for (const b of cityData.buildings) {
86
+ let path = b.path;
87
+ const slash = path.lastIndexOf('/');
88
+ path = slash >= 0 ? path.slice(0, slash) : '';
89
+ while (path) {
90
+ fileCounts.set(path, (fileCounts.get(path) ?? 0) + 1);
91
+ const s = path.lastIndexOf('/');
92
+ if (s < 0) break;
93
+ path = path.slice(0, s);
94
+ }
95
+ }
96
+
97
+ return { children, bounds, fileCounts };
98
+ }
99
+
100
+ export interface BuildFolderElevatedPanelsOptions {
101
+ cityData: CityData;
102
+ /**
103
+ * Set of folder paths that are currently expanded in the file tree. A folder
104
+ * not in this set is treated as collapsed.
105
+ */
106
+ expandedFolders: ReadonlySet<string>;
107
+ /** Toggle handler invoked when an umbrella tile is clicked. */
108
+ onToggleFolder?: (folderPath: string) => void;
109
+ /**
110
+ * Scale label font size by descendant file count. Default true. When false,
111
+ * the renderer's auto-sized label is used (size derived from tile footprint).
112
+ */
113
+ scaleLabelByFileCount?: boolean;
114
+ /**
115
+ * Pre-built index from `buildFolderIndex(cityData)`. Pass when you cache the
116
+ * city's index across renders to avoid recomputing it.
117
+ */
118
+ index?: FolderIndex;
119
+ }
120
+
121
+ /**
122
+ * Walks the folder hierarchy from the top-level folders of a `CityData`. For
123
+ * each folder:
124
+ * - if expanded → recurse into child folders
125
+ * - if collapsed → emit one elevated panel covering the union of every
126
+ * descendant district's world bounds, colored via `hashFolderColor`.
127
+ *
128
+ * Mirror of the scope-tree expansion behavior, applied to file-tree folders.
129
+ */
130
+ export function buildFolderElevatedPanels(
131
+ options: BuildFolderElevatedPanelsOptions,
132
+ ): ElevatedScopePanel[] {
133
+ const {
134
+ cityData,
135
+ expandedFolders,
136
+ onToggleFolder,
137
+ scaleLabelByFileCount = true,
138
+ } = options;
139
+ const index = options.index ?? buildFolderIndex(cityData);
140
+
141
+ const panels: ElevatedScopePanel[] = [];
142
+
143
+ const walk = (folderPath: string): void => {
144
+ if (expandedFolders.has(folderPath)) {
145
+ const kids = index.children.get(folderPath) ?? [];
146
+ for (const child of kids) walk(child);
147
+ return;
148
+ }
149
+ const bounds = index.bounds.get(folderPath);
150
+ if (!bounds) return;
151
+ const label = folderPath.split('/').pop() ?? folderPath;
152
+ const fileCount = index.fileCounts.get(folderPath) ?? 0;
153
+ const labelSize = scaleLabelByFileCount
154
+ ? Math.max(12, Math.min(240, 8 + Math.sqrt(fileCount) * 5))
155
+ : undefined;
156
+ panels.push({
157
+ id: `folder::${folderPath}`,
158
+ color: hashFolderColor(folderPath),
159
+ height: 4,
160
+ thickness: 2,
161
+ bounds,
162
+ label,
163
+ labelSize,
164
+ onClick: onToggleFolder ? () => onToggleFolder(folderPath) : undefined,
165
+ });
166
+ };
167
+
168
+ for (const top of index.children.get('') ?? []) walk(top);
169
+ return panels;
170
+ }