@principal-ai/file-city-react 0.5.42 → 0.5.44

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.
@@ -27,6 +27,7 @@ export type {
27
27
  HeightScaling,
28
28
  FlatPattern,
29
29
  ElevatedScopePanel,
30
+ SelectionStyle,
30
31
  CityData,
31
32
  CityBuilding,
32
33
  CityDistrict,
@@ -35,4 +36,5 @@ export type {
35
36
  TouchOneAction,
36
37
  TouchTwoAction,
37
38
  WheelAction,
39
+ OnCameraFrame,
38
40
  } from './FileCity3D';
@@ -564,47 +564,17 @@ export const FileCityExplorer: React.FC<FileCityExplorerProps> = ({
564
564
  const displayLabel = folderPath ? areaNameByCityPath.get(folderPath) : undefined;
565
565
  return displayLabel ? { ...panel, displayLabel } : panel;
566
566
  });
567
- // Selection indicator: render a thin, slightly-larger panel underneath
568
- // the selected folder's umbrella so an accent ring peeks out around its
569
- // edges. Inserted *before* the umbrella in the list so the umbrella
570
- // draws on top — only the inflated rim shows. If the folder is expanded
571
- // (no umbrella in the panel list) findIndex returns -1 and no ring is
572
- // drawn, which is exactly what we want.
573
- if (selectedPanelFolder) {
574
- const idx = panels.findIndex(p => p.id === `folder::${selectedPanelFolder}`);
575
- if (idx >= 0) {
576
- const target = panels[idx];
577
- const inflate = 4;
578
- const border: ElevatedScopePanel = {
579
- id: `folder-border::${selectedPanelFolder}`,
580
- color: theme.colors.warning,
581
- height: (target.height ?? 4) - 2,
582
- thickness: 1,
583
- bounds: {
584
- minX: target.bounds.minX - inflate,
585
- maxX: target.bounds.maxX + inflate,
586
- minZ: target.bounds.minZ - inflate,
587
- maxZ: target.bounds.maxZ + inflate,
588
- },
589
- };
590
- const next = [...panels];
591
- next.splice(idx, 0, border);
592
- return next;
593
- }
594
- }
595
567
 
596
568
  return panels.length > 0 ? panels : undefined;
597
569
  }, [
598
570
  activeTab,
599
571
  cityData,
600
- selectedPanelFolder,
601
572
  treeModel,
602
573
  folderTreeExpansion,
603
574
  setFocusDirectoryIfUnpinned,
604
575
  areaNameByCityPath,
605
576
  folderIndex,
606
577
  packageRootClamp,
607
- theme,
608
578
  ]);
609
579
 
610
580
  // Cmd-click on a building → surface the chain of expanded ancestor folders
