@jkwd/inbase 0.1.5 → 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.
- package/README.md +4 -1
- package/apps/explorer/scripts/patch-lib.d.ts +13 -0
- package/apps/explorer/scripts/patch-lib.mjs +162 -25
- package/apps/explorer/scripts/session-store.d.ts +18 -1
- package/apps/explorer/scripts/session-store.mjs +102 -26
- package/apps/explorer/src/App.tsx +153 -3
- package/apps/explorer/src/agentIntent.ts +9 -0
- package/apps/explorer/src/index.css +103 -11
- package/apps/explorer/src/layout.ts +65 -1
- package/apps/explorer/src/scene/Bridge.tsx +23 -5
- package/apps/explorer/src/scene/FileBlock.tsx +13 -9
- package/apps/explorer/src/scene/FolderArea.tsx +18 -12
- package/apps/explorer/src/scene/MapView.tsx +69 -12
- package/apps/explorer/src/scene/Player.tsx +9 -1
- package/apps/explorer/src/scene/SelectionController.tsx +36 -13
- package/apps/explorer/src/scene/SelectionThumbnail.tsx +477 -33
- package/apps/explorer/src/scene/World.tsx +27 -10
- package/apps/explorer/src/theme.ts +10 -3
- package/apps/explorer/src/types.ts +5 -0
- package/apps/explorer/src/ui/HUD.tsx +294 -37
- package/apps/explorer/vite.config.ts +11 -1
- package/bin/session.mjs +18 -6
- package/package.json +1 -1
- package/skill/inbase/SKILL.md +13 -8
|
@@ -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.
|
|
158
|
-
const MAX_ZOOM =
|
|
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
|
-
|
|
329
|
+
zoomRef,
|
|
330
|
+
panRef,
|
|
331
|
+
cameraRef,
|
|
176
332
|
}: {
|
|
177
|
-
position:
|
|
178
|
-
lookAt:
|
|
179
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
lookAt
|
|
187
|
-
|
|
188
|
-
|
|
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,
|
|
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
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
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.
|
|
661
|
+
if (!pointerOverStage(event.clientX, event.clientY)) return
|
|
345
662
|
event.preventDefault()
|
|
346
|
-
event.
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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>
|