@jkwd/inbase 0.1.20 → 0.1.22

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