@jkwd/inbase 0.1.5 → 0.1.7

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.
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
2
  import { Canvas, useFrame, useThree } from '@react-three/fiber'
3
+ import * as THREE from 'three'
3
4
  import {
4
5
  fileChangeKind,
5
6
  filesImporting,
@@ -13,6 +14,8 @@ import { FileBlock } from './FileBlock'
13
14
  import { FolderArea } from './FolderArea'
14
15
  import { RelationLines } from './RelationLines'
15
16
 
17
+ type Vec3 = [number, number, number]
18
+
16
19
  type SelectionThumbnailProps = {
17
20
  graph: CodebaseGraph
18
21
  layout: WorldLayout
@@ -154,13 +157,164 @@ function resolveTarget(
154
157
  }
155
158
  }
156
159
 
157
- const MIN_ZOOM = 0.55
158
- const MAX_ZOOM = 3.4
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
+ }
159
177
 
160
178
  function clampZoom(value: number) {
161
179
  return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value))
162
180
  }
163
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
+
164
318
  function touchDistance(touches: TouchList) {
165
319
  if (touches.length < 2) return 0
166
320
  return Math.hypot(
@@ -172,30 +326,61 @@ function touchDistance(touches: TouchList) {
172
326
  function CameraRig({
173
327
  position,
174
328
  lookAt,
175
- zoom,
329
+ zoomRef,
330
+ panRef,
331
+ cameraRef,
176
332
  }: {
177
- position: [number, number, number]
178
- lookAt: [number, number, number]
179
- zoom: number
333
+ position: Vec3
334
+ lookAt: Vec3
335
+ zoomRef: { current: number }
336
+ panRef: { current: Vec3 }
337
+ cameraRef: { current: THREE.Camera | null }
180
338
  }) {
181
339
  const { camera } = useThree()
340
+ cameraRef.current = camera
182
341
  const aim = () => {
183
- const scale = 1 / zoom
184
- camera.up.set(0, 1, 0)
185
- camera.position.set(
186
- lookAt[0] + (position[0] - lookAt[0]) * scale,
187
- lookAt[1] + (position[1] - lookAt[1]) * scale,
188
- lookAt[2] + (position[2] - lookAt[2]) * scale,
342
+ applyThumbnailCamera(
343
+ camera,
344
+ position,
345
+ lookAt,
346
+ zoomRef.current,
347
+ panRef.current,
189
348
  )
190
- camera.lookAt(lookAt[0], lookAt[1], lookAt[2])
191
- camera.updateProjectionMatrix()
192
349
  }
193
350
 
194
- useLayoutEffect(aim, [camera, lookAt, position, zoom])
351
+ useLayoutEffect(aim, [camera, cameraRef, lookAt, panRef, position, zoomRef])
195
352
  useFrame(aim)
196
353
  return null
197
354
  }
198
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
+
199
384
  function ThumbnailScene({
200
385
  graph,
201
386
  layout,
@@ -206,6 +391,11 @@ function ThumbnailScene({
206
391
  created,
207
392
  deleted,
208
393
  zoom,
394
+ zoomRef,
395
+ panRef,
396
+ cameraRef,
397
+ visibleLabelIds,
398
+ onVisibleLabels,
209
399
  }: {
210
400
  graph: CodebaseGraph
211
401
  layout: WorldLayout
@@ -216,7 +406,42 @@ function ThumbnailScene({
216
406
  created: Set<string>
217
407
  deleted: Set<string>
218
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
219
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
+
220
445
  return (
221
446
  <>
222
447
  <color attach="background" args={[WORLD_VOID]} />
@@ -237,12 +462,19 @@ function ThumbnailScene({
237
462
  <CameraRig
238
463
  position={target.camera.position}
239
464
  lookAt={target.camera.lookAt}
465
+ zoomRef={zoomRef}
466
+ panRef={panRef}
467
+ cameraRef={cameraRef}
468
+ />
469
+ <ThumbnailLabelFilter
470
+ candidates={labelCandidates}
240
471
  zoom={zoom}
472
+ onChange={onVisibleLabels}
241
473
  />
242
474
  <FolderArea
243
475
  folder={target.folder}
244
476
  highlightKind={highlightedFolders[target.folder.path] ?? null}
245
- previewLabels
477
+ previewLabels={zoom < 1.45}
246
478
  />
247
479
  {target.files.map(({ file, placed }) => {
248
480
  const selected = file.id === target.selectedFileId
@@ -260,6 +492,7 @@ function ThumbnailScene({
260
492
  added={created.has(file.id) || file.userCreated}
261
493
  highlightMapChange
262
494
  previewLabels
495
+ labelVisible={visibleLabelIds.has(file.id)}
263
496
  dimmed={
264
497
  Boolean(target.selectedFileId) &&
265
498
  !selected &&
@@ -298,9 +531,24 @@ export function SelectionThumbnail({
298
531
  onHide,
299
532
  }: SelectionThumbnailProps) {
300
533
  const stageRef = useRef<HTMLDivElement>(null)
534
+ const cameraRef = useRef<THREE.Camera | null>(null)
301
535
  const zoomRef = useRef(1)
536
+ const panRef = useRef<Vec3>(ZERO_PAN)
302
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
+ })
303
547
  const [zoom, setZoom] = useState(1)
548
+ const [panning, setPanning] = useState(false)
549
+ const [visibleLabelIds, setVisibleLabelIds] = useState<Set<string>>(
550
+ () => new Set(),
551
+ )
304
552
  const planned = useMemo(() => new Set(plannedIds), [plannedIds])
305
553
  const created = useMemo(() => new Set(createdIds), [createdIds])
306
554
  const deleted = useMemo(() => new Set(deletedIds), [deletedIds])
@@ -321,30 +569,102 @@ export function SelectionThumbnail({
321
569
  [graph, importedBy, landAt, layout, selectedFolder, selectedId],
322
570
  )
323
571
 
572
+ const panningRef = useRef(false)
573
+
324
574
  useEffect(() => {
325
- setZoom(1)
326
575
  zoomRef.current = 1
576
+ panRef.current = ZERO_PAN
577
+ setZoom(1)
578
+ panningRef.current = false
579
+ setPanning(false)
327
580
  }, [selectedId, selectedFolder, landAt])
328
581
 
329
582
  useEffect(() => {
330
583
  zoomRef.current = zoom
331
584
  }, [zoom])
332
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
+
333
604
  useEffect(() => {
334
605
  const stage = stageRef.current
335
- if (!stage || minimized) return
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
336
649
 
337
- const applyZoom = (next: number) => {
338
- const clamped = clampZoom(next)
339
- zoomRef.current = clamped
340
- setZoom(clamped)
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
+ )
341
658
  }
342
659
 
343
660
  const onWheel = (event: WheelEvent) => {
344
- if (!event.ctrlKey && !event.metaKey) return
661
+ if (!pointerOverStage(event.clientX, event.clientY)) return
345
662
  event.preventDefault()
346
- event.stopPropagation()
347
- applyZoom(zoomRef.current * Math.exp(-event.deltaY * 0.01))
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
+ }
348
668
  }
349
669
 
350
670
  const onGestureStart = (event: Event) => {
@@ -356,11 +676,22 @@ export function SelectionThumbnail({
356
676
  event.preventDefault()
357
677
  const scale = Number((event as Event & { scale?: number }).scale)
358
678
  if (!Number.isFinite(scale) || scale <= 0) return
359
- applyZoom(pinchRef.current.zoom * scale)
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
+ )
360
688
  }
361
689
 
362
690
  const onTouchStart = (event: TouchEvent) => {
363
691
  if (event.touches.length !== 2) return
692
+ dragRef.current.pointerId = -1
693
+ panningRef.current = false
694
+ setPanning(false)
364
695
  pinchRef.current = {
365
696
  start: touchDistance(event.touches),
366
697
  zoom: zoomRef.current,
@@ -371,25 +702,92 @@ export function SelectionThumbnail({
371
702
  if (event.touches.length !== 2 || pinchRef.current.start <= 0) return
372
703
  event.preventDefault()
373
704
  event.stopPropagation()
374
- applyZoom(
375
- pinchRef.current.zoom *
376
- (touchDistance(event.touches) / pinchRef.current.start),
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,
377
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)
378
766
  }
379
767
 
380
- stage.addEventListener('wheel', onWheel, { passive: false, capture: true })
768
+ window.addEventListener('wheel', onWheel, { passive: false, capture: true })
381
769
  stage.addEventListener('gesturestart', onGestureStart, { capture: true })
382
770
  stage.addEventListener('gesturechange', onGestureChange, { capture: true })
383
771
  stage.addEventListener('touchstart', onTouchStart, { passive: true })
384
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)
385
778
  return () => {
386
- stage.removeEventListener('wheel', onWheel, { capture: true })
779
+ window.removeEventListener('wheel', onWheel, { capture: true })
387
780
  stage.removeEventListener('gesturestart', onGestureStart, { capture: true })
388
781
  stage.removeEventListener('gesturechange', onGestureChange, { capture: true })
389
782
  stage.removeEventListener('touchstart', onTouchStart)
390
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)
391
789
  }
392
- }, [minimized])
790
+ }, [minimized, target])
393
791
 
394
792
  if (!target) return null
395
793
 
@@ -419,7 +817,11 @@ export function SelectionThumbnail({
419
817
  </div>
420
818
  </div>
421
819
  {!minimized && (
422
- <div className="hud-thumbnail-stage" ref={stageRef}>
820
+ <div
821
+ className="hud-thumbnail-stage"
822
+ ref={stageRef}
823
+ data-panning={panning}
824
+ >
423
825
  <Canvas
424
826
  shadows={false}
425
827
  dpr={[1, 1.5]}
@@ -442,8 +844,50 @@ export function SelectionThumbnail({
442
844
  created={created}
443
845
  deleted={deleted}
444
846
  zoom={zoom}
847
+ zoomRef={zoomRef}
848
+ panRef={panRef}
849
+ cameraRef={cameraRef}
850
+ visibleLabelIds={visibleLabelIds}
851
+ onVisibleLabels={setVisibleLabelIds}
445
852
  />
446
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>
447
891
  </div>
448
892
  )}
449
893
  </div>