@principal-ai/file-city-react 0.5.41 → 0.5.43
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/components/FileCity3D/FileCity3D.d.ts +36 -2
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +146 -49
- package/dist/components/FileCity3D/index.d.ts +1 -1
- package/dist/components/FileCity3D/index.d.ts.map +1 -1
- package/dist/components/FileCityExplorer/FileCityExplorer.d.ts.map +1 -1
- package/dist/components/FileCityExplorer/FileCityExplorer.js +1 -31
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/utils/folderElevatedPanels.d.ts.map +1 -1
- package/dist/utils/folderElevatedPanels.js +8 -0
- package/package.json +1 -1
- package/src/components/FileCity3D/FileCity3D.tsx +376 -135
- package/src/components/FileCity3D/index.ts +1 -0
- package/src/components/FileCityExplorer/FileCityExplorer.tsx +2 -30
- package/src/index.ts +1 -0
- package/src/stories/ElevatedScopePanels.stories.tsx +72 -27
- package/src/stories/FileCityExplorer.stories.tsx +2 -29
- package/src/stories/LeaderLineSnippetOverlay.stories.tsx +894 -0
- package/src/utils/folderElevatedPanels.ts +7 -0
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
|
|
4
|
+
import { ArchitectureMapHighlightLayers } from '../components/ArchitectureMapHighlightLayers';
|
|
5
|
+
import type { CityData } from '../components/FileCity3D';
|
|
6
|
+
import { createFileColorHighlightLayers } from '../utils/fileColorHighlightLayers';
|
|
7
|
+
import authServerCityData from '../../../../assets/auth-server-city-data.json';
|
|
8
|
+
|
|
9
|
+
const meta = {
|
|
10
|
+
title: 'Prototypes/Leader Line Snippet Overlay',
|
|
11
|
+
parameters: { layout: 'fullscreen' },
|
|
12
|
+
} satisfies Meta;
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
|
|
16
|
+
// Mirrors the default `padding` inside ArchitectureMapHighlightLayers, so the
|
|
17
|
+
// world->screen math here lines up with what the canvas actually draws.
|
|
18
|
+
const CANVAS_PADDING = 20;
|
|
19
|
+
|
|
20
|
+
const PANEL_WIDTH = 380;
|
|
21
|
+
const FADE_PX = 60; // distance over which a line fades after its card leaves the viewport
|
|
22
|
+
const DWELL_MS = 400; // card must be visible for this long before scroll-past auto-approves it
|
|
23
|
+
const APPROVED_COLOR = '#22c55e';
|
|
24
|
+
const BUILDING_RECT_INSET = -3; // outset px so the border sits around (not on top of) the building
|
|
25
|
+
|
|
26
|
+
type GitStatus = 'added' | 'modified' | 'deleted';
|
|
27
|
+
|
|
28
|
+
// GitHub-style diff palette. Approved is a brighter, more saturated green
|
|
29
|
+
// (#22c55e) so it reads as "settled" against the softer "added" green.
|
|
30
|
+
const STATUS_COLOR: Record<GitStatus, string> = {
|
|
31
|
+
added: '#3fb950',
|
|
32
|
+
modified: '#d29922',
|
|
33
|
+
deleted: '#f85149',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const STATUS_LABEL: Record<GitStatus, string> = {
|
|
37
|
+
added: '+ added',
|
|
38
|
+
modified: '~ modified',
|
|
39
|
+
deleted: '− deleted',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
interface Snippet {
|
|
43
|
+
path: string;
|
|
44
|
+
label: string;
|
|
45
|
+
status: GitStatus;
|
|
46
|
+
code: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SNIPPETS: Snippet[] = [
|
|
50
|
+
{
|
|
51
|
+
path: 'auth-server/src/lib/auth-provider.ts',
|
|
52
|
+
label: 'auth-provider.ts',
|
|
53
|
+
status: 'modified',
|
|
54
|
+
code: `export const authProvider = {
|
|
55
|
+
async getSession(req: Request) {
|
|
56
|
+
const cookie = req.headers.get('cookie');
|
|
57
|
+
return parseSessionCookie(cookie);
|
|
58
|
+
},
|
|
59
|
+
};`,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
path: 'auth-server/src/lib/org-membership.ts',
|
|
63
|
+
label: 'org-membership.ts',
|
|
64
|
+
status: 'added',
|
|
65
|
+
code: `export async function getOrgMembership(userId: string) {
|
|
66
|
+
return db.query.memberships.findFirst({
|
|
67
|
+
where: eq(memberships.userId, userId),
|
|
68
|
+
});
|
|
69
|
+
}`,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
path: 'auth-server/src/app/api/auth/workos/callback/route.ts',
|
|
73
|
+
label: 'callback / route.ts',
|
|
74
|
+
status: 'modified',
|
|
75
|
+
code: `export async function GET(req: Request) {
|
|
76
|
+
const code = new URL(req.url).searchParams.get('code');
|
|
77
|
+
if (!code) return NextResponse.redirect('/login');
|
|
78
|
+
const session = await workos.userManagement
|
|
79
|
+
.authenticateWithCode({ clientId: env.WORKOS_CLIENT_ID, code });
|
|
80
|
+
return setSessionCookie(session);
|
|
81
|
+
}`,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
path: 'auth-server/src/app/api/auth/workos/refresh/route.ts',
|
|
85
|
+
label: 'refresh / route.ts',
|
|
86
|
+
status: 'added',
|
|
87
|
+
code: `export async function POST(req: Request) {
|
|
88
|
+
const { refreshToken } = await req.json();
|
|
89
|
+
const next = await workos.userManagement
|
|
90
|
+
.authenticateWithRefreshToken({ refreshToken });
|
|
91
|
+
return Response.json(next);
|
|
92
|
+
}`,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
path: 'auth-server/src/app/api/auth/cli/room-token/route.ts',
|
|
96
|
+
label: 'cli / room-token',
|
|
97
|
+
status: 'modified',
|
|
98
|
+
code: `export async function POST(req: Request) {
|
|
99
|
+
const session = await authProvider.getSession(req);
|
|
100
|
+
if (!session) return new Response('unauthorized', { status: 401 });
|
|
101
|
+
return Response.json({ token: mintRoomToken(session.userId) });
|
|
102
|
+
}`,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
path: 'auth-server/src/app/api/auth/workos/verify/route.ts',
|
|
106
|
+
label: 'verify / route.ts',
|
|
107
|
+
status: 'modified',
|
|
108
|
+
code: `export async function GET(req: Request) {
|
|
109
|
+
const token = req.headers.get('authorization')?.slice(7);
|
|
110
|
+
const claims = await verifyJwt(token);
|
|
111
|
+
return Response.json({ ok: true, claims });
|
|
112
|
+
}`,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
path: 'auth-server/src/lib/token-store.ts',
|
|
116
|
+
label: 'token-store.ts',
|
|
117
|
+
status: 'added',
|
|
118
|
+
code: `export const tokenStore = {
|
|
119
|
+
async put(key: string, value: string, ttlSec: number) {
|
|
120
|
+
await redis.set(key, value, 'EX', ttlSec);
|
|
121
|
+
},
|
|
122
|
+
async get(key: string) {
|
|
123
|
+
return redis.get(key);
|
|
124
|
+
},
|
|
125
|
+
};`,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
path: 'auth-server/src/app/api/auth/workos/start/route.ts',
|
|
129
|
+
label: 'start / route.ts',
|
|
130
|
+
status: 'modified',
|
|
131
|
+
code: `export function GET(req: Request) {
|
|
132
|
+
const url = workos.userManagement.getAuthorizationUrl({
|
|
133
|
+
provider: 'authkit',
|
|
134
|
+
redirectUri: env.WORKOS_REDIRECT_URI,
|
|
135
|
+
});
|
|
136
|
+
return NextResponse.redirect(url);
|
|
137
|
+
}`,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
path: 'auth-server/src/app/api/auth/workos/token/route.ts',
|
|
141
|
+
label: 'token / route.ts',
|
|
142
|
+
status: 'deleted',
|
|
143
|
+
code: `export async function POST(req: Request) {
|
|
144
|
+
const { code, codeVerifier } = await req.json();
|
|
145
|
+
const result = await workos.userManagement
|
|
146
|
+
.authenticateWithCode({ code, codeVerifier });
|
|
147
|
+
return Response.json(result);
|
|
148
|
+
}`,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
path: 'auth-server/src/app/page.tsx',
|
|
152
|
+
label: 'page.tsx',
|
|
153
|
+
status: 'modified',
|
|
154
|
+
code: `export default async function Page() {
|
|
155
|
+
const session = await authProvider.getSession(headers());
|
|
156
|
+
if (!session) redirect('/login');
|
|
157
|
+
return <Dashboard session={session} />;
|
|
158
|
+
}`,
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
interface FitParams {
|
|
163
|
+
scale: number;
|
|
164
|
+
offsetX: number;
|
|
165
|
+
offsetZ: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Replicates calculateScaleAndOffset from ArchitectureMapHighlightLayers.
|
|
169
|
+
function fitCityToBox(
|
|
170
|
+
bounds: CityData['bounds'],
|
|
171
|
+
width: number,
|
|
172
|
+
height: number,
|
|
173
|
+
padding: number,
|
|
174
|
+
): FitParams {
|
|
175
|
+
const cityWidth = bounds.maxX - bounds.minX;
|
|
176
|
+
const cityDepth = bounds.maxZ - bounds.minZ;
|
|
177
|
+
const horizontalPadding = padding;
|
|
178
|
+
const verticalPadding = padding * 2;
|
|
179
|
+
const scaleX = (width - horizontalPadding) / cityDepth;
|
|
180
|
+
const scaleZ = (height - verticalPadding) / cityWidth;
|
|
181
|
+
const scale = Math.min(scaleX, scaleZ);
|
|
182
|
+
const scaledCityWidth = cityDepth * scale;
|
|
183
|
+
const scaledCityHeight = cityWidth * scale;
|
|
184
|
+
return {
|
|
185
|
+
scale,
|
|
186
|
+
offsetX: (width - scaledCityWidth) / 2,
|
|
187
|
+
offsetZ: (height - scaledCityHeight) / 2,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface ItemLayout {
|
|
192
|
+
path: string;
|
|
193
|
+
buildingAnchor: { x: number; y: number };
|
|
194
|
+
buildingRect: { x: number; y: number; w: number; h: number };
|
|
195
|
+
card: { x: number; y: number; w: number; h: number };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
interface StageLayout {
|
|
199
|
+
stageW: number;
|
|
200
|
+
stageH: number;
|
|
201
|
+
viewport: { top: number; bottom: number } | null;
|
|
202
|
+
items: ItemLayout[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const SingleLeaderLine: StoryObj = {
|
|
206
|
+
render: function RenderLeaderLines() {
|
|
207
|
+
const cityData = authServerCityData as CityData;
|
|
208
|
+
const highlightLayers = createFileColorHighlightLayers(cityData.buildings);
|
|
209
|
+
|
|
210
|
+
// Resolve which snippets actually have buildings in the dataset.
|
|
211
|
+
const resolvedSnippets = useMemo(
|
|
212
|
+
() =>
|
|
213
|
+
SNIPPETS.flatMap(s => {
|
|
214
|
+
const building = cityData.buildings.find(b => b.path === s.path);
|
|
215
|
+
return building ? [{ snippet: s, building }] : [];
|
|
216
|
+
}),
|
|
217
|
+
[cityData.buildings],
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const stageRef = useRef<HTMLDivElement | null>(null);
|
|
221
|
+
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
|
|
222
|
+
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
|
223
|
+
const cardRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
|
224
|
+
|
|
225
|
+
const [layout, setLayout] = useState<StageLayout>({
|
|
226
|
+
stageW: 0,
|
|
227
|
+
stageH: 0,
|
|
228
|
+
viewport: null,
|
|
229
|
+
items: [],
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Cards that have been visible for >= DWELL_MS at some point this session.
|
|
233
|
+
// Sticky for the session. Auto-approval = seen && currently above viewport.
|
|
234
|
+
const [seenPaths, setSeenPaths] = useState<Set<string>>(new Set());
|
|
235
|
+
// Manual approvals. Sticky relative to scroll: scrolling back never undoes
|
|
236
|
+
// these. Click again to toggle off.
|
|
237
|
+
const [manualApproved, setManualApproved] = useState<Set<string>>(new Set());
|
|
238
|
+
|
|
239
|
+
// Pending dwell timers per path; read inside `measure` via a ref so we
|
|
240
|
+
// don't have to depend on rapidly-changing state.
|
|
241
|
+
const seenTimersRef = useRef<Map<string, number>>(new Map());
|
|
242
|
+
const seenPathsRef = useRef(seenPaths);
|
|
243
|
+
seenPathsRef.current = seenPaths;
|
|
244
|
+
|
|
245
|
+
useLayoutEffect(() => {
|
|
246
|
+
const stage = stageRef.current;
|
|
247
|
+
const canvasWrap = canvasWrapRef.current;
|
|
248
|
+
if (!stage || !canvasWrap) return;
|
|
249
|
+
|
|
250
|
+
const measure = () => {
|
|
251
|
+
const stageRect = stage.getBoundingClientRect();
|
|
252
|
+
const canvasRect = canvasWrap.getBoundingClientRect();
|
|
253
|
+
const scrollRect = scrollContainerRef.current?.getBoundingClientRect();
|
|
254
|
+
|
|
255
|
+
const fit = fitCityToBox(
|
|
256
|
+
cityData.bounds,
|
|
257
|
+
canvasRect.width,
|
|
258
|
+
canvasRect.height,
|
|
259
|
+
CANVAS_PADDING,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const items: ItemLayout[] = resolvedSnippets.flatMap(({ snippet, building }) => {
|
|
263
|
+
const card = cardRefs.current.get(snippet.path);
|
|
264
|
+
if (!card) return [];
|
|
265
|
+
const cardRect = card.getBoundingClientRect();
|
|
266
|
+
|
|
267
|
+
const buildingX =
|
|
268
|
+
(building.position.x - cityData.bounds.minX) * fit.scale + fit.offsetX;
|
|
269
|
+
const buildingY =
|
|
270
|
+
(building.position.z - cityData.bounds.minZ) * fit.scale + fit.offsetZ;
|
|
271
|
+
const halfW = (building.dimensions[0] / 2) * fit.scale;
|
|
272
|
+
const halfH = (building.dimensions[2] / 2) * fit.scale;
|
|
273
|
+
const stageBuildingX = canvasRect.left - stageRect.left + buildingX;
|
|
274
|
+
const stageBuildingY = canvasRect.top - stageRect.top + buildingY;
|
|
275
|
+
|
|
276
|
+
return [
|
|
277
|
+
{
|
|
278
|
+
path: snippet.path,
|
|
279
|
+
buildingAnchor: { x: stageBuildingX, y: stageBuildingY },
|
|
280
|
+
buildingRect: {
|
|
281
|
+
x: stageBuildingX - halfW + BUILDING_RECT_INSET,
|
|
282
|
+
y: stageBuildingY - halfH + BUILDING_RECT_INSET,
|
|
283
|
+
w: halfW * 2 - BUILDING_RECT_INSET * 2,
|
|
284
|
+
h: halfH * 2 - BUILDING_RECT_INSET * 2,
|
|
285
|
+
},
|
|
286
|
+
card: {
|
|
287
|
+
x: cardRect.left - stageRect.left,
|
|
288
|
+
y: cardRect.top - stageRect.top,
|
|
289
|
+
w: cardRect.width,
|
|
290
|
+
h: cardRect.height,
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
];
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const viewport = scrollRect
|
|
297
|
+
? {
|
|
298
|
+
top: scrollRect.top - stageRect.top,
|
|
299
|
+
bottom: scrollRect.bottom - stageRect.top,
|
|
300
|
+
}
|
|
301
|
+
: null;
|
|
302
|
+
|
|
303
|
+
setLayout({
|
|
304
|
+
stageW: stageRect.width,
|
|
305
|
+
stageH: stageRect.height,
|
|
306
|
+
viewport,
|
|
307
|
+
items,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Track dwell: any card overlapping the viewport gets a timer that
|
|
311
|
+
// adds it to `seenPaths` after DWELL_MS. If it leaves before then,
|
|
312
|
+
// cancel. Already-seen paths are ignored.
|
|
313
|
+
if (viewport) {
|
|
314
|
+
const visibleNow = new Set<string>();
|
|
315
|
+
for (const it of items) {
|
|
316
|
+
const cardTop = it.card.y;
|
|
317
|
+
const cardBottom = it.card.y + it.card.h;
|
|
318
|
+
if (cardBottom > viewport.top && cardTop < viewport.bottom) {
|
|
319
|
+
visibleNow.add(it.path);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
for (const p of visibleNow) {
|
|
323
|
+
if (seenPathsRef.current.has(p)) continue;
|
|
324
|
+
if (seenTimersRef.current.has(p)) continue;
|
|
325
|
+
const id = window.setTimeout(() => {
|
|
326
|
+
seenTimersRef.current.delete(p);
|
|
327
|
+
setSeenPaths(prev => {
|
|
328
|
+
if (prev.has(p)) return prev;
|
|
329
|
+
const next = new Set(prev);
|
|
330
|
+
next.add(p);
|
|
331
|
+
return next;
|
|
332
|
+
});
|
|
333
|
+
}, DWELL_MS);
|
|
334
|
+
seenTimersRef.current.set(p, id);
|
|
335
|
+
}
|
|
336
|
+
for (const [p, id] of seenTimersRef.current) {
|
|
337
|
+
if (!visibleNow.has(p)) {
|
|
338
|
+
clearTimeout(id);
|
|
339
|
+
seenTimersRef.current.delete(p);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
measure();
|
|
346
|
+
const ro = new ResizeObserver(measure);
|
|
347
|
+
ro.observe(stage);
|
|
348
|
+
ro.observe(canvasWrap);
|
|
349
|
+
cardRefs.current.forEach(el => el && ro.observe(el));
|
|
350
|
+
window.addEventListener('resize', measure);
|
|
351
|
+
const scrollEl = scrollContainerRef.current;
|
|
352
|
+
scrollEl?.addEventListener('scroll', measure, { passive: true });
|
|
353
|
+
return () => {
|
|
354
|
+
ro.disconnect();
|
|
355
|
+
window.removeEventListener('resize', measure);
|
|
356
|
+
scrollEl?.removeEventListener('scroll', measure);
|
|
357
|
+
for (const id of seenTimersRef.current.values()) clearTimeout(id);
|
|
358
|
+
seenTimersRef.current.clear();
|
|
359
|
+
};
|
|
360
|
+
}, [cityData.bounds, resolvedSnippets]);
|
|
361
|
+
|
|
362
|
+
const toggleManual = (path: string) =>
|
|
363
|
+
setManualApproved(prev => {
|
|
364
|
+
const next = new Set(prev);
|
|
365
|
+
if (next.has(path)) next.delete(path);
|
|
366
|
+
else next.add(path);
|
|
367
|
+
return next;
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Per-snippet approval state: manual is sticky; auto fires once a card
|
|
371
|
+
// has been seen (>= DWELL_MS visible) AND its bottom edge has cleared
|
|
372
|
+
// the viewport's vertical midpoint (the whole card is above the line).
|
|
373
|
+
const approvalFor = (path: string, cardBottomY: number, vpMid: number) => {
|
|
374
|
+
if (manualApproved.has(path)) return 'manual' as const;
|
|
375
|
+
if (seenPaths.has(path) && cardBottomY < vpMid) return 'auto' as const;
|
|
376
|
+
return 'pending' as const;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// Build the per-snippet rendering data: clamp anchor to the viewport so
|
|
380
|
+
// the line recedes (and fades) as the card scrolls out of view.
|
|
381
|
+
const renderItems = layout.items.flatMap(item => {
|
|
382
|
+
if (!layout.viewport) return [];
|
|
383
|
+
const cardCenterY = item.card.y + item.card.h / 2;
|
|
384
|
+
const { top, bottom } = layout.viewport;
|
|
385
|
+
|
|
386
|
+
// Fade based on the leader-line intersection point only — the card's
|
|
387
|
+
// vertical center. Once that point leaves the viewport, fade out over
|
|
388
|
+
// FADE_PX of further scroll.
|
|
389
|
+
let intersectionDistance = 0;
|
|
390
|
+
if (cardCenterY < top) intersectionDistance = top - cardCenterY;
|
|
391
|
+
else if (cardCenterY > bottom) intersectionDistance = cardCenterY - bottom;
|
|
392
|
+
const lineOpacity = Math.max(0, 1 - intersectionDistance / FADE_PX);
|
|
393
|
+
|
|
394
|
+
const clampedY = Math.max(top, Math.min(bottom, cardCenterY));
|
|
395
|
+
const a = item.buildingAnchor;
|
|
396
|
+
const b = { x: item.card.x, y: clampedY };
|
|
397
|
+
const dx = Math.max(80, (b.x - a.x) * 0.5);
|
|
398
|
+
const path = `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`;
|
|
399
|
+
|
|
400
|
+
const snippet = resolvedSnippets.find(s => s.snippet.path === item.path)?.snippet;
|
|
401
|
+
const cardBottomY = item.card.y + item.card.h;
|
|
402
|
+
const approval = approvalFor(item.path, cardBottomY, (top + bottom) / 2);
|
|
403
|
+
const color = snippet ? STATUS_COLOR[snippet.status] : '#9ca3af';
|
|
404
|
+
|
|
405
|
+
return [
|
|
406
|
+
{
|
|
407
|
+
path: item.path,
|
|
408
|
+
color,
|
|
409
|
+
d: path,
|
|
410
|
+
buildingAnchor: a,
|
|
411
|
+
buildingRect: item.buildingRect,
|
|
412
|
+
panelAnchor: b,
|
|
413
|
+
lineOpacity,
|
|
414
|
+
approval,
|
|
415
|
+
},
|
|
416
|
+
];
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const approvalSet = new Set(
|
|
420
|
+
renderItems.filter(r => r.approval !== 'pending').map(r => r.path),
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const allApproved =
|
|
424
|
+
resolvedSnippets.length > 0 &&
|
|
425
|
+
resolvedSnippets.every(({ snippet }) => approvalSet.has(snippet.path));
|
|
426
|
+
|
|
427
|
+
// "Has started": any card has crossed into the viewport. Initial state
|
|
428
|
+
// (cards still parked below the bottom padding) shows the start prompt.
|
|
429
|
+
const hasStarted =
|
|
430
|
+
layout.viewport != null &&
|
|
431
|
+
layout.items.some(it => it.card.y < layout.viewport!.bottom);
|
|
432
|
+
const showStartPrompt = !hasStarted && !allApproved;
|
|
433
|
+
|
|
434
|
+
return (
|
|
435
|
+
<div
|
|
436
|
+
style={{
|
|
437
|
+
width: '100vw',
|
|
438
|
+
height: '100vh',
|
|
439
|
+
display: 'flex',
|
|
440
|
+
flexDirection: 'column',
|
|
441
|
+
backgroundColor: '#0f1419',
|
|
442
|
+
}}
|
|
443
|
+
>
|
|
444
|
+
<div
|
|
445
|
+
style={{
|
|
446
|
+
padding: '12px 16px',
|
|
447
|
+
backgroundColor: '#1f2937',
|
|
448
|
+
borderBottom: '1px solid #374151',
|
|
449
|
+
color: '#9ca3af',
|
|
450
|
+
fontSize: 13,
|
|
451
|
+
}}
|
|
452
|
+
>
|
|
453
|
+
PR review prototype — {approvalSet.size}/{resolvedSnippets.length}{' '}
|
|
454
|
+
approved. Scroll a card fully past the middle line to auto-approve
|
|
455
|
+
(dashed → solid green); click "approve" to make it sticky.
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
<div
|
|
459
|
+
ref={stageRef}
|
|
460
|
+
style={{
|
|
461
|
+
flex: 1,
|
|
462
|
+
position: 'relative',
|
|
463
|
+
display: 'flex',
|
|
464
|
+
minHeight: 0,
|
|
465
|
+
}}
|
|
466
|
+
>
|
|
467
|
+
<div ref={canvasWrapRef} style={{ flex: 1, position: 'relative', minWidth: 0 }}>
|
|
468
|
+
<ArchitectureMapHighlightLayers
|
|
469
|
+
cityData={cityData}
|
|
470
|
+
highlightLayers={highlightLayers}
|
|
471
|
+
fullSize
|
|
472
|
+
canvasBackgroundColor="#0f1419"
|
|
473
|
+
defaultBuildingColor="#36454F"
|
|
474
|
+
defaultDirectoryColor="#111827"
|
|
475
|
+
enableZoom={false}
|
|
476
|
+
/>
|
|
477
|
+
</div>
|
|
478
|
+
|
|
479
|
+
<div
|
|
480
|
+
ref={scrollContainerRef}
|
|
481
|
+
style={{
|
|
482
|
+
width: PANEL_WIDTH,
|
|
483
|
+
borderLeft: '1px solid #1f2937',
|
|
484
|
+
backgroundColor: '#0b0f14',
|
|
485
|
+
overflowY: 'auto',
|
|
486
|
+
}}
|
|
487
|
+
>
|
|
488
|
+
<div
|
|
489
|
+
style={{
|
|
490
|
+
padding: 24,
|
|
491
|
+
// Start cards below the viewport so the user has to scroll
|
|
492
|
+
// them in (the "Scroll to Start" overlay covers the empty
|
|
493
|
+
// initial state).
|
|
494
|
+
paddingTop: '100vh',
|
|
495
|
+
// Enough bottom padding that the last card can scroll well
|
|
496
|
+
// above the midpoint approval line.
|
|
497
|
+
paddingBottom: '70vh',
|
|
498
|
+
display: 'flex',
|
|
499
|
+
flexDirection: 'column',
|
|
500
|
+
gap: 16,
|
|
501
|
+
}}
|
|
502
|
+
>
|
|
503
|
+
{resolvedSnippets.map(({ snippet }) => {
|
|
504
|
+
const statusColor = STATUS_COLOR[snippet.status];
|
|
505
|
+
const isManual = manualApproved.has(snippet.path);
|
|
506
|
+
const isApproved = approvalSet.has(snippet.path);
|
|
507
|
+
const borderColor = isApproved ? APPROVED_COLOR : statusColor;
|
|
508
|
+
const borderStyle = isApproved ? 'solid' : 'dashed';
|
|
509
|
+
return (
|
|
510
|
+
<div
|
|
511
|
+
key={snippet.path}
|
|
512
|
+
ref={el => {
|
|
513
|
+
cardRefs.current.set(snippet.path, el);
|
|
514
|
+
}}
|
|
515
|
+
style={{
|
|
516
|
+
backgroundColor: '#111827',
|
|
517
|
+
border: `1.5px ${borderStyle} ${
|
|
518
|
+
isApproved ? APPROVED_COLOR : '#374151'
|
|
519
|
+
}`,
|
|
520
|
+
borderRadius: 8,
|
|
521
|
+
padding: 14,
|
|
522
|
+
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
|
|
523
|
+
borderLeft: `3px solid ${borderColor}`,
|
|
524
|
+
transition: 'border-color 200ms ease',
|
|
525
|
+
}}
|
|
526
|
+
>
|
|
527
|
+
<div
|
|
528
|
+
style={{
|
|
529
|
+
display: 'flex',
|
|
530
|
+
alignItems: 'center',
|
|
531
|
+
justifyContent: 'space-between',
|
|
532
|
+
marginBottom: 8,
|
|
533
|
+
gap: 8,
|
|
534
|
+
}}
|
|
535
|
+
>
|
|
536
|
+
<div
|
|
537
|
+
style={{
|
|
538
|
+
display: 'flex',
|
|
539
|
+
alignItems: 'center',
|
|
540
|
+
gap: 8,
|
|
541
|
+
minWidth: 0,
|
|
542
|
+
}}
|
|
543
|
+
>
|
|
544
|
+
<span
|
|
545
|
+
style={{
|
|
546
|
+
fontSize: 10,
|
|
547
|
+
fontWeight: 600,
|
|
548
|
+
letterSpacing: 0.5,
|
|
549
|
+
textTransform: 'uppercase',
|
|
550
|
+
color: statusColor,
|
|
551
|
+
padding: '2px 6px',
|
|
552
|
+
borderRadius: 3,
|
|
553
|
+
backgroundColor: `${statusColor}22`,
|
|
554
|
+
whiteSpace: 'nowrap',
|
|
555
|
+
}}
|
|
556
|
+
>
|
|
557
|
+
{STATUS_LABEL[snippet.status]}
|
|
558
|
+
</span>
|
|
559
|
+
<span
|
|
560
|
+
style={{
|
|
561
|
+
fontSize: 11,
|
|
562
|
+
color: '#9ca3af',
|
|
563
|
+
textTransform: 'uppercase',
|
|
564
|
+
letterSpacing: 0.5,
|
|
565
|
+
overflow: 'hidden',
|
|
566
|
+
textOverflow: 'ellipsis',
|
|
567
|
+
whiteSpace: 'nowrap',
|
|
568
|
+
}}
|
|
569
|
+
>
|
|
570
|
+
{snippet.label}
|
|
571
|
+
</span>
|
|
572
|
+
</div>
|
|
573
|
+
<button
|
|
574
|
+
onClick={() => toggleManual(snippet.path)}
|
|
575
|
+
style={{
|
|
576
|
+
fontSize: 10,
|
|
577
|
+
letterSpacing: 0.5,
|
|
578
|
+
textTransform: 'uppercase',
|
|
579
|
+
padding: '4px 8px',
|
|
580
|
+
borderRadius: 4,
|
|
581
|
+
border: `1px solid ${
|
|
582
|
+
isManual ? APPROVED_COLOR : '#374151'
|
|
583
|
+
}`,
|
|
584
|
+
backgroundColor: isManual
|
|
585
|
+
? 'rgba(34, 197, 94, 0.15)'
|
|
586
|
+
: isApproved
|
|
587
|
+
? 'rgba(34, 197, 94, 0.08)'
|
|
588
|
+
: 'transparent',
|
|
589
|
+
color: isApproved ? APPROVED_COLOR : '#9ca3af',
|
|
590
|
+
cursor: 'pointer',
|
|
591
|
+
fontWeight: 600,
|
|
592
|
+
}}
|
|
593
|
+
>
|
|
594
|
+
{isManual
|
|
595
|
+
? '✓ approved'
|
|
596
|
+
: isApproved
|
|
597
|
+
? '✓ auto'
|
|
598
|
+
: 'approve'}
|
|
599
|
+
</button>
|
|
600
|
+
</div>
|
|
601
|
+
<pre
|
|
602
|
+
style={{
|
|
603
|
+
margin: 0,
|
|
604
|
+
fontSize: 11.5,
|
|
605
|
+
lineHeight: 1.5,
|
|
606
|
+
color: '#e5e7eb',
|
|
607
|
+
fontFamily:
|
|
608
|
+
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
609
|
+
whiteSpace: 'pre-wrap',
|
|
610
|
+
}}
|
|
611
|
+
>
|
|
612
|
+
{snippet.code}
|
|
613
|
+
</pre>
|
|
614
|
+
</div>
|
|
615
|
+
);
|
|
616
|
+
})}
|
|
617
|
+
</div>
|
|
618
|
+
</div>
|
|
619
|
+
|
|
620
|
+
<svg
|
|
621
|
+
width={layout.stageW}
|
|
622
|
+
height={layout.stageH}
|
|
623
|
+
style={{
|
|
624
|
+
position: 'absolute',
|
|
625
|
+
top: 0,
|
|
626
|
+
left: 0,
|
|
627
|
+
pointerEvents: 'none',
|
|
628
|
+
zIndex: 5,
|
|
629
|
+
}}
|
|
630
|
+
>
|
|
631
|
+
{/* Approval boundary guide line across the panel's vertical
|
|
632
|
+
midpoint, so the threshold is visible while tuning. */}
|
|
633
|
+
{layout.viewport && (
|
|
634
|
+
<line
|
|
635
|
+
x1={layout.stageW - PANEL_WIDTH}
|
|
636
|
+
x2={layout.stageW}
|
|
637
|
+
y1={(layout.viewport.top + layout.viewport.bottom) / 2}
|
|
638
|
+
y2={(layout.viewport.top + layout.viewport.bottom) / 2}
|
|
639
|
+
stroke="#6b7280"
|
|
640
|
+
strokeWidth={1}
|
|
641
|
+
strokeDasharray="2 4"
|
|
642
|
+
opacity={0.4}
|
|
643
|
+
/>
|
|
644
|
+
)}
|
|
645
|
+
|
|
646
|
+
{/* Building borders: drawn for every snippet, not gated by line
|
|
647
|
+
visibility — so approved buildings stay marked even after
|
|
648
|
+
their card scrolls off. */}
|
|
649
|
+
{renderItems.map(item => {
|
|
650
|
+
const approved = item.approval !== 'pending';
|
|
651
|
+
const stroke = approved ? APPROVED_COLOR : item.color;
|
|
652
|
+
return (
|
|
653
|
+
<rect
|
|
654
|
+
key={`border-${item.path}`}
|
|
655
|
+
x={item.buildingRect.x}
|
|
656
|
+
y={item.buildingRect.y}
|
|
657
|
+
width={item.buildingRect.w}
|
|
658
|
+
height={item.buildingRect.h}
|
|
659
|
+
fill="none"
|
|
660
|
+
stroke={stroke}
|
|
661
|
+
strokeWidth={approved ? 2 : 1.5}
|
|
662
|
+
strokeDasharray={approved ? undefined : '3 3'}
|
|
663
|
+
rx={1.5}
|
|
664
|
+
ry={1.5}
|
|
665
|
+
opacity={approved ? 1 : 0.9}
|
|
666
|
+
/>
|
|
667
|
+
);
|
|
668
|
+
})}
|
|
669
|
+
|
|
670
|
+
{/* Leader lines + endpoint markers: fade with the card and are
|
|
671
|
+
hidden once the snippet is approved (the building border alone
|
|
672
|
+
carries the approved state). */}
|
|
673
|
+
{renderItems.map(item => {
|
|
674
|
+
if (item.lineOpacity <= 0) return null;
|
|
675
|
+
if (item.approval !== 'pending') return null;
|
|
676
|
+
return (
|
|
677
|
+
<g key={`line-${item.path}`} opacity={item.lineOpacity}>
|
|
678
|
+
<path
|
|
679
|
+
d={item.d}
|
|
680
|
+
fill="none"
|
|
681
|
+
stroke={item.color}
|
|
682
|
+
strokeWidth={1.5}
|
|
683
|
+
strokeDasharray="4 4"
|
|
684
|
+
opacity={0.85}
|
|
685
|
+
/>
|
|
686
|
+
<rect
|
|
687
|
+
x={item.buildingAnchor.x - 3.5}
|
|
688
|
+
y={item.buildingAnchor.y - 3.5}
|
|
689
|
+
width={7}
|
|
690
|
+
height={7}
|
|
691
|
+
fill={item.color}
|
|
692
|
+
stroke="#0f1419"
|
|
693
|
+
strokeWidth={1.25}
|
|
694
|
+
/>
|
|
695
|
+
<circle
|
|
696
|
+
cx={item.panelAnchor.x}
|
|
697
|
+
cy={item.panelAnchor.y}
|
|
698
|
+
r={3}
|
|
699
|
+
fill={item.color}
|
|
700
|
+
/>
|
|
701
|
+
</g>
|
|
702
|
+
);
|
|
703
|
+
})}
|
|
704
|
+
</svg>
|
|
705
|
+
|
|
706
|
+
{/* Review-complete overlay: pinned over the panel column, fades
|
|
707
|
+
and scales in once every snippet is approved. */}
|
|
708
|
+
<style>{`
|
|
709
|
+
@keyframes leaderline-review-pop {
|
|
710
|
+
0% { transform: scale(0.6) rotate(-12deg); opacity: 0; }
|
|
711
|
+
60% { transform: scale(1.15) rotate(2deg); opacity: 1; }
|
|
712
|
+
100% { transform: scale(1) rotate(0); opacity: 1; }
|
|
713
|
+
}
|
|
714
|
+
@keyframes leaderline-check-draw {
|
|
715
|
+
from { stroke-dashoffset: 48; }
|
|
716
|
+
to { stroke-dashoffset: 0; }
|
|
717
|
+
}
|
|
718
|
+
@keyframes leaderline-ring-pulse {
|
|
719
|
+
0% { transform: scale(0.85); opacity: 0.9; }
|
|
720
|
+
100% { transform: scale(1.6); opacity: 0; }
|
|
721
|
+
}
|
|
722
|
+
@keyframes leaderline-arrow-bounce {
|
|
723
|
+
0%, 100% { transform: translateY(0); opacity: 0.7; }
|
|
724
|
+
50% { transform: translateY(8px); opacity: 1; }
|
|
725
|
+
}
|
|
726
|
+
`}</style>
|
|
727
|
+
|
|
728
|
+
{/* Start prompt: shown until the user scrolls a card into view. */}
|
|
729
|
+
<div
|
|
730
|
+
style={{
|
|
731
|
+
position: 'absolute',
|
|
732
|
+
top: 0,
|
|
733
|
+
right: 0,
|
|
734
|
+
width: PANEL_WIDTH,
|
|
735
|
+
height: '100%',
|
|
736
|
+
display: 'flex',
|
|
737
|
+
alignItems: 'center',
|
|
738
|
+
justifyContent: 'center',
|
|
739
|
+
pointerEvents: 'none',
|
|
740
|
+
opacity: showStartPrompt ? 1 : 0,
|
|
741
|
+
transition: 'opacity 300ms ease-out',
|
|
742
|
+
zIndex: 15,
|
|
743
|
+
}}
|
|
744
|
+
>
|
|
745
|
+
<div
|
|
746
|
+
style={{
|
|
747
|
+
display: 'flex',
|
|
748
|
+
flexDirection: 'column',
|
|
749
|
+
alignItems: 'center',
|
|
750
|
+
gap: 14,
|
|
751
|
+
color: '#9ca3af',
|
|
752
|
+
textAlign: 'center',
|
|
753
|
+
padding: '0 24px',
|
|
754
|
+
}}
|
|
755
|
+
>
|
|
756
|
+
<div
|
|
757
|
+
style={{
|
|
758
|
+
fontSize: 11,
|
|
759
|
+
textTransform: 'uppercase',
|
|
760
|
+
letterSpacing: 1.5,
|
|
761
|
+
color: '#6b7280',
|
|
762
|
+
}}
|
|
763
|
+
>
|
|
764
|
+
{resolvedSnippets.length} files changed
|
|
765
|
+
</div>
|
|
766
|
+
<div
|
|
767
|
+
style={{
|
|
768
|
+
fontSize: 20,
|
|
769
|
+
fontWeight: 600,
|
|
770
|
+
color: '#e5e7eb',
|
|
771
|
+
letterSpacing: 0.3,
|
|
772
|
+
}}
|
|
773
|
+
>
|
|
774
|
+
Scroll to Start Code Review
|
|
775
|
+
</div>
|
|
776
|
+
<div style={{ fontSize: 12, color: '#6b7280', maxWidth: 260 }}>
|
|
777
|
+
Each snippet you scroll past the midpoint line is auto-approved.
|
|
778
|
+
</div>
|
|
779
|
+
<svg
|
|
780
|
+
width={28}
|
|
781
|
+
height={28}
|
|
782
|
+
viewBox="0 0 24 24"
|
|
783
|
+
fill="none"
|
|
784
|
+
style={{
|
|
785
|
+
marginTop: 4,
|
|
786
|
+
animation:
|
|
787
|
+
'leaderline-arrow-bounce 1400ms ease-in-out infinite',
|
|
788
|
+
}}
|
|
789
|
+
>
|
|
790
|
+
<path
|
|
791
|
+
d="M6 9l6 6 6-6"
|
|
792
|
+
stroke="#9ca3af"
|
|
793
|
+
strokeWidth={2}
|
|
794
|
+
strokeLinecap="round"
|
|
795
|
+
strokeLinejoin="round"
|
|
796
|
+
/>
|
|
797
|
+
</svg>
|
|
798
|
+
</div>
|
|
799
|
+
</div>
|
|
800
|
+
<div
|
|
801
|
+
style={{
|
|
802
|
+
position: 'absolute',
|
|
803
|
+
top: 0,
|
|
804
|
+
right: 0,
|
|
805
|
+
width: PANEL_WIDTH,
|
|
806
|
+
height: '100%',
|
|
807
|
+
display: 'flex',
|
|
808
|
+
alignItems: 'center',
|
|
809
|
+
justifyContent: 'center',
|
|
810
|
+
pointerEvents: 'none',
|
|
811
|
+
backgroundColor: allApproved
|
|
812
|
+
? 'rgba(11, 15, 20, 0.78)'
|
|
813
|
+
: 'transparent',
|
|
814
|
+
backdropFilter: allApproved ? 'blur(2px)' : 'none',
|
|
815
|
+
opacity: allApproved ? 1 : 0,
|
|
816
|
+
transition:
|
|
817
|
+
'opacity 350ms ease-out, background-color 350ms ease-out',
|
|
818
|
+
zIndex: 20,
|
|
819
|
+
}}
|
|
820
|
+
>
|
|
821
|
+
<div
|
|
822
|
+
key={allApproved ? 'shown' : 'hidden'}
|
|
823
|
+
style={{
|
|
824
|
+
display: 'flex',
|
|
825
|
+
flexDirection: 'column',
|
|
826
|
+
alignItems: 'center',
|
|
827
|
+
gap: 16,
|
|
828
|
+
animation: allApproved
|
|
829
|
+
? 'leaderline-review-pop 600ms cubic-bezier(0.34, 1.56, 0.64, 1) both'
|
|
830
|
+
: 'none',
|
|
831
|
+
}}
|
|
832
|
+
>
|
|
833
|
+
<div style={{ position: 'relative', width: 84, height: 84 }}>
|
|
834
|
+
{allApproved && (
|
|
835
|
+
<div
|
|
836
|
+
style={{
|
|
837
|
+
position: 'absolute',
|
|
838
|
+
inset: 0,
|
|
839
|
+
borderRadius: '50%',
|
|
840
|
+
border: `2px solid ${APPROVED_COLOR}`,
|
|
841
|
+
animation:
|
|
842
|
+
'leaderline-ring-pulse 900ms ease-out 200ms both',
|
|
843
|
+
}}
|
|
844
|
+
/>
|
|
845
|
+
)}
|
|
846
|
+
<div
|
|
847
|
+
style={{
|
|
848
|
+
width: 84,
|
|
849
|
+
height: 84,
|
|
850
|
+
borderRadius: '50%',
|
|
851
|
+
backgroundColor: 'rgba(34, 197, 94, 0.15)',
|
|
852
|
+
border: `2px solid ${APPROVED_COLOR}`,
|
|
853
|
+
display: 'flex',
|
|
854
|
+
alignItems: 'center',
|
|
855
|
+
justifyContent: 'center',
|
|
856
|
+
}}
|
|
857
|
+
>
|
|
858
|
+
<svg width={44} height={44} viewBox="0 0 24 24" fill="none">
|
|
859
|
+
<path
|
|
860
|
+
d="M5 12.5l4.5 4.5L19 7"
|
|
861
|
+
stroke={APPROVED_COLOR}
|
|
862
|
+
strokeWidth={2.5}
|
|
863
|
+
strokeLinecap="round"
|
|
864
|
+
strokeLinejoin="round"
|
|
865
|
+
strokeDasharray={48}
|
|
866
|
+
style={{
|
|
867
|
+
animation: allApproved
|
|
868
|
+
? 'leaderline-check-draw 450ms ease-out 250ms both'
|
|
869
|
+
: 'none',
|
|
870
|
+
}}
|
|
871
|
+
/>
|
|
872
|
+
</svg>
|
|
873
|
+
</div>
|
|
874
|
+
</div>
|
|
875
|
+
<div
|
|
876
|
+
style={{
|
|
877
|
+
fontSize: 22,
|
|
878
|
+
fontWeight: 600,
|
|
879
|
+
color: '#e5e7eb',
|
|
880
|
+
letterSpacing: 0.3,
|
|
881
|
+
}}
|
|
882
|
+
>
|
|
883
|
+
Review Completed
|
|
884
|
+
</div>
|
|
885
|
+
<div style={{ fontSize: 13, color: '#9ca3af' }}>
|
|
886
|
+
{approvalSet.size} of {resolvedSnippets.length} files approved
|
|
887
|
+
</div>
|
|
888
|
+
</div>
|
|
889
|
+
</div>
|
|
890
|
+
</div>
|
|
891
|
+
</div>
|
|
892
|
+
);
|
|
893
|
+
},
|
|
894
|
+
};
|