@jkwd/inbase 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +13 -7
  2. package/apps/explorer/package.json +1 -0
  3. package/apps/explorer/scripts/explain-store.d.ts +135 -0
  4. package/apps/explorer/scripts/explain-store.mjs +666 -0
  5. package/apps/explorer/scripts/patch-lib.mjs +4 -0
  6. package/apps/explorer/scripts/scan-target.mjs +22 -5
  7. package/apps/explorer/scripts/session-store.d.ts +86 -11
  8. package/apps/explorer/scripts/session-store.mjs +371 -58
  9. package/apps/explorer/scripts/target-config.d.ts +38 -3
  10. package/apps/explorer/scripts/target-config.mjs +147 -3
  11. package/apps/explorer/src/App.tsx +1073 -158
  12. package/apps/explorer/src/agentIntent.ts +61 -7
  13. package/apps/explorer/src/codebase.ts +1 -1
  14. package/apps/explorer/src/devTargets.ts +66 -0
  15. package/apps/explorer/src/explain.ts +312 -0
  16. package/apps/explorer/src/index.css +874 -222
  17. package/apps/explorer/src/layout.ts +55 -0
  18. package/apps/explorer/src/scene/DistantFileBlocks.tsx +4 -2
  19. package/apps/explorer/src/scene/FileBlock.tsx +95 -72
  20. package/apps/explorer/src/scene/FolderArea.tsx +55 -32
  21. package/apps/explorer/src/scene/MapView.tsx +506 -33
  22. package/apps/explorer/src/scene/RelationLines.tsx +7 -0
  23. package/apps/explorer/src/scene/World.tsx +146 -32
  24. package/apps/explorer/src/speech.ts +228 -0
  25. package/apps/explorer/src/theme.ts +29 -0
  26. package/apps/explorer/src/types.ts +98 -8
  27. package/apps/explorer/src/ui/CanvasErrorBoundary.tsx +1 -1
  28. package/apps/explorer/src/ui/ExplainAskCard.tsx +142 -0
  29. package/apps/explorer/src/ui/ExplainHud.tsx +524 -0
  30. package/apps/explorer/src/ui/ExplainInfoPanel.tsx +135 -0
  31. package/apps/explorer/src/ui/ExplainPointer.tsx +73 -0
  32. package/apps/explorer/src/ui/EyeIcon.tsx +38 -1
  33. package/apps/explorer/src/ui/HUD.tsx +1067 -795
  34. package/apps/explorer/src/ui/NameInput.tsx +114 -5
  35. package/apps/explorer/src/userContext.ts +0 -11
  36. package/apps/explorer/src/userCreated.ts +54 -1
  37. package/apps/explorer/vite.config.ts +173 -26
  38. package/bin/inbase.mjs +11 -2
  39. package/bin/project.mjs +6 -4
  40. package/bin/session.mjs +287 -38
  41. package/package.json +4 -1
  42. package/skill/commands/amber.md +23 -0
  43. package/skill/commands/blue.md +13 -0
  44. package/skill/commands/coral.md +23 -0
  45. package/skill/commands/explain.md +77 -0
  46. package/skill/commands/green.md +23 -0
  47. package/skill/commands/inbase.md +7 -5
  48. package/skill/commands/lime.md +23 -0
  49. package/skill/commands/orange.md +23 -0
  50. package/skill/commands/purple.md +23 -0
  51. package/skill/commands/red.md +23 -0
  52. package/skill/commands/skipinbase.md +1 -1
  53. package/skill/commands/violet.md +23 -0
  54. package/skill/commands/yellow.md +23 -0
  55. package/skill/inbase/SKILL.md +124 -76
  56. package/apps/explorer/src/scene/BlockPlacer.tsx +0 -78
  57. package/apps/explorer/src/scene/IslandPlacer.tsx +0 -31
  58. package/apps/explorer/src/scene/SelectionThumbnail.tsx +0 -1069
