@jkwd/inbase 0.1.7 → 0.1.8

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,12 +1,36 @@
1
- import { useEffect, useRef, useState } from 'react'
1
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
2
2
  import { useFrame, useThree } from '@react-three/fiber'
3
3
  import { PerspectiveCamera, PointerLockControls } from '@react-three/drei'
4
4
  import type { PointerLockControls as PointerLockControlsImpl } from 'three-stdlib'
5
5
  import * as THREE from 'three'
6
6
  import { CONFIG } from '../theme'
7
- import { locationLabel } from '../layout'
8
7
  import type { FlyTo, ViewMode, WorldLayout } from '../types'
9
8
 
9
+ const lookDir = new THREE.Vector3()
10
+ const flyPos = new THREE.Vector3()
11
+ const flyLook = new THREE.Vector3()
12
+
13
+ function smootherstep(t: number) {
14
+ const x = Math.min(1, Math.max(0, t))
15
+ return x * x * x * (x * (x * 6 - 15) + 10)
16
+ }
17
+
18
+ function quadBezier(
19
+ out: THREE.Vector3,
20
+ a: THREE.Vector3,
21
+ b: THREE.Vector3,
22
+ c: THREE.Vector3,
23
+ t: number,
24
+ ) {
25
+ const u = 1 - t
26
+ out.set(
27
+ u * u * a.x + 2 * u * t * b.x + t * t * c.x,
28
+ u * u * a.y + 2 * u * t * b.y + t * t * c.y,
29
+ u * u * a.z + 2 * u * t * b.z + t * t * c.z,
30
+ )
31
+ return out
32
+ }
33
+
10
34
  type PlayerProps = {
11
35
  layout: WorldLayout
12
36
  mode: ViewMode
@@ -14,19 +38,16 @@ type PlayerProps = {
14
38
  locked: boolean
15
39
  lockEnabled?: boolean
16
40
  onLockedChange: (locked: boolean) => void
17
- onFolderChange: (label: string) => void
18
41
  onWalkPosition: (x: number, z: number) => void
19
42
  flyTo: FlyTo | null
20
43
  }
21
44
 
22
45
  export function Player({
23
- layout,
24
46
  mode,
25
47
  landAt,
26
48
  locked,
27
49
  lockEnabled = true,
28
50
  onLockedChange,
29
- onFolderChange,
30
51
  onWalkPosition,
31
52
  flyTo,
32
53
  }: PlayerProps) {
@@ -39,52 +60,85 @@ export function Player({
39
60
  right: false,
40
61
  sprint: false,
41
62
  })
42
- const lastFolder = useRef<string | null>(null)
43
63
  const front = useRef(new THREE.Vector3())
44
64
  const right = useRef(new THREE.Vector3())
45
65
  const up = useRef(new THREE.Vector3(0, 1, 0))
46
66
  const move = useRef(new THREE.Vector3())
47
67
  const lastFly = useRef(0)
48
- const lookAt = useRef(new THREE.Vector3())
49
68
  const [steering, setSteering] = useState(true)
50
69
  const controlsRef = useRef<PointerLockControlsImpl>(null)
51
70
  const capturedLook = useRef(false)
52
71
  const lookHeld = useRef(false)
72
+ const poseReady = useRef(false)
73
+ const groundedPose = useRef({
74
+ pos: new THREE.Vector3(),
75
+ look: new THREE.Vector3(),
76
+ })
53
77
  const flying = useRef<{
54
78
  startPos: THREE.Vector3
79
+ midPos: THREE.Vector3
55
80
  endPos: THREE.Vector3
56
- lookAt: THREE.Vector3
81
+ startLook: THREE.Vector3
82
+ endLook: THREE.Vector3
57
83
  duration: number
58
84
  t: number
59
85
  } | null>(null)
60
86
 
61
- useEffect(() => {
87
+ useLayoutEffect(() => {
62
88
  if (!walking) {
63
89
  flying.current = null
64
90
  capturedLook.current = false
91
+ poseReady.current = false
65
92
  setSteering(true)
66
93
  return
67
94
  }
68
95
  if (flyTo && flyTo.nonce !== lastFly.current) {
69
96
  lastFly.current = flyTo.nonce
70
- const end = new THREE.Vector3(landAt[0], CONFIG.eyeHeight, landAt[1])
71
- lookAt.current.set(flyTo.lookAt[0], flyTo.lookAt[1], flyTo.lookAt[2])
72
- const dist = camera.position.distanceTo(end)
97
+ const endPos = new THREE.Vector3(landAt[0], CONFIG.eyeHeight, landAt[1])
98
+ const endLook = new THREE.Vector3(flyTo.lookAt[0], flyTo.lookAt[1], flyTo.lookAt[2])
99
+ const startPos = flying.current
100
+ ? camera.position.clone()
101
+ : poseReady.current
102
+ ? groundedPose.current.pos.clone()
103
+ : new THREE.Vector3(flyTo.from[0], CONFIG.eyeHeight, flyTo.from[1])
104
+ const startLook = new THREE.Vector3()
105
+ if (flying.current || poseReady.current) {
106
+ camera.getWorldDirection(lookDir)
107
+ startLook.copy(startPos).addScaledVector(lookDir, 16)
108
+ } else {
109
+ startLook.copy(startPos).lerp(endLook, 0.4)
110
+ }
111
+
112
+ const dist = startPos.distanceTo(endPos)
113
+ if (dist < 0.2) {
114
+ placeCamera(camera, endPos, endLook)
115
+ return
116
+ }
117
+
118
+ const lift = Math.min(26, 6 + dist * 0.22)
73
119
  flying.current = {
74
- startPos: camera.position.clone(),
75
- endPos: end,
76
- lookAt: lookAt.current.clone(),
77
- duration: Math.min(1.35, Math.max(0.55, dist * 0.025)),
120
+ startPos,
121
+ midPos: new THREE.Vector3(
122
+ (startPos.x + endPos.x) * 0.5,
123
+ CONFIG.eyeHeight + lift,
124
+ (startPos.z + endPos.z) * 0.5,
125
+ ),
126
+ endPos,
127
+ startLook,
128
+ endLook,
129
+ duration: Math.min(2.6, Math.max(0.8, 0.55 + dist * 0.04)),
78
130
  t: 0,
79
131
  }
132
+ placeCamera(camera, startPos, startLook)
80
133
  setSteering(false)
81
134
  return
82
135
  }
83
136
  if (flying.current) return
84
- camera.up.set(0, 1, 0)
85
- camera.position.set(landAt[0], CONFIG.eyeHeight, landAt[1])
86
- camera.lookAt(landAt[0], CONFIG.eyeHeight, landAt[1] + 10)
87
- camera.updateProjectionMatrix()
137
+ placeCamera(
138
+ camera,
139
+ new THREE.Vector3(landAt[0], CONFIG.eyeHeight, landAt[1]),
140
+ new THREE.Vector3(landAt[0], CONFIG.eyeHeight, landAt[1] + 10),
141
+ )
88
142
  }, [camera, flyTo, landAt, walking])
89
143
 
90
144
  useEffect(() => {
@@ -163,20 +217,21 @@ export function Player({
163
217
  try {
164
218
  if (walking && flying.current) {
165
219
  const flight = flying.current
166
- flight.t += delta / flight.duration
220
+ flight.t += Math.min(delta, 0.05) / flight.duration
167
221
  const t = Math.min(1, flight.t)
168
- const ease = t * t * (3 - 2 * t)
169
- camera.position.lerpVectors(flight.startPos, flight.endPos, ease)
170
- const dist = flight.startPos.distanceTo(flight.endPos)
171
- camera.position.y =
172
- CONFIG.eyeHeight + Math.sin(t * Math.PI) * Math.min(14, 3.5 + dist * 0.1)
222
+ const ease = smootherstep(t)
223
+ quadBezier(flyPos, flight.startPos, flight.midPos, flight.endPos, ease)
224
+ flyLook.lerpVectors(flight.startLook, flight.endLook, ease)
225
+ camera.position.copy(flyPos)
173
226
  camera.up.set(0, 1, 0)
174
- camera.lookAt(flight.lookAt)
227
+ camera.lookAt(flyLook)
228
+ camera.rotation.order = 'YXZ'
175
229
  if (t >= 1) {
176
- camera.position.copy(flight.endPos)
177
- camera.up.set(0, 1, 0)
178
- camera.lookAt(flight.lookAt)
230
+ placeCamera(camera, flight.endPos, flight.endLook)
179
231
  flying.current = null
232
+ poseReady.current = true
233
+ groundedPose.current.pos.copy(flight.endPos)
234
+ groundedPose.current.look.copy(flight.endLook)
180
235
  setSteering(true)
181
236
  }
182
237
  } else if (walking && locked) {
@@ -198,12 +253,13 @@ export function Player({
198
253
  }
199
254
 
200
255
  if (!walking) return
201
- onWalkPosition(camera.position.x, camera.position.z)
202
- const nextPath = locationLabel(camera.position.x, camera.position.z, layout)
203
- if (nextPath !== lastFolder.current) {
204
- lastFolder.current = nextPath
205
- onFolderChange(nextPath)
256
+ if (!flying.current) {
257
+ poseReady.current = true
258
+ groundedPose.current.pos.copy(camera.position)
259
+ camera.getWorldDirection(lookDir)
260
+ groundedPose.current.look.copy(camera.position).addScaledVector(lookDir, 16)
206
261
  }
262
+ onWalkPosition(camera.position.x, camera.position.z)
207
263
  } catch {
208
264
  // Movement must never stop the render loop.
209
265
  }
@@ -213,13 +269,7 @@ export function Player({
213
269
 
214
270
  return (
215
271
  <>
216
- <PerspectiveCamera
217
- makeDefault
218
- fov={70}
219
- near={0.1}
220
- far={400}
221
- position={[landAt[0], CONFIG.eyeHeight, landAt[1]]}
222
- />
272
+ <PerspectiveCamera makeDefault fov={70} near={0.1} far={400} />
223
273
  <PlayerLight />
224
274
  <PointerLockControls
225
275
  ref={controlsRef}
@@ -241,6 +291,20 @@ export function Player({
241
291
  )
242
292
  }
243
293
 
294
+ function placeCamera(
295
+ camera: THREE.Camera,
296
+ position: THREE.Vector3,
297
+ lookAt: THREE.Vector3,
298
+ ) {
299
+ camera.position.copy(position)
300
+ camera.up.set(0, 1, 0)
301
+ camera.lookAt(lookAt)
302
+ camera.rotation.order = 'YXZ'
303
+ if ('updateProjectionMatrix' in camera) {
304
+ ;(camera as THREE.PerspectiveCamera).updateProjectionMatrix()
305
+ }
306
+ }
307
+
244
308
  function PlayerLight() {
245
309
  const { camera } = useThree()
246
310
  const overhead = useRef<THREE.PointLight>(null)
@@ -15,6 +15,7 @@ import { FolderArea } from './FolderArea'
15
15
  import { RelationLines } from './RelationLines'
16
16
 
17
17
  type Vec3 = [number, number, number]
18
+ type Orbit = { yaw: number; pitch: number }
18
19
 
19
20
  type SelectionThumbnailProps = {
20
21
  graph: CodebaseGraph
@@ -24,10 +25,12 @@ type SelectionThumbnailProps = {
24
25
  landAt: [number, number]
25
26
  importedBy?: boolean
26
27
  minimized?: boolean
28
+ maximized?: boolean
27
29
  plannedIds?: string[]
28
30
  createdIds?: string[]
29
31
  deletedIds?: string[]
30
32
  onMinimize?: () => void
33
+ onMaximize?: () => void
31
34
  onHide: () => void
32
35
  }
33
36
 
@@ -159,7 +162,10 @@ function resolveTarget(
159
162
 
160
163
  const MIN_ZOOM = 0.45
161
164
  const MAX_ZOOM = 6
165
+ const MIN_ORBIT_PITCH = 0.08
166
+ const MAX_ORBIT_PITCH = Math.PI / 2 - 0.06
162
167
  const ZERO_PAN: Vec3 = [0, 0, 0]
168
+ const ZERO_ORBIT: Orbit = { yaw: 0, pitch: 0 }
163
169
  const WORLD_UP: Vec3 = [0, 1, 0]
164
170
  const LABEL_PROJECT = new THREE.Vector3()
165
171
  const GROUND_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
@@ -212,14 +218,61 @@ function cameraOffset(position: Vec3, lookAt: Vec3, zoom: number): Vec3 {
212
218
  return vecScale(vecSub(position, lookAt), 1 / zoom)
213
219
  }
214
220
 
221
+ function sphericalFromOffset(offset: Vec3) {
222
+ const radius = vecLength(offset)
223
+ return {
224
+ radius,
225
+ yaw: Math.atan2(offset[0], offset[2]),
226
+ pitch: Math.asin(
227
+ Math.min(1, Math.max(-1, offset[1] / Math.max(radius, 1e-6))),
228
+ ),
229
+ }
230
+ }
231
+
232
+ function offsetFromSpherical(radius: number, yaw: number, pitch: number): Vec3 {
233
+ const cosPitch = Math.cos(pitch)
234
+ return [
235
+ radius * Math.sin(yaw) * cosPitch,
236
+ radius * Math.sin(pitch),
237
+ radius * Math.cos(yaw) * cosPitch,
238
+ ]
239
+ }
240
+
241
+ function clampOrbitPitch(basePitch: number, orbitPitch: number) {
242
+ return Math.min(
243
+ MAX_ORBIT_PITCH - basePitch,
244
+ Math.max(MIN_ORBIT_PITCH - basePitch, orbitPitch),
245
+ )
246
+ }
247
+
248
+ function orbitedOffset(
249
+ position: Vec3,
250
+ lookAt: Vec3,
251
+ zoom: number,
252
+ orbit: Orbit,
253
+ ): Vec3 {
254
+ const base = cameraOffset(position, lookAt, zoom)
255
+ if (orbit.yaw === 0 && orbit.pitch === 0) return base
256
+ const spherical = sphericalFromOffset(base)
257
+ return offsetFromSpherical(
258
+ spherical.radius,
259
+ spherical.yaw + orbit.yaw,
260
+ Math.min(
261
+ MAX_ORBIT_PITCH,
262
+ Math.max(MIN_ORBIT_PITCH, spherical.pitch + orbit.pitch),
263
+ ),
264
+ )
265
+ }
266
+
215
267
  function applyThumbnailCamera(
216
268
  camera: THREE.Camera,
217
269
  position: Vec3,
218
270
  lookAt: Vec3,
219
271
  zoom: number,
220
272
  pan: Vec3,
273
+ orbit: Orbit,
221
274
  ) {
222
- const offset = cameraOffset(position, lookAt, zoom)
275
+ const offset = orbitedOffset(position, lookAt, zoom, orbit)
223
276
  camera.up.set(0, 1, 0)
224
277
  camera.position.set(
225
278
  lookAt[0] + pan[0] + offset[0],
@@ -247,8 +300,13 @@ function worldUnderCursor(
247
300
  return Boolean(CURSOR_RAY.ray.intersectPlane(GROUND_PLANE, target))
248
301
  }
249
302
 
250
- function cameraPanBasis(position: Vec3, lookAt: Vec3, zoom: number) {
251
- const offset = cameraOffset(position, lookAt, zoom)
303
+ function cameraPanBasis(
304
+ position: Vec3,
305
+ lookAt: Vec3,
306
+ zoom: number,
307
+ orbit: Orbit,
308
+ ) {
309
+ const offset = orbitedOffset(position, lookAt, zoom, orbit)
252
310
  const forward = vecNormalize(vecScale(offset, -1))
253
311
  const right = vecNormalize(vecCross(forward, WORLD_UP))
254
312
  const up = vecNormalize(vecCross(right, forward))
@@ -328,12 +386,14 @@ function CameraRig({
328
386
  lookAt,
329
387
  zoomRef,
330
388
  panRef,
389
+ orbitRef,
331
390
  cameraRef,
332
391
  }: {
333
392
  position: Vec3
334
393
  lookAt: Vec3
335
394
  zoomRef: { current: number }
336
395
  panRef: { current: Vec3 }
396
+ orbitRef: { current: Orbit }
337
397
  cameraRef: { current: THREE.Camera | null }
338
398
  }) {
339
399
  const { camera } = useThree()
@@ -345,10 +405,19 @@ function CameraRig({
345
405
  lookAt,
346
406
  zoomRef.current,
347
407
  panRef.current,
408
+ orbitRef.current,
348
409
  )
349
410
  }
350
411
 
351
- useLayoutEffect(aim, [camera, cameraRef, lookAt, panRef, position, zoomRef])
412
+ useLayoutEffect(aim, [
413
+ camera,
414
+ cameraRef,
415
+ lookAt,
416
+ orbitRef,
417
+ panRef,
418
+ position,
419
+ zoomRef,
420
+ ])
352
421
  useFrame(aim)
353
422
  return null
354
423
  }
@@ -393,6 +462,7 @@ function ThumbnailScene({
393
462
  zoom,
394
463
  zoomRef,
395
464
  panRef,
465
+ orbitRef,
396
466
  cameraRef,
397
467
  visibleLabelIds,
398
468
  onVisibleLabels,
@@ -408,6 +478,7 @@ function ThumbnailScene({
408
478
  zoom: number
409
479
  zoomRef: { current: number }
410
480
  panRef: { current: Vec3 }
481
+ orbitRef: { current: Orbit }
411
482
  cameraRef: { current: THREE.Camera | null }
412
483
  visibleLabelIds: Set<string>
413
484
  onVisibleLabels: (ids: Set<string>) => void
@@ -464,6 +535,7 @@ function ThumbnailScene({
464
535
  lookAt={target.camera.lookAt}
465
536
  zoomRef={zoomRef}
466
537
  panRef={panRef}
538
+ orbitRef={orbitRef}
467
539
  cameraRef={cameraRef}
468
540
  />
469
541
  <ThumbnailLabelFilter
@@ -524,16 +596,19 @@ export function SelectionThumbnail({
524
596
  landAt,
525
597
  importedBy = false,
526
598
  minimized = false,
599
+ maximized = false,
527
600
  plannedIds = [],
528
601
  createdIds = [],
529
602
  deletedIds = [],
530
603
  onMinimize,
604
+ onMaximize,
531
605
  onHide,
532
606
  }: SelectionThumbnailProps) {
533
607
  const stageRef = useRef<HTMLDivElement>(null)
534
608
  const cameraRef = useRef<THREE.Camera | null>(null)
535
609
  const zoomRef = useRef(1)
536
610
  const panRef = useRef<Vec3>(ZERO_PAN)
611
+ const orbitRef = useRef<Orbit>(ZERO_ORBIT)
537
612
  const pinchRef = useRef({ start: 0, zoom: 1 })
538
613
  const zoomAtCursorRef = useRef(
539
614
  (_clientX: number, _clientY: number, _dollyScale: number) => {},
@@ -543,9 +618,12 @@ export function SelectionThumbnail({
543
618
  x: 0,
544
619
  y: 0,
545
620
  pan: ZERO_PAN,
621
+ orbit: ZERO_ORBIT,
622
+ rotate: false,
546
623
  })
547
624
  const [zoom, setZoom] = useState(1)
548
625
  const [panning, setPanning] = useState(false)
626
+ const [orbiting, setOrbiting] = useState(false)
549
627
  const [visibleLabelIds, setVisibleLabelIds] = useState<Set<string>>(
550
628
  () => new Set(),
551
629
  )
@@ -574,9 +652,11 @@ export function SelectionThumbnail({
574
652
  useEffect(() => {
575
653
  zoomRef.current = 1
576
654
  panRef.current = ZERO_PAN
655
+ orbitRef.current = ZERO_ORBIT
577
656
  setZoom(1)
578
657
  panningRef.current = false
579
658
  setPanning(false)
659
+ setOrbiting(false)
580
660
  }, [selectedId, selectedFolder, landAt])
581
661
 
582
662
  useEffect(() => {
@@ -624,6 +704,7 @@ export function SelectionThumbnail({
624
704
  target.camera.lookAt,
625
705
  nextZoom,
626
706
  panRef.current,
707
+ orbitRef.current,
627
708
  )
628
709
  if (
629
710
  hit &&
@@ -640,6 +721,7 @@ export function SelectionThumbnail({
640
721
  target.camera.lookAt,
641
722
  nextZoom,
642
723
  panRef.current,
724
+ orbitRef.current,
643
725
  )
644
726
  }
645
727
  }
@@ -692,6 +774,7 @@ export function SelectionThumbnail({
692
774
  dragRef.current.pointerId = -1
693
775
  panningRef.current = false
694
776
  setPanning(false)
777
+ setOrbiting(false)
695
778
  pinchRef.current = {
696
779
  start: touchDistance(event.touches),
697
780
  zoom: zoomRef.current,
@@ -722,6 +805,8 @@ export function SelectionThumbnail({
722
805
  x: event.clientX,
723
806
  y: event.clientY,
724
807
  pan: panRef.current,
808
+ orbit: { ...orbitRef.current },
809
+ rotate: event.metaKey || event.ctrlKey,
725
810
  }
726
811
  stage.setPointerCapture(event.pointerId)
727
812
  }
@@ -733,12 +818,28 @@ export function SelectionThumbnail({
733
818
  if (!panningRef.current) {
734
819
  if (Math.hypot(dx, dy) <= 3) return
735
820
  panningRef.current = true
736
- setPanning(true)
821
+ if (dragRef.current.rotate) setOrbiting(true)
822
+ else setPanning(true)
823
+ }
824
+ if (dragRef.current.rotate) {
825
+ const speed = (2 * Math.PI) / Math.max(stage.clientHeight, 1)
826
+ const basePitch = sphericalFromOffset(
827
+ cameraOffset(target.camera.position, target.camera.lookAt, 1),
828
+ ).pitch
829
+ orbitRef.current = {
830
+ yaw: dragRef.current.orbit.yaw - dx * speed,
831
+ pitch: clampOrbitPitch(
832
+ basePitch,
833
+ dragRef.current.orbit.pitch + dy * speed,
834
+ ),
835
+ }
836
+ return
737
837
  }
738
838
  const { right, up, distance } = cameraPanBasis(
739
839
  target.camera.position,
740
840
  target.camera.lookAt,
741
841
  zoomRef.current,
842
+ orbitRef.current,
742
843
  )
743
844
  const speed = distance / Math.max(stage.clientHeight, 1)
744
845
  panRef.current = vecAdd(
@@ -752,6 +853,7 @@ export function SelectionThumbnail({
752
853
  dragRef.current.pointerId = -1
753
854
  panningRef.current = false
754
855
  setPanning(false)
856
+ setOrbiting(false)
755
857
  if (stage.hasPointerCapture(event.pointerId)) {
756
858
  stage.releasePointerCapture(event.pointerId)
757
859
  }
@@ -762,6 +864,7 @@ export function SelectionThumbnail({
762
864
  event.preventDefault()
763
865
  zoomRef.current = 1
764
866
  panRef.current = ZERO_PAN
867
+ orbitRef.current = ZERO_ORBIT
765
868
  setZoom(1)
766
869
  }
767
870
 
@@ -789,14 +892,29 @@ export function SelectionThumbnail({
789
892
  }
790
893
  }, [minimized, target])
791
894
 
895
+ useEffect(() => {
896
+ if (!maximized) return
897
+ const onKeyDown = (event: KeyboardEvent) => {
898
+ if (event.key !== 'Escape') return
899
+ event.preventDefault()
900
+ onMaximize?.()
901
+ }
902
+ window.addEventListener('keydown', onKeyDown)
903
+ return () => window.removeEventListener('keydown', onKeyDown)
904
+ }, [maximized, onMaximize])
905
+
792
906
  if (!target) return null
793
907
 
794
908
  return (
795
- <div className="hud-thumbnail" data-minimized={minimized}>
909
+ <div
910
+ className="hud-thumbnail"
911
+ data-minimized={minimized}
912
+ data-maximized={maximized}
913
+ >
796
914
  <div className="hud-thumbnail-bar">
797
915
  <span>3D view</span>
798
916
  <div className="hud-panel-controls">
799
- {onMinimize && (
917
+ {onMinimize && !maximized && (
800
918
  <button
801
919
  className="hud-button hud-icon-button hud-panel-control"
802
920
  type="button"
@@ -806,6 +924,16 @@ export function SelectionThumbnail({
806
924
  {minimized ? '+' : '−'}
807
925
  </button>
808
926
  )}
927
+ {onMaximize && (
928
+ <button
929
+ className="hud-button hud-icon-button hud-panel-control"
930
+ type="button"
931
+ aria-label={maximized ? 'Minimize 3D view' : 'Maximize 3D view'}
932
+ onClick={onMaximize}
933
+ >
934
+ {maximized ? '−' : '□'}
935
+ </button>
936
+ )}
809
937
  <button
810
938
  className="hud-button hud-icon-button hud-panel-control"
811
939
  type="button"
@@ -821,6 +949,7 @@ export function SelectionThumbnail({
821
949
  className="hud-thumbnail-stage"
822
950
  ref={stageRef}
823
951
  data-panning={panning}
952
+ data-orbiting={orbiting}
824
953
  >
825
954
  <Canvas
826
955
  shadows={false}
@@ -846,12 +975,15 @@ export function SelectionThumbnail({
846
975
  zoom={zoom}
847
976
  zoomRef={zoomRef}
848
977
  panRef={panRef}
978
+ orbitRef={orbitRef}
849
979
  cameraRef={cameraRef}
850
980
  visibleLabelIds={visibleLabelIds}
851
981
  onVisibleLabels={setVisibleLabelIds}
852
982
  />
853
983
  </Canvas>
854
- <div className="hud-thumbnail-hint">Scroll zoom · drag pan</div>
984
+ <div className="hud-thumbnail-hint">
985
+ Scroll zoom · drag pan · ⌘ drag rotate
986
+ </div>
855
987
  <div className="hud-thumbnail-nav">
856
988
  <button
857
989
  className="hud-button hud-icon-button"
@@ -41,7 +41,6 @@ type WorldProps = {
41
41
  onSelect: (fileId: string | null) => void
42
42
  onSelectFolder: (folderPath: string | null) => void
43
43
  onLockedChange: (locked: boolean) => void
44
- onFolderChange: (label: string) => void
45
44
  onLand: (x: number, z: number) => void
46
45
  onWalkPosition: (x: number, z: number) => void
47
46
  onContext: (context: UserContext) => void
@@ -81,7 +80,6 @@ export function World({
81
80
  onSelect,
82
81
  onSelectFolder,
83
82
  onLockedChange,
84
- onFolderChange,
85
83
  onLand,
86
84
  onWalkPosition,
87
85
  onContext,
@@ -277,7 +275,6 @@ export function World({
277
275
  locked={locked}
278
276
  lockEnabled={!placing}
279
277
  onLockedChange={onLockedChange}
280
- onFolderChange={onFolderChange}
281
278
  onWalkPosition={onWalkPosition}
282
279
  flyTo={flyTo}
283
280
  />
@@ -69,6 +69,7 @@ export type ViewMode = 'map' | 'walk'
69
69
 
70
70
  export type FlyTo = {
71
71
  nonce: number
72
+ from: [number, number]
72
73
  lookAt: [number, number, number]
73
74
  }
74
75