@jkwd/inbase 0.1.4 → 0.1.6

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