@@ -1,1069 +0,0 @@
1
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
- import { Canvas, useFrame, useThree } from '@react-three/fiber'
3
- import * as THREE from 'three'
4
- import {
5
- fileChangeKind,
6
- filesImporting,
7
- folderAt,
8
- folderChangeHighlights,
9
- folderOfFile,
10
- } from '../layout'
11
- import { CONFIG, WORLD_VOID, type ChangeKind } from '../theme'
12
- import { shouldIgnoreShortcut } from '../keyboard'
13
- import type { CodebaseGraph, PlacedFile, PlacedFolder, WorldLayout } from '../types'
14
- import { CanvasErrorBoundary } from '../ui/CanvasErrorBoundary'
15
- import { FileBlock } from './FileBlock'
16
- import { FolderArea } from './FolderArea'
17
- import { RelationLines } from './RelationLines'
18
-
19
- type Vec3 = [number, number, number]
20
- type Orbit = { yaw: number; pitch: number }
21
-
22
- type SelectionThumbnailProps = {
23
- graph: CodebaseGraph
24
- layout: WorldLayout
25
- selectedId: string | null
26
- selectedFolder: string | null
27
- landAt: [number, number]
28
- importedBy?: boolean
29
- minimized?: boolean
30
- maximized?: boolean
31
- plannedIds?: string[]
32
- createdIds?: string[]
33
- deletedIds?: string[]
34
- pointedFileIds?: string[]
35
- pointedFolderPaths?: string[]
36
- onMinimize?: () => void
37
- onMaximize?: () => void
38
- onHide: () => void
39
- }
40
-
41
- type ThumbnailTarget = {
42
- folder: PlacedFolder
43
- files: Array<{ file: CodebaseGraph['files'][number]; placed: PlacedFile }>
44
- relatedIds: Set<string>
45
- selectedFileId: string | null
46
- camera: {
47
- position: [number, number, number]
48
- lookAt: [number, number, number]
49
- }
50
- }
51
-
52
- function elevatedCamera(
53
- lookAt: [number, number, number],
54
- spanX: number,
55
- spanY: number,
56
- spanZ: number,
57
- towardX: 1 | -1,
58
- ): ThumbnailTarget['camera'] {
59
- const span = Math.max(spanX, spanZ, 4)
60
- const distance = Math.max(12, span * 0.9, spanY * 1.8)
61
- const height = Math.max(CONFIG.eyeHeight * 6, spanY + 8, distance * 0.7)
62
- return {
63
- position: [
64
- lookAt[0] + towardX * distance * 0.55,
65
- lookAt[1] + height,
66
- lookAt[2] + distance * 0.72,
67
- ],
68
- lookAt,
69
- }
70
- }
71
-
72
- function filesOnFolder(
73
- graph: CodebaseGraph,
74
- layout: WorldLayout,
75
- folderPath: string,
76
- ) {
77
- return graph.files.flatMap((file) => {
78
- if (file.folder !== folderPath && folderOfFile(file.id) !== folderPath) {
79
- return []
80
- }
81
- const placed = layout.files[file.id]
82
- return placed ? [{ file, placed }] : []
83
- })
84
- }
85
-
86
- function resolveTarget(
87
- graph: CodebaseGraph,
88
- layout: WorldLayout,
89
- selectedId: string | null,
90
- selectedFolder: string | null,
91
- landAt: [number, number],
92
- importedBy: boolean,
93
- ): ThumbnailTarget | null {
94
- const selectedFile = selectedId
95
- ? graph.files.find((file) => file.id === selectedId)
96
- : undefined
97
- const selectedPlaced = selectedFile
98
- ? layout.files[selectedFile.id]
99
- : undefined
100
-
101
- const folder =
102
- (selectedFile
103
- ? layout.folders[selectedFile.folder] ??
104
- layout.folders[folderOfFile(selectedFile.id)]
105
- : undefined) ??
106
- (selectedFolder ? layout.folders[selectedFolder] : undefined) ??
107
- folderAt(landAt[0], landAt[1], layout) ??
108
- Object.values(layout.folders)[0]
109
- if (!folder) return null
110
-
111
- const islandFiles = filesOnFolder(graph, layout, folder.path)
112
- const relatedIds = new Set(
113
- selectedFile
114
- ? importedBy
115
- ? filesImporting(graph.files, selectedFile.id).map((file) => file.id)
116
- : selectedFile.imports
117
- : [],
118
- )
119
- const extraFiles = graph.files.flatMap((file) => {
120
- if (!relatedIds.has(file.id)) return []
121
- if (islandFiles.some((entry) => entry.file.id === file.id)) return []
122
- const placed = layout.files[file.id]
123
- return placed ? [{ file, placed }] : []
124
- })
125
- const files = [...islandFiles, ...extraFiles]
126
-
127
- if (selectedPlaced) {
128
- const [width, height, depth] = selectedPlaced.size
129
- return {
130
- folder,
131
- files,
132
- relatedIds,
133
- selectedFileId: selectedFile?.id ?? null,
134
- camera: elevatedCamera(
135
- [
136
- selectedPlaced.position[0],
137
- selectedPlaced.position[1],
138
- selectedPlaced.position[2],
139
- ],
140
- width,
141
- height,
142
- depth,
143
- selectedPlaced.aisleFace,
144
- ),
145
- }
146
- }
147
-
148
- const tallest = files.reduce(
149
- (height, entry) => Math.max(height, entry.placed.size[1]),
150
- CONFIG.minHeight,
151
- )
152
- return {
153
- folder,
154
- files,
155
- relatedIds,
156
- selectedFileId: null,
157
- camera: elevatedCamera(
158
- [folder.x, 1.2, folder.z + folder.depth / 2],
159
- folder.width,
160
- tallest,
161
- folder.depth,
162
- 1,
163
- ),
164
- }
165
- }
166
-
167
- const MIN_ZOOM = 0.45
168
- const MAX_ZOOM = 6
169
- const MIN_ORBIT_PITCH = 0.08
170
- const MAX_ORBIT_PITCH = Math.PI / 2 - 0.06
171
- const ZERO_PAN: Vec3 = [0, 0, 0]
172
- const ZERO_ORBIT: Orbit = { yaw: 0, pitch: 0 }
173
- const WORLD_UP: Vec3 = [0, 1, 0]
174
- const LABEL_PROJECT = new THREE.Vector3()
175
- const GROUND_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
176
- const CURSOR_NDC = new THREE.Vector2()
177
- const CURSOR_BEFORE = new THREE.Vector3()
178
- const CURSOR_AFTER = new THREE.Vector3()
179
- const CURSOR_RAY = new THREE.Raycaster()
180
- const ZOOM_SCALE = Math.pow(0.95, 1.15)
181
-
182
- type LabelCandidate = {
183
- id: string
184
- position: Vec3
185
- priority: number
186
- }
187
-
188
- function clampZoom(value: number) {
189
- return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value))
190
- }
191
-
192
- function vecAdd(a: Vec3, b: Vec3): Vec3 {
193
- return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
194
- }
195
-
196
- function vecSub(a: Vec3, b: Vec3): Vec3 {
197
- return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
198
- }
199
-
200
- function vecScale(a: Vec3, scale: number): Vec3 {
201
- return [a[0] * scale, a[1] * scale, a[2] * scale]
202
- }
203
-
204
- function vecCross(a: Vec3, b: Vec3): Vec3 {
205
- return [
206
- a[1] * b[2] - a[2] * b[1],
207
- a[2] * b[0] - a[0] * b[2],
208
- a[0] * b[1] - a[1] * b[0],
209
- ]
210
- }
211
-
212
- function vecLength(a: Vec3) {
213
- return Math.hypot(a[0], a[1], a[2])
214
- }
215
-
216
- function vecNormalize(a: Vec3): Vec3 {
217
- const length = vecLength(a)
218
- return length < 1e-6 ? a : vecScale(a, 1 / length)
219
- }
220
-
221
- function cameraOffset(position: Vec3, lookAt: Vec3, zoom: number): Vec3 {
222
- return vecScale(vecSub(position, lookAt), 1 / zoom)
223
- }
224
-
225
- function sphericalFromOffset(offset: Vec3) {
226
- const radius = vecLength(offset)
227
- return {
228
- radius,
229
- yaw: Math.atan2(offset[0], offset[2]),
230
- pitch: Math.asin(
231
- Math.min(1, Math.max(-1, offset[1] / Math.max(radius, 1e-6))),
232
- ),
233
- }
234
- }
235
-
236
- function offsetFromSpherical(radius: number, yaw: number, pitch: number): Vec3 {
237
- const cosPitch = Math.cos(pitch)
238
- return [
239
- radius * Math.sin(yaw) * cosPitch,
240
- radius * Math.sin(pitch),
241
- radius * Math.cos(yaw) * cosPitch,
242
- ]
243
- }
244
-
245
- function clampOrbitPitch(basePitch: number, orbitPitch: number) {
246
- return Math.min(
247
- MAX_ORBIT_PITCH - basePitch,
248
- Math.max(MIN_ORBIT_PITCH - basePitch, orbitPitch),
249
- )
250
- }
251
-
252
- function orbitedOffset(
253
- position: Vec3,
254
- lookAt: Vec3,
255
- zoom: number,
256
- orbit: Orbit,
257
- ): Vec3 {
258
- const base = cameraOffset(position, lookAt, zoom)
259
- if (orbit.yaw === 0 && orbit.pitch === 0) return base
260
- const spherical = sphericalFromOffset(base)
261
- return offsetFromSpherical(
262
- spherical.radius,
263
- spherical.yaw + orbit.yaw,
264
- Math.min(
265
- MAX_ORBIT_PITCH,
266
- Math.max(MIN_ORBIT_PITCH, spherical.pitch + orbit.pitch),
267
- ),
268
- )
269
- }
270
-
271
- function applyThumbnailCamera(
272
- camera: THREE.Camera,
273
- position: Vec3,
274
- lookAt: Vec3,
275
- zoom: number,
276
- pan: Vec3,
277
- orbit: Orbit,
278
- ) {
279
- const offset = orbitedOffset(position, lookAt, zoom, orbit)
280
- camera.up.set(0, 1, 0)
281
- camera.position.set(
282
- lookAt[0] + pan[0] + offset[0],
283
- lookAt[1] + pan[1] + offset[1],
284
- lookAt[2] + pan[2] + offset[2],
285
- )
286
- camera.lookAt(lookAt[0] + pan[0], lookAt[1] + pan[1], lookAt[2] + pan[2])
287
- camera.updateProjectionMatrix()
288
- }
289
-
290
- function worldUnderCursor(
291
- camera: THREE.Camera,
292
- element: HTMLElement,
293
- clientX: number,
294
- clientY: number,
295
- target: THREE.Vector3,
296
- ) {
297
- const rect = element.getBoundingClientRect()
298
- if (rect.width < 2 || rect.height < 2) return false
299
- CURSOR_NDC.set(
300
- ((clientX - rect.left) / rect.width) * 2 - 1,
301
- -((clientY - rect.top) / rect.height) * 2 + 1,
302
- )
303
- CURSOR_RAY.setFromCamera(CURSOR_NDC, camera)
304
- return Boolean(CURSOR_RAY.ray.intersectPlane(GROUND_PLANE, target))
305
- }
306
-
307
- function cameraPanBasis(
308
- position: Vec3,
309
- lookAt: Vec3,
310
- zoom: number,
311
- orbit: Orbit,
312
- ) {
313
- const offset = orbitedOffset(position, lookAt, zoom, orbit)
314
- const forward = vecNormalize(vecScale(offset, -1))
315
- const right = vecNormalize(vecCross(forward, WORLD_UP))
316
- const up = vecNormalize(vecCross(right, forward))
317
- return { right, up, distance: vecLength(offset) }
318
- }
319
-
320
- function sameIdSet(a: Set<string>, b: Set<string>) {
321
- if (a.size !== b.size) return false
322
- for (const id of a) {
323
- if (!b.has(id)) return false
324
- }
325
- return true
326
- }
327
-
328
- function priorityLabels(candidates: LabelCandidate[], limit: number) {
329
- return new Set(
330
- [...candidates]
331
- .sort((left, right) => right.priority - left.priority)
332
- .slice(0, limit)
333
- .map((candidate) => candidate.id),
334
- )
335
- }
336
-
337
- function visibleThumbnailLabels(
338
- candidates: LabelCandidate[],
339
- camera: THREE.Camera,
340
- width: number,
341
- height: number,
342
- zoom: number,
343
- ) {
344
- if (candidates.length <= 4) {
345
- return new Set(candidates.map((candidate) => candidate.id))
346
- }
347
-
348
- const maxLabels = zoom < 0.85 ? 5 : zoom < 1.35 ? 8 : zoom < 2.2 ? 12 : 18
349
- const boxW = Math.max(40, 78 / Math.sqrt(Math.max(zoom, 0.5)))
350
- const boxH = Math.max(12, 18 / Math.sqrt(Math.max(zoom, 0.5)))
351
- const ranked = [...candidates].sort((left, right) => right.priority - left.priority)
352
- const placed: Array<{ x: number; y: number }> = []
353
- const visible = new Set<string>()
354
-
355
- for (const candidate of ranked) {
356
- LABEL_PROJECT.set(
357
- candidate.position[0],
358
- candidate.position[1],
359
- candidate.position[2],
360
- ).project(camera)
361
- if (LABEL_PROJECT.z < -1 || LABEL_PROJECT.z > 1) continue
362
- const x = (LABEL_PROJECT.x * 0.5 + 0.5) * width
363
- const y = (-LABEL_PROJECT.y * 0.5 + 0.5) * height
364
- if (x < -24 || x > width + 24 || y < -16 || y > height + 16) continue
365
-
366
- const essential = candidate.priority >= 1000
367
- const overlaps = placed.some(
368
- (other) => Math.abs(other.x - x) < boxW && Math.abs(other.y - y) < boxH,
369
- )
370
- if (overlaps && !essential) continue
371
- if (visible.size >= maxLabels && !essential) continue
372
-
373
- placed.push({ x, y })
374
- visible.add(candidate.id)
375
- }
376
-
377
- return visible
378
- }
379
-
380
- function touchDistance(touches: TouchList) {
381
- if (touches.length < 2) return 0
382
- return Math.hypot(
383
- touches[0].clientX - touches[1].clientX,
384
- touches[0].clientY - touches[1].clientY,
385
- )
386
- }
387
-
388
- function CameraRig({
389
- position,
390
- lookAt,
391
- zoomRef,
392
- panRef,
393
- orbitRef,
394
- cameraRef,
395
- }: {
396
- position: Vec3
397
- lookAt: Vec3
398
- zoomRef: { current: number }
399
- panRef: { current: Vec3 }
400
- orbitRef: { current: Orbit }
401
- cameraRef: { current: THREE.Camera | null }
402
- }) {
403
- const { camera } = useThree()
404
- cameraRef.current = camera
405
- const aim = () => {
406
- applyThumbnailCamera(
407
- camera,
408
- position,
409
- lookAt,
410
- zoomRef.current,
411
- panRef.current,
412
- orbitRef.current,
413
- )
414
- }
415
-
416
- useLayoutEffect(aim, [
417
- camera,
418
- cameraRef,
419
- lookAt,
420
- orbitRef,
421
- panRef,
422
- position,
423
- zoomRef,
424
- ])
425
- useFrame(aim)
426
- return null
427
- }
428
-
429
- function ThumbnailLabelFilter({
430
- candidates,
431
- zoom,
432
- frozenRef,
433
- onChange,
434
- }: {
435
- candidates: LabelCandidate[]
436
- zoom: number
437
- frozenRef: { current: boolean }
438
- onChange: (ids: Set<string>) => void
439
- }) {
440
- const camera = useThree((state) => state.camera)
441
- const size = useThree((state) => state.size)
442
- const visibleRef = useRef<Set<string>>(new Set())
443
-
444
- useFrame(() => {
445
- if (frozenRef.current) return
446
- const next = visibleThumbnailLabels(
447
- candidates,
448
- camera,
449
- size.width,
450
- size.height,
451
- zoom,
452
- )
453
- if (sameIdSet(visibleRef.current, next)) return
454
- visibleRef.current = next
455
- onChange(next)
456
- })
457
-
458
- return null
459
- }
460
-
461
- function ThumbnailScene({
462
- graph,
463
- layout,
464
- importedBy,
465
- target,
466
- highlightedFolders,
467
- planned,
468
- created,
469
- deleted,
470
- pointedFiles,
471
- pointedFolders,
472
- zoom,
473
- zoomRef,
474
- panRef,
475
- orbitRef,
476
- cameraRef,
477
- frozenRef,
478
- visibleLabelIds,
479
- onVisibleLabels,
480
- }: {
481
- graph: CodebaseGraph
482
- layout: WorldLayout
483
- importedBy: boolean
484
- target: ThumbnailTarget
485
- highlightedFolders: Partial<Record<string, ChangeKind>>
486
- planned: Set<string>
487
- created: Set<string>
488
- deleted: Set<string>
489
- pointedFiles: Set<string>
490
- pointedFolders: Set<string>
491
- zoom: number
492
- zoomRef: { current: number }
493
- panRef: { current: Vec3 }
494
- orbitRef: { current: Orbit }
495
- cameraRef: { current: THREE.Camera | null }
496
- frozenRef: { current: boolean }
497
- visibleLabelIds: Set<string>
498
- onVisibleLabels: (ids: Set<string>) => void
499
- }) {
500
- const labelCandidates = useMemo(
501
- () =>
502
- target.files.map(({ file, placed }) => {
503
- const selected = file.id === target.selectedFileId
504
- const related = target.relatedIds.has(file.id)
505
- const changeKind = fileChangeKind(file.id, planned, created, deleted)
506
- const pointed = pointedFiles.has(file.id)
507
- const dimmed =
508
- Boolean(target.selectedFileId) &&
509
- !selected &&
510
- !related &&
511
- !changeKind &&
512
- !pointed
513
- let priority = placed.size[1]
514
- if (selected) priority += 1000
515
- else if (related) priority += 400
516
- else if (changeKind) priority += 300
517
- else if (pointed) priority += 250
518
- else if (file.userCreated) priority += 80
519
- if (dimmed) priority -= 200
520
- return {
521
- id: file.id,
522
- position: [
523
- placed.position[0],
524
- placed.position[1] + placed.size[1] / 2 + 0.4,
525
- placed.position[2],
526
- ] satisfies Vec3,
527
- priority,
528
- }
529
- }),
530
- [created, deleted, planned, pointedFiles, target],
531
- )
532
-
533
- return (
534
- <>
535
- <color attach="background" args={[WORLD_VOID]} />
536
- <hemisphereLight args={['#d7e2ee', '#2a3038', 1.1]} />
537
- <directionalLight position={[8, 60, 8]} intensity={1.35} />
538
- <ambientLight intensity={0.7} />
539
- <pointLight
540
- position={[
541
- target.camera.position[0],
542
- target.camera.position[1] - 1.4,
543
- target.camera.position[2],
544
- ]}
545
- color="#f4f1e8"
546
- intensity={7}
547
- distance={40}
548
- decay={1.2}
549
- />
550
- <CameraRig
551
- position={target.camera.position}
552
- lookAt={target.camera.lookAt}
553
- zoomRef={zoomRef}
554
- panRef={panRef}
555
- orbitRef={orbitRef}
556
- cameraRef={cameraRef}
557
- />
558
- <ThumbnailLabelFilter
559
- candidates={labelCandidates}
560
- zoom={zoom}
561
- frozenRef={frozenRef}
562
- onChange={onVisibleLabels}
563
- />
564
- <FolderArea
565
- folder={target.folder}
566
- highlightKind={highlightedFolders[target.folder.path] ?? null}
567
- pointed={pointedFolders.has(target.folder.path)}
568
- previewLabels={zoom < 1.45}
569
- />
570
- {target.files.map(({ file, placed }) => {
571
- const selected = file.id === target.selectedFileId
572
- const related = target.relatedIds.has(file.id)
573
- const changeKind = fileChangeKind(file.id, planned, created, deleted)
574
- const pointed = pointedFiles.has(file.id)
575
- return (
576
- <FileBlock
577
- key={file.id}
578
- file={file}
579
- placed={placed}
580
- selected={selected}
581
- related={related}
582
- planned={Boolean(changeKind)}
583
- changeKind={changeKind}
584
- added={created.has(file.id) || file.userCreated}
585
- pointed={pointed}
586
- highlightMapChange
587
- previewLabels
588
- labelVisible={visibleLabelIds.has(file.id)}
589
- dimmed={
590
- Boolean(target.selectedFileId) &&
591
- !selected &&
592
- !related &&
593
- !changeKind &&
594
- !pointed
595
- }
596
- />
597
- )
598
- })}
599
- {target.selectedFileId && (
600
- <RelationLines
601
- selectedId={target.selectedFileId}
602
- aimedRelation={null}
603
- files={graph.files}
604
- layout={layout}
605
- fromAbove
606
- importedBy={importedBy}
607
- />
608
- )}
609
- </>
610
- )
611
- }
612
-
613
- export function SelectionThumbnail({
614
- graph,
615
- layout,
616
- selectedId,
617
- selectedFolder,
618
- landAt,
619
- importedBy = false,
620
- minimized = false,
621
- maximized = false,
622
- plannedIds = [],
623
- createdIds = [],
624
- deletedIds = [],
625
- onMinimize,
626
- onMaximize,
627
- onHide,
628
- }: SelectionThumbnailProps) {
629
- const stageRef = useRef<HTMLDivElement>(null)
630
- const cameraRef = useRef<THREE.Camera | null>(null)
631
- const zoomRef = useRef(1)
632
- const panRef = useRef<Vec3>(ZERO_PAN)
633
- const orbitRef = useRef<Orbit>(ZERO_ORBIT)
634
- const pinchRef = useRef({ start: 0, zoom: 1 })
635
- const zoomAtCursorRef = useRef(
636
- (_clientX: number, _clientY: number, _dollyScale: number) => {},
637
- )
638
- const dragRef = useRef({
639
- pointerId: -1,
640
- x: 0,
641
- y: 0,
642
- pan: ZERO_PAN,
643
- orbit: ZERO_ORBIT,
644
- rotate: false,
645
- })
646
- const [zoom, setZoom] = useState(1)
647
- const [panning, setPanning] = useState(false)
648
- const [orbiting, setOrbiting] = useState(false)
649
- const [canvasReady, setCanvasReady] = useState(false)
650
- const [visibleLabelIds, setVisibleLabelIds] = useState<Set<string>>(
651
- () => new Set(),
652
- )
653
- const planned = useMemo(() => new Set(plannedIds), [plannedIds])
654
- const created = useMemo(() => new Set(createdIds), [createdIds])
655
- const deleted = useMemo(() => new Set(deletedIds), [deletedIds])
656
- const highlightedFolders = useMemo(
657
- () => folderChangeHighlights(planned, created, deleted, layout.folders),
658
- [created, deleted, layout.folders, planned],
659
- )
660
- const target = useMemo(
661
- () =>
662
- resolveTarget(
663
- graph,
664
- layout,
665
- selectedId,
666
- selectedFolder,
667
- landAt,
668
- importedBy,
669
- ),
670
- [graph, importedBy, landAt, layout, selectedFolder, selectedId],
671
- )
672
-
673
- const panningRef = useRef(false)
674
-
675
- useEffect(() => {
676
- const frame = window.requestAnimationFrame(() => setCanvasReady(true))
677
- return () => window.cancelAnimationFrame(frame)
678
- }, [])
679
-
680
- useEffect(() => {
681
- zoomRef.current = 1
682
- panRef.current = ZERO_PAN
683
- orbitRef.current = ZERO_ORBIT
684
- setZoom(1)
685
- panningRef.current = false
686
- setPanning(false)
687
- setOrbiting(false)
688
- }, [selectedId, selectedFolder, landAt])
689
-
690
- useEffect(() => {
691
- zoomRef.current = zoom
692
- }, [zoom])
693
-
694
- useEffect(() => {
695
- if (!target) {
696
- setVisibleLabelIds(new Set())
697
- return
698
- }
699
- const candidates = target.files.map(({ file, placed }) => {
700
- const selected = file.id === target.selectedFileId
701
- const related = target.relatedIds.has(file.id)
702
- const changeKind = fileChangeKind(file.id, planned, created, deleted)
703
- let priority = placed.size[1]
704
- if (selected) priority += 1000
705
- else if (related) priority += 400
706
- else if (changeKind) priority += 300
707
- return { id: file.id, position: placed.position, priority }
708
- })
709
- setVisibleLabelIds(priorityLabels(candidates, 8))
710
- }, [created, deleted, planned, target])
711
-
712
- useEffect(() => {
713
- const stage = stageRef.current
714
- if (!stage || minimized || !target) return
715
-
716
- const zoomAtCursor = (
717
- clientX: number,
718
- clientY: number,
719
- dollyScale: number,
720
- ) => {
721
- const camera = cameraRef.current
722
- const nextZoom = clampZoom(zoomRef.current / dollyScale)
723
- if (nextZoom === zoomRef.current) return
724
- const hit =
725
- camera !== null &&
726
- worldUnderCursor(camera, stage, clientX, clientY, CURSOR_BEFORE)
727
- zoomRef.current = nextZoom
728
- if (camera) {
729
- applyThumbnailCamera(
730
- camera,
731
- target.camera.position,
732
- target.camera.lookAt,
733
- nextZoom,
734
- panRef.current,
735
- orbitRef.current,
736
- )
737
- if (
738
- hit &&
739
- worldUnderCursor(camera, stage, clientX, clientY, CURSOR_AFTER)
740
- ) {
741
- panRef.current = [
742
- panRef.current[0] + CURSOR_BEFORE.x - CURSOR_AFTER.x,
743
- panRef.current[1],
744
- panRef.current[2] + CURSOR_BEFORE.z - CURSOR_AFTER.z,
745
- ]
746
- applyThumbnailCamera(
747
- camera,
748
- target.camera.position,
749
- target.camera.lookAt,
750
- nextZoom,
751
- panRef.current,
752
- orbitRef.current,
753
- )
754
- }
755
- }
756
- setZoom(nextZoom)
757
- }
758
- zoomAtCursorRef.current = zoomAtCursor
759
-
760
- const pointerOverStage = (clientX: number, clientY: number) => {
761
- const rect = stage.getBoundingClientRect()
762
- return (
763
- clientX >= rect.left &&
764
- clientX <= rect.right &&
765
- clientY >= rect.top &&
766
- clientY <= rect.bottom
767
- )
768
- }
769
-
770
- const onWheel = (event: WheelEvent) => {
771
- if (!pointerOverStage(event.clientX, event.clientY)) return
772
- event.preventDefault()
773
- event.stopImmediatePropagation()
774
- if (event.deltaY < 0) zoomAtCursor(event.clientX, event.clientY, ZOOM_SCALE)
775
- else if (event.deltaY > 0) {
776
- zoomAtCursor(event.clientX, event.clientY, 1 / ZOOM_SCALE)
777
- }
778
- }
779
-
780
- const onGestureStart = (event: Event) => {
781
- event.preventDefault()
782
- pinchRef.current.zoom = zoomRef.current
783
- }
784
-
785
- const onGestureChange = (event: Event) => {
786
- event.preventDefault()
787
- const scale = Number((event as Event & { scale?: number }).scale)
788
- if (!Number.isFinite(scale) || scale <= 0) return
789
- const nextZoom = clampZoom(pinchRef.current.zoom * scale)
790
- const dolly = zoomRef.current / Math.max(nextZoom, 1e-6)
791
- const gesture = event as Event & { clientX?: number; clientY?: number }
792
- const rect = stage.getBoundingClientRect()
793
- zoomAtCursor(
794
- gesture.clientX ?? rect.left + rect.width / 2,
795
- gesture.clientY ?? rect.top + rect.height / 2,
796
- dolly,
797
- )
798
- }
799
-
800
- const onTouchStart = (event: TouchEvent) => {
801
- if (event.touches.length !== 2) return
802
- dragRef.current.pointerId = -1
803
- panningRef.current = false
804
- setPanning(false)
805
- setOrbiting(false)
806
- pinchRef.current = {
807
- start: touchDistance(event.touches),
808
- zoom: zoomRef.current,
809
- }
810
- }
811
-
812
- const onTouchMove = (event: TouchEvent) => {
813
- if (event.touches.length !== 2 || pinchRef.current.start <= 0) return
814
- event.preventDefault()
815
- event.stopPropagation()
816
- const scale = touchDistance(event.touches) / pinchRef.current.start
817
- const nextZoom = clampZoom(pinchRef.current.zoom * scale)
818
- const midX = (event.touches[0].clientX + event.touches[1].clientX) / 2
819
- const midY = (event.touches[0].clientY + event.touches[1].clientY) / 2
820
- const dolly =
821
- nextZoom === 0 ? 1 : zoomRef.current / Math.max(nextZoom, 1e-6)
822
- zoomAtCursor(midX, midY, dolly)
823
- }
824
-
825
- const fromNav = (event: Event) =>
826
- event.target instanceof Element &&
827
- event.target.closest('.hud-thumbnail-nav')
828
-
829
- const onPointerDown = (event: PointerEvent) => {
830
- if (fromNav(event) || event.button !== 0) return
831
- dragRef.current = {
832
- pointerId: event.pointerId,
833
- x: event.clientX,
834
- y: event.clientY,
835
- pan: panRef.current,
836
- orbit: { ...orbitRef.current },
837
- rotate: event.metaKey || event.ctrlKey,
838
- }
839
- stage.setPointerCapture(event.pointerId)
840
- }
841
-
842
- const onPointerMove = (event: PointerEvent) => {
843
- if (dragRef.current.pointerId !== event.pointerId) return
844
- const dx = event.clientX - dragRef.current.x
845
- const dy = event.clientY - dragRef.current.y
846
- if (!panningRef.current) {
847
- if (Math.hypot(dx, dy) <= 3) return
848
- panningRef.current = true
849
- if (dragRef.current.rotate) setOrbiting(true)
850
- else setPanning(true)
851
- }
852
- if (dragRef.current.rotate) {
853
- const speed = (2 * Math.PI) / Math.max(stage.clientHeight, 1)
854
- const basePitch = sphericalFromOffset(
855
- cameraOffset(target.camera.position, target.camera.lookAt, 1),
856
- ).pitch
857
- orbitRef.current = {
858
- yaw: dragRef.current.orbit.yaw - dx * speed,
859
- pitch: clampOrbitPitch(
860
- basePitch,
861
- dragRef.current.orbit.pitch + dy * speed,
862
- ),
863
- }
864
- return
865
- }
866
- const { right, up, distance } = cameraPanBasis(
867
- target.camera.position,
868
- target.camera.lookAt,
869
- zoomRef.current,
870
- orbitRef.current,
871
- )
872
- const speed = distance / Math.max(stage.clientHeight, 1)
873
- panRef.current = vecAdd(
874
- dragRef.current.pan,
875
- vecAdd(vecScale(right, -dx * speed), vecScale(up, dy * speed)),
876
- )
877
- }
878
-
879
- const onPointerUp = (event: PointerEvent) => {
880
- if (dragRef.current.pointerId !== event.pointerId) return
881
- dragRef.current.pointerId = -1
882
- panningRef.current = false
883
- setPanning(false)
884
- setOrbiting(false)
885
- if (stage.hasPointerCapture(event.pointerId)) {
886
- stage.releasePointerCapture(event.pointerId)
887
- }
888
- }
889
-
890
- const onDoubleClick = (event: MouseEvent) => {
891
- if (fromNav(event)) return
892
- event.preventDefault()
893
- zoomRef.current = 1
894
- panRef.current = ZERO_PAN
895
- orbitRef.current = ZERO_ORBIT
896
- setZoom(1)
897
- }
898
-
899
- window.addEventListener('wheel', onWheel, { passive: false, capture: true })
900
- stage.addEventListener('gesturestart', onGestureStart, { capture: true })
901
- stage.addEventListener('gesturechange', onGestureChange, { capture: true })
902
- stage.addEventListener('touchstart', onTouchStart, { passive: true })
903
- stage.addEventListener('touchmove', onTouchMove, { passive: false, capture: true })
904
- stage.addEventListener('pointerdown', onPointerDown)
905
- stage.addEventListener('pointermove', onPointerMove)
906
- stage.addEventListener('pointerup', onPointerUp)
907
- stage.addEventListener('pointercancel', onPointerUp)
908
- stage.addEventListener('dblclick', onDoubleClick)
909
- return () => {
910
- window.removeEventListener('wheel', onWheel, { capture: true })
911
- stage.removeEventListener('gesturestart', onGestureStart, { capture: true })
912
- stage.removeEventListener('gesturechange', onGestureChange, { capture: true })
913
- stage.removeEventListener('touchstart', onTouchStart)
914
- stage.removeEventListener('touchmove', onTouchMove, { capture: true })
915
- stage.removeEventListener('pointerdown', onPointerDown)
916
- stage.removeEventListener('pointermove', onPointerMove)
917
- stage.removeEventListener('pointerup', onPointerUp)
918
- stage.removeEventListener('pointercancel', onPointerUp)
919
- stage.removeEventListener('dblclick', onDoubleClick)
920
- }
921
- }, [minimized, target])
922
-
923
- useEffect(() => {
924
- if (!maximized) return
925
- const onKeyDown = (event: KeyboardEvent) => {
926
- if (event.key !== 'Escape') return
927
- if (shouldIgnoreShortcut(event)) return
928
- event.preventDefault()
929
- onMaximize?.()
930
- }
931
- window.addEventListener('keydown', onKeyDown)
932
- return () => window.removeEventListener('keydown', onKeyDown)
933
- }, [maximized, onMaximize])
934
-
935
- if (!target) return null
936
-
937
- return (
938
- <div
939
- className="hud-thumbnail"
940
- data-minimized={minimized}
941
- data-maximized={maximized}
942
- >
943
- <div className="hud-thumbnail-bar">
944
- <span>3D view</span>
945
- <div className="hud-panel-controls">
946
- {onMinimize && !maximized && (
947
- <button
948
- className="hud-button hud-icon-button hud-panel-control"
949
- type="button"
950
- aria-label={minimized ? 'Restore 3D view' : 'Minimize 3D view'}
951
- onClick={onMinimize}
952
- >
953
- {minimized ? '+' : '−'}
954
- </button>
955
- )}
956
- {onMaximize && (
957
- <button
958
- className="hud-button hud-icon-button hud-panel-control"
959
- type="button"
960
- aria-label={maximized ? 'Minimize 3D view' : 'Maximize 3D view'}
961
- onClick={onMaximize}
962
- >
963
- {maximized ? '−' : '□'}
964
- </button>
965
- )}
966
- <button
967
- className="hud-button hud-icon-button hud-panel-control"
968
- type="button"
969
- aria-label="Close 3D view"
970
- onClick={onHide}
971
- >
972
- ×
973
- </button>
974
- </div>
975
- </div>
976
- {!minimized && (
977
- <div
978
- className="hud-thumbnail-stage"
979
- ref={stageRef}
980
- data-panning={panning}
981
- data-orbiting={orbiting}
982
- >
983
- {canvasReady ? (
984
- <CanvasErrorBoundary
985
- fallback={
986
- <div className="hud-thumbnail-hint">3D preview unavailable</div>
987
- }
988
- >
989
- <Canvas
990
- shadows={false}
991
- dpr={[1, 1]}
992
- resize={{ offsetSize: true }}
993
- gl={{
994
- antialias: false,
995
- powerPreference: 'low-power',
996
- toneMappingExposure: 1.25,
997
- }}
998
- camera={{
999
- fov: 50,
1000
- near: 0.1,
1001
- far: 400,
1002
- position: target.camera.position,
1003
- }}
1004
- >
1005
- <ThumbnailScene
1006
- graph={graph}
1007
- layout={layout}
1008
- importedBy={importedBy}
1009
- target={target}
1010
- highlightedFolders={highlightedFolders}
1011
- planned={planned}
1012
- created={created}
1013
- deleted={deleted}
1014
- zoom={zoom}
1015
- zoomRef={zoomRef}
1016
- panRef={panRef}
1017
- orbitRef={orbitRef}
1018
- cameraRef={cameraRef}
1019
- frozenRef={panningRef}
1020
- visibleLabelIds={visibleLabelIds}
1021
- onVisibleLabels={setVisibleLabelIds}
1022
- />
1023
- </Canvas>
1024
- </CanvasErrorBoundary>
1025
- ) : null}
1026
- <div className="hud-thumbnail-hint">
1027
- Scroll zoom · drag pan · ⌘ drag rotate
1028
- </div>
1029
- <div className="hud-thumbnail-nav">
1030
- <button
1031
- className="hud-button hud-icon-button"
1032
- type="button"
1033
- aria-label="Zoom out"
1034
- onPointerDown={(event) => event.stopPropagation()}
1035
- onClick={() => {
1036
- const rect = stageRef.current?.getBoundingClientRect()
1037
- if (!rect) return
1038
- zoomAtCursorRef.current(
1039
- rect.left + rect.width / 2,
1040
- rect.top + rect.height / 2,
1041
- 1 / ZOOM_SCALE,
1042
- )
1043
- }}
1044
- >
1045
-
1046
- </button>
1047
- <button
1048
- className="hud-button hud-icon-button"
1049
- type="button"
1050
- aria-label="Zoom in"
1051
- onPointerDown={(event) => event.stopPropagation()}
1052
- onClick={() => {
1053
- const rect = stageRef.current?.getBoundingClientRect()
1054
- if (!rect) return
1055
- zoomAtCursorRef.current(
1056
- rect.left + rect.width / 2,
1057
- rect.top + rect.height / 2,
1058
- ZOOM_SCALE,
1059
- )
1060
- }}
1061
- >
1062
- +
1063
- </button>
1064
- </div>
1065
- </div>
1066
- )}
1067
- </div>
1068
- )
1069
- }