@@ -829,6 +799,8 @@ export const FileCityExplorer: React.FC<FileCityExplorerProps> = ({
829
799
  focusDirectory={focusDirectory}
830
800
  highlightLayers={cityHighlightLayers}
831
801
  elevatedScopePanels={cityElevatedPanels ?? folderElevatedPanels}
802
+ selectedPath={activeTab === 'files' ? selectedPanelFolder : null}
803
+ selectionStyle={{ color: theme.colors.warning }}
832
804
  onBuildingClick={handleBuildingClick}
833
805
  animation={{
834
806
  startFlat: true,
package/src/index.ts CHANGED
@@ -81,6 +81,7 @@ export type {
81
81
  HeightScaling,
82
82
  FlatPattern,
83
83
  ElevatedScopePanel,
84
+ SelectionStyle,
84
85
  } from './components/FileCity3D';
85
86
 
86
87
  // Re-export HighlightLayer from FileCity3D with distinct name to avoid conflict
@@ -1,3 +1,4 @@
1
+ import { useCallback, useState } from 'react';
1
2
  import type { Meta, StoryObj } from '@storybook/react';
2
3
 
3
4
  import {
@@ -189,7 +190,8 @@ export const RecoloredAndTranslucent: Story = {
189
190
  };
190
191
 
191
192
  // ---------------------------------------------------------------------------
192
- // Selection ring
193
+ // Selection ring (now a first-class `selectedPath` prop on FileCity3D —
194
+ // kept here as a regression demo so the panel/ring composition stays visible).
193
195
  // ---------------------------------------------------------------------------
194
196
 
195
197
  const SELECTED_FOLDER = 'electron-app/src';
@@ -198,37 +200,17 @@ const SELECTION_RING_COLOR = '#fbbf24';
198
200
  export const WithSelectionRing: Story = {
199
201
  args: {
200
202
  ...baseArgs,
201
- elevatedScopePanels: (() => {
202
- const panels = panelsFor(TOP_LEVEL);
203
- const idx = panels.findIndex(p => p.id === `folder::${SELECTED_FOLDER}`);
204
- if (idx < 0) return panels;
205
- const target = panels[idx];
206
- const inflate = 4;
207
- const ring: ElevatedScopePanel = {
208
- id: `folder-border::${SELECTED_FOLDER}`,
209
- color: SELECTION_RING_COLOR,
210
- height: (target.height ?? 4) - 2,
211
- thickness: 1,
212
- bounds: {
213
- minX: target.bounds.minX - inflate,
214
- maxX: target.bounds.maxX + inflate,
215
- minZ: target.bounds.minZ - inflate,
216
- maxZ: target.bounds.maxZ + inflate,
217
- },
218
- };
219
- const next = [...panels];
220
- next.splice(idx, 0, ring);
221
- return next;
222
- })(),
203
+ elevatedScopePanels: panelsFor(TOP_LEVEL),
204
+ selectedPath: SELECTED_FOLDER,
205
+ selectionStyle: { color: SELECTION_RING_COLOR },
223
206
  },
224
207
  parameters: {
225
208
  docs: {
226
209
  description: {
227
210
  story:
228
- 'Insert an inflated, lower-height panel just *before* the target ' +
229
- 'umbrella so only its rim peeks out the selection-ring pattern ' +
230
- 'used by FileCityExplorer. Order matters: the ring must come ' +
231
- 'first so the umbrella draws on top of its centre.',
211
+ '`selectedPath` resolves to a district, so FileCity3D draws the ' +
212
+ 'selection ring above the umbrella covering that district. ' +
213
+ 'Replaces the older inflate-a-panel-underneath-the-umbrella trick.',
232
214
  },
233
215
  },
234
216
  },
@@ -293,3 +275,66 @@ export const HandBuiltPanels: Story = {
293
275
  },
294
276
  },
295
277
  };
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Cmd-click to dismiss (parent-owned dismiss flow)
281
+ // ---------------------------------------------------------------------------
282
+
283
+ function DismissOnCommandClickStory() {
284
+ const [panels, setPanels] = useState<ElevatedScopePanel[]>(() =>
285
+ panelsFor(TOP_LEVEL),
286
+ );
287
+ const [dismissingIds, setDismissingIds] = useState<ReadonlySet<string>>(
288
+ () => new Set(),
289
+ );
290
+
291
+ const wired = panels.map(panel => ({
292
+ ...panel,
293
+ onClick: (event: MouseEvent) => {
294
+ if (!event.metaKey) return;
295
+ setDismissingIds(prev => {
296
+ if (prev.has(panel.id)) return prev;
297
+ const next = new Set(prev);
298
+ next.add(panel.id);
299
+ return next;
300
+ });
301
+ },
302
+ }));
303
+
304
+ const handleDismissed = useCallback((id: string) => {
305
+ setPanels(prev => prev.filter(p => p.id !== id));
306
+ setDismissingIds(prev => {
307
+ if (!prev.has(id)) return prev;
308
+ const next = new Set(prev);
309
+ next.delete(id);
310
+ return next;
311
+ });
312
+ }, []);
313
+
314
+ return (
315
+ <FileCity3D
316
+ {...baseArgs}
317
+ elevatedScopePanels={wired}
318
+ dismissingPanelIds={dismissingIds}
319
+ onPanelDismissed={handleDismissed}
320
+ />
321
+ );
322
+ }
323
+
324
+ export const DismissOnCommandClick: Story = {
325
+ render: () => <DismissOnCommandClickStory />,
326
+ parameters: {
327
+ docs: {
328
+ description: {
329
+ story:
330
+ '⌘-click (or ctrl-click on non-Mac with `event.metaKey`) a panel ' +
331
+ 'to lift it toward the camera and fade it out. The story owns ' +
332
+ 'both the `panels` array and a `dismissingIds` set: clicking adds ' +
333
+ 'the id to that set, `FileCity3D` plays the spring, and once it ' +
334
+ 'settles `onPanelDismissed` fires so the story drops the panel ' +
335
+ 'from both pieces of state. The component never owns the ' +
336
+ 'truth — it just animates and notifies.',
337
+ },
338
+ },
339
+ },
340
+ };
@@ -1570,39 +1570,10 @@ const FileCityExplorerTemplate: React.FC = () => {
1570
1570
  const displayLabel = folderPath ? areaNameByCityPath.get(folderPath) : undefined;
1571
1571
  return displayLabel ? { ...panel, displayLabel } : panel;
1572
1572
  });
1573
- // Selection indicator: render a thin, slightly-larger panel underneath
1574
- // the selected folder's umbrella so an accent ring peeks out around its
1575
- // edges. Inserted *before* the umbrella in the list so the umbrella
1576
- // draws on top — only the inflated rim shows. If the folder is expanded
1577
- // (no umbrella in the panel list) findIndex returns -1 and no ring is
1578
- // drawn, which is exactly what we want.
1579
- if (selectedPanelFolder) {
1580
- const idx = panels.findIndex(p => p.id === `folder::${selectedPanelFolder}`);
1581
- if (idx >= 0) {
1582
- const target = panels[idx];
1583
- const inflate = 4;
1584
- const border: ElevatedScopePanel = {
1585
- id: `folder-border::${selectedPanelFolder}`,
1586
- color: '#fbbf24',
1587
- height: (target.height ?? 4) - 2,
1588
- thickness: 1,
1589
- bounds: {
1590
- minX: target.bounds.minX - inflate,
1591
- maxX: target.bounds.maxX + inflate,
1592
- minZ: target.bounds.minZ - inflate,
1593
- maxZ: target.bounds.maxZ + inflate,
1594
- },
1595
- };
1596
- const next = [...panels];
1597
- next.splice(idx, 0, border);
1598
- return next;
1599
- }
1600
- }
1601
1573
 
1602
1574
  return panels.length > 0 ? panels : undefined;
1603
1575
  }, [
1604
1576
  activeTab,
1605
- selectedPanelFolder,
1606
1577
  treeModel,
1607
1578
  folderTreeExpansion,
1608
1579
  setFocusDirectoryIfUnpinned,
@@ -1829,6 +1800,8 @@ const FileCityExplorerTemplate: React.FC = () => {
1829
1800
  focusDirectory={focusDirectory}
1830
1801
  highlightLayers={cityHighlightLayers}
1831
1802
  elevatedScopePanels={cityElevatedPanels ?? folderElevatedPanels}
1803
+ selectedPath={activeTab === 'files' ? selectedPanelFolder : null}
1804
+ selectionStyle={{ color: '#fbbf24' }}
1832
1805
  onBuildingClick={handleBuildingClick}
1833
1806
  animation={{
1834
1807
  startFlat: true,
@@ -0,0 +1,319 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+
3
+ import { FileCity3D } from '../components/FileCity3D';
4
+ import type { CityData, HighlightLayer } from '../components/FileCity3D';
5
+ import { createFileColorHighlightLayers } from '../utils/fileColorHighlightLayers';
6
+ import authServerCityData from '../../../../assets/auth-server-city-data.json';
7
+
8
+ const meta = {
9
+ title: 'Debug/Highlight Layers (Flat)',
10
+ parameters: { layout: 'fullscreen' },
11
+ } satisfies Meta;
12
+
13
+ export default meta;
14
+
15
+ const cityData = authServerCityData as CityData;
16
+
17
+ // Three known-existing paths in the auth-server fixture, picked so they
18
+ // represent different file types.
19
+ const TARGETS = {
20
+ ts: 'auth-server/src/lib/auth-provider.ts', // .ts → fileColor primary 'fill'
21
+ tsx: 'auth-server/src/app/page.tsx', // .tsx → fileColor primary 'fill', secondary 'border'
22
+ route: 'auth-server/src/app/api/auth/workos/callback/route.ts', // .ts
23
+ };
24
+
25
+ const RED = '#ef4444';
26
+ const AMBER = '#f59e0b';
27
+ const GREEN = '#22c55e';
28
+ const NEUTRAL_BUILDING = '#475569'; // slate-600 — used in stories that want to isolate highlight rendering
29
+
30
+ const FLAT_ANIMATION = {
31
+ startFlat: true as const,
32
+ autoStartDelay: null,
33
+ staggerDelay: 0,
34
+ tension: 200,
35
+ friction: 24,
36
+ };
37
+
38
+ const Stage = ({ children }: { children: React.ReactNode }) => (
39
+ <div
40
+ style={{
41
+ width: '100vw',
42
+ height: '100vh',
43
+ backgroundColor: '#0f1419',
44
+ }}
45
+ >
46
+ {children}
47
+ </div>
48
+ );
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // 1. Borders only, no fileColorLayers — the cleanest possible test.
52
+ // ---------------------------------------------------------------------------
53
+ export const BorderOnly_NoFileColors: StoryObj = {
54
+ name: '1. border only, no file colors',
55
+ render: () => {
56
+ const layers: HighlightLayer[] = [
57
+ {
58
+ id: 'red',
59
+ name: 'red',
60
+ enabled: true,
61
+ color: RED,
62
+ priority: 10,
63
+ borderWidth: 30,
64
+ items: [{ path: TARGETS.ts, type: 'file', renderStrategy: 'border' }],
65
+ },
66
+ {
67
+ id: 'amber',
68
+ name: 'amber',
69
+ enabled: true,
70
+ color: AMBER,
71
+ priority: 11,
72
+ borderWidth: 30,
73
+ items: [{ path: TARGETS.tsx, type: 'file', renderStrategy: 'border' }],
74
+ },
75
+ {
76
+ id: 'green',
77
+ name: 'green',
78
+ enabled: true,
79
+ color: GREEN,
80
+ priority: 12,
81
+ borderWidth: 30,
82
+ items: [{ path: TARGETS.route, type: 'file', renderStrategy: 'border' }],
83
+ },
84
+ ];
85
+ return (
86
+ <Stage>
87
+ <FileCity3D
88
+ cityData={cityData}
89
+ width="100%"
90
+ height="100%"
91
+ isGrown={false}
92
+ animation={FLAT_ANIMATION}
93
+ highlightLayers={layers}
94
+ defaultBuildingColor={NEUTRAL_BUILDING}
95
+ isolationMode="none"
96
+ backgroundColor="#0f1419"
97
+ showControls={true}
98
+ />
99
+ </Stage>
100
+ );
101
+ },
102
+ };
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // 2. Same as #1 but with fileColorLayers also set — does anything change?
106
+ // ---------------------------------------------------------------------------
107
+ export const BorderOnly_WithFileColors: StoryObj = {
108
+ name: '2. border + file colors',
109
+ render: () => {
110
+ const fileColorLayers = createFileColorHighlightLayers(cityData.buildings);
111
+ const layers: HighlightLayer[] = [
112
+ {
113
+ id: 'red',
114
+ name: 'red',
115
+ enabled: true,
116
+ color: RED,
117
+ priority: 1000,
118
+ borderWidth: 30,
119
+ items: [{ path: TARGETS.ts, type: 'file', renderStrategy: 'border' }],
120
+ },
121
+ {
122
+ id: 'amber',
123
+ name: 'amber',
124
+ enabled: true,
125
+ color: AMBER,
126
+ priority: 1000,
127
+ borderWidth: 30,
128
+ items: [{ path: TARGETS.tsx, type: 'file', renderStrategy: 'border' }],
129
+ },
130
+ {
131
+ id: 'green',
132
+ name: 'green',
133
+ enabled: true,
134
+ color: GREEN,
135
+ priority: 1000,
136
+ borderWidth: 30,
137
+ items: [{ path: TARGETS.route, type: 'file', renderStrategy: 'border' }],
138
+ },
139
+ ];
140
+ return (
141
+ <Stage>
142
+ <FileCity3D
143
+ cityData={cityData}
144
+ width="100%"
145
+ height="100%"
146
+ isGrown={false}
147
+ animation={FLAT_ANIMATION}
148
+ fileColorLayers={fileColorLayers}
149
+ highlightLayers={layers}
150
+ isolationMode="none"
151
+ backgroundColor="#0f1419"
152
+ showControls={true}
153
+ />
154
+ </Stage>
155
+ );
156
+ },
157
+ };
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // 3. Fill strategy — sanity check that the layer system applies at all.
161
+ // If these buildings turn red/amber/green, the layer plumbing is fine
162
+ // and the issue is specific to BorderHighlights rendering.
163
+ // ---------------------------------------------------------------------------
164
+ export const Fill_NoFileColors: StoryObj = {
165
+ name: '3. fill only, no file colors',
166
+ render: () => {
167
+ const layers: HighlightLayer[] = [
168
+ {
169
+ id: 'red',
170
+ name: 'red',
171
+ enabled: true,
172
+ color: RED,
173
+ priority: 10,
174
+ items: [{ path: TARGETS.ts, type: 'file', renderStrategy: 'fill' }],
175
+ },
176
+ {
177
+ id: 'amber',
178
+ name: 'amber',
179
+ enabled: true,
180
+ color: AMBER,
181
+ priority: 11,
182
+ items: [{ path: TARGETS.tsx, type: 'file', renderStrategy: 'fill' }],
183
+ },
184
+ {
185
+ id: 'green',
186
+ name: 'green',
187
+ enabled: true,
188
+ color: GREEN,
189
+ priority: 12,
190
+ items: [{ path: TARGETS.route, type: 'file', renderStrategy: 'fill' }],
191
+ },
192
+ ];
193
+ return (
194
+ <Stage>
195
+ <FileCity3D
196
+ cityData={cityData}
197
+ width="100%"
198
+ height="100%"
199
+ isGrown={false}
200
+ animation={FLAT_ANIMATION}
201
+ highlightLayers={layers}
202
+ defaultBuildingColor={NEUTRAL_BUILDING}
203
+ isolationMode="none"
204
+ backgroundColor="#0f1419"
205
+ showControls={true}
206
+ />
207
+ </Stage>
208
+ );
209
+ },
210
+ };
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // 4. Same buildings as #1 but in 3D (grown). If borders show colored here
214
+ // but black in #1, the issue is specific to flat mode.
215
+ // ---------------------------------------------------------------------------
216
+ export const BorderOnly_Grown: StoryObj = {
217
+ name: '4. border only, 3D grown',
218
+ render: () => {
219
+ const layers: HighlightLayer[] = [
220
+ {
221
+ id: 'red',
222
+ name: 'red',
223
+ enabled: true,
224
+ color: RED,
225
+ priority: 10,
226
+ borderWidth: 30,
227
+ items: [{ path: TARGETS.ts, type: 'file', renderStrategy: 'border' }],
228
+ },
229
+ {
230
+ id: 'amber',
231
+ name: 'amber',
232
+ enabled: true,
233
+ color: AMBER,
234
+ priority: 11,
235
+ borderWidth: 30,
236
+ items: [{ path: TARGETS.tsx, type: 'file', renderStrategy: 'border' }],
237
+ },
238
+ {
239
+ id: 'green',
240
+ name: 'green',
241
+ enabled: true,
242
+ color: GREEN,
243
+ priority: 12,
244
+ borderWidth: 30,
245
+ items: [{ path: TARGETS.route, type: 'file', renderStrategy: 'border' }],
246
+ },
247
+ ];
248
+ return (
249
+ <Stage>
250
+ <FileCity3D
251
+ cityData={cityData}
252
+ width="100%"
253
+ height="100%"
254
+ isGrown={true}
255
+ animation={{ ...FLAT_ANIMATION, autoStartDelay: 0 }}
256
+ highlightLayers={layers}
257
+ defaultBuildingColor={NEUTRAL_BUILDING}
258
+ isolationMode="none"
259
+ backgroundColor="#0f1419"
260
+ showControls={true}
261
+ />
262
+ </Stage>
263
+ );
264
+ },
265
+ };
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // 5. Sweep of borderWidths. Are any of them visible in flat mode?
269
+ // ---------------------------------------------------------------------------
270
+ export const BorderWidthSweep: StoryObj = {
271
+ name: '5. borderWidth sweep (4, 30, 100)',
272
+ render: () => {
273
+ const layers: HighlightLayer[] = [
274
+ {
275
+ id: 'bw-4',
276
+ name: 'bw 4',
277
+ enabled: true,
278
+ color: RED,
279
+ priority: 10,
280
+ borderWidth: 4,
281
+ items: [{ path: TARGETS.ts, type: 'file', renderStrategy: 'border' }],
282
+ },
283
+ {
284
+ id: 'bw-30',
285
+ name: 'bw 30',
286
+ enabled: true,
287
+ color: AMBER,
288
+ priority: 11,
289
+ borderWidth: 30,
290
+ items: [{ path: TARGETS.tsx, type: 'file', renderStrategy: 'border' }],
291
+ },
292
+ {
293
+ id: 'bw-100',
294
+ name: 'bw 100',
295
+ enabled: true,
296
+ color: GREEN,
297
+ priority: 12,
298
+ borderWidth: 100,
299
+ items: [{ path: TARGETS.route, type: 'file', renderStrategy: 'border' }],
300
+ },
301
+ ];
302
+ return (
303
+ <Stage>
304
+ <FileCity3D
305
+ cityData={cityData}
306
+ width="100%"
307
+ height="100%"
308
+ isGrown={false}
309
+ animation={FLAT_ANIMATION}
310
+ highlightLayers={layers}
311
+ defaultBuildingColor={NEUTRAL_BUILDING}
312
+ isolationMode="none"
313
+ backgroundColor="#0f1419"
314
+ showControls={true}
315
+ />
316
+ </Stage>
317
+ );
318
+ },
319
+ };