@jkwd/inbase 0.1.11 → 0.1.13

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,10 +1,17 @@
1
- import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
2
3
  import { useFrame, useThree } from '@react-three/fiber'
3
4
  import { Html, MapControls, OrthographicCamera } from '@react-three/drei'
4
5
  import * as THREE from 'three'
5
6
  import { folderAt, worldBounds } from '../layout'
6
7
  import type { ChangeKind } from '../theme'
7
- import type { WorldLayout } from '../types'
8
+ import type { PlacedFolder, WorldLayout } from '../types'
9
+
10
+ export type MapBlueprintMenu = {
11
+ x: number
12
+ y: number
13
+ folder: string
14
+ }
8
15
 
9
16
  type MapViewProps = {
10
17
  layout: WorldLayout
@@ -16,6 +23,7 @@ type MapViewProps = {
16
23
  onSelect: (fileId: string | null) => void
17
24
  onSelectFolder: (folderPath: string | null) => void
18
25
  onTravelTo: (fromId: string, toId: string) => void
26
+ onBlueprintMenu?: (menu: MapBlueprintMenu) => void
19
27
  }
20
28
 
21
29
  export function MapView({
@@ -28,9 +36,13 @@ export function MapView({
28
36
  onSelect,
29
37
  onSelectFolder,
30
38
  onTravelTo,
39
+ onBlueprintMenu,
31
40
  }: MapViewProps) {
32
41
  const size = useThree((state) => state.size)
33
- const { camera, gl, invalidate, scene } = useThree()
42
+ const camera = useThree((state) => state.camera)
43
+ const gl = useThree((state) => state.gl)
44
+ const invalidate = useThree((state) => state.invalidate)
45
+ const scene = useThree((state) => state.scene)
34
46
  const bounds = useMemo(() => worldBounds(layout), [layout])
35
47
  const drag = useRef({ x: 0, y: 0, moved: false, active: false })
36
48
  const sized = size.width > 16 && size.height > 16
@@ -172,7 +184,28 @@ export function MapView({
172
184
  }
173
185
 
174
186
  const onContextMenu = (event: MouseEvent) => {
175
- if (event.ctrlKey) event.preventDefault()
187
+ if (event.ctrlKey) {
188
+ event.preventDefault()
189
+ return
190
+ }
191
+ if (!onBlueprintMenu) return
192
+ event.preventDefault()
193
+ const pick = pickAt(event.clientX, event.clientY)
194
+ if (!pick) return
195
+ const hit = new THREE.Vector3()
196
+ const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
197
+ if (!pick.raycaster.ray.intersectPlane(plane, hit)) return
198
+ const folder =
199
+ folderAt(hit.x, hit.z, layout) ??
200
+ (selectedFolder ? layout.folders[selectedFolder] : undefined) ??
201
+ layout.folders['.']
202
+ if (!folder) return
203
+ onSelectFolder(folder.path)
204
+ onBlueprintMenu({
205
+ x: event.clientX,
206
+ y: event.clientY,
207
+ folder: folder.path,
208
+ })
176
209
  }
177
210
 
178
211
  const ground = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
@@ -250,10 +283,12 @@ export function MapView({
250
283
  invalidate,
251
284
  layout,
252
285
  onLand,
286
+ onBlueprintMenu,
253
287
  onSelect,
254
288
  onSelectFolder,
255
289
  onTravelTo,
256
290
  scene,
291
+ selectedFolder,
257
292
  ])
258
293
 
259
294
  return (
@@ -274,44 +309,121 @@ export function MapView({
274
309
  maxZoom={Math.max(fitZoom * 10, 20)}
275
310
  />
276
311
  )}
277
- {enabled &&
278
- Object.values(layout.folders).map((folder) => (
279
- <Html
280
- key={folder.path}
281
- position={[folder.x, 14, folder.z + 1.35]}
282
- center
283
- zIndexRange={[80, 50]}
284
- style={{ pointerEvents: 'none' }}
285
- >
286
- <div
287
- className={[
288
- 'map-folder-label',
289
- highlightedFolders?.[folder.path]
290
- ? `map-folder-label-${highlightedFolders[folder.path]}`
291
- : folder.added
292
- ? 'map-folder-label-added'
293
- : selectedFolder === folder.path
294
- ? 'map-folder-label-selected'
295
- : '',
296
- ]
297
- .filter(Boolean)
298
- .join(' ')}
299
- >
300
- <span className="map-folder-name">
301
- {folderKindLabel(
302
- folder.name,
303
- highlightedFolders?.[folder.path] ?? null,
304
- folder.added ?? false,
305
- )}
306
- </span>
307
- </div>
308
- </Html>
309
- ))}
312
+ {enabled && (
313
+ <MapFolderLabels
314
+ folders={layout.folders}
315
+ highlightedFolders={highlightedFolders}
316
+ selectedFolder={selectedFolder}
317
+ />
318
+ )}
310
319
  {enabled && marker && <LandMarker marker={marker} />}
311
320
  </>
312
321
  )
313
322
  }
314
323
 
324
+ const PROJECT = new THREE.Vector3()
325
+ const MIN_FOLDER_LABEL_PX = 28
326
+
327
+ function folderLabelClass(
328
+ folder: PlacedFolder,
329
+ highlightedFolders: Partial<Record<string, ChangeKind>> | undefined,
330
+ selectedFolder: string | null,
331
+ ) {
332
+ return [
333
+ 'map-folder-label',
334
+ highlightedFolders?.[folder.path]
335
+ ? `map-folder-label-${highlightedFolders[folder.path]}`
336
+ : folder.added
337
+ ? 'map-folder-label-added'
338
+ : selectedFolder === folder.path
339
+ ? 'map-folder-label-selected'
340
+ : '',
341
+ ]
342
+ .filter(Boolean)
343
+ .join(' ')
344
+ }
345
+
346
+ function MapFolderLabels({
347
+ folders,
348
+ highlightedFolders,
349
+ selectedFolder,
350
+ }: {
351
+ folders: Record<string, PlacedFolder>
352
+ highlightedFolders?: Partial<Record<string, ChangeKind>>
353
+ selectedFolder: string | null
354
+ }) {
355
+ const camera = useThree((state) => state.camera)
356
+ const gl = useThree((state) => state.gl)
357
+ const size = useThree((state) => state.size)
358
+ const layerRef = useRef<HTMLDivElement>(null)
359
+ const items = useMemo(() => Object.values(folders), [folders])
360
+ const [host, setHost] = useState<HTMLElement | null>(null)
361
+
362
+ useLayoutEffect(() => {
363
+ setHost(gl.domElement.parentElement)
364
+ }, [gl])
365
+
366
+ useFrame(() => {
367
+ const layer = layerRef.current
368
+ if (!layer) return
369
+ const zoom = 'zoom' in camera ? Number(camera.zoom) : 1
370
+ const nodes = layer.children
371
+ for (let i = 0; i < items.length; i += 1) {
372
+ const el = nodes[i] as HTMLElement | undefined
373
+ const folder = items[i]
374
+ if (!el || !folder) continue
375
+ PROJECT.set(folder.x, 14, folder.z + 1.35).project(camera)
376
+ const x = (PROJECT.x * 0.5 + 0.5) * size.width
377
+ const y = (-PROJECT.y * 0.5 + 0.5) * size.height
378
+ const span = Math.max(folder.width, folder.depth) * zoom
379
+ const force =
380
+ selectedFolder === folder.path ||
381
+ Boolean(highlightedFolders?.[folder.path] || folder.added)
382
+ const onScreen =
383
+ PROJECT.z >= -1 &&
384
+ PROJECT.z <= 1 &&
385
+ x > -120 &&
386
+ x < size.width + 120 &&
387
+ y > -40 &&
388
+ y < size.height + 40
389
+ if (!onScreen || (!force && span < MIN_FOLDER_LABEL_PX)) {
390
+ if (el.style.visibility !== 'hidden') el.style.visibility = 'hidden'
391
+ continue
392
+ }
393
+ const tx = Math.round(x)
394
+ const ty = Math.round(y)
395
+ const next = `${tx},${ty}`
396
+ if (el.dataset.pos !== next) {
397
+ el.dataset.pos = next
398
+ el.style.transform = `translate3d(${tx}px, ${ty}px, 0) translate(-50%, -50%)`
399
+ }
400
+ if (el.style.visibility !== 'visible') el.style.visibility = 'visible'
401
+ }
402
+ })
403
+
404
+ if (!host) return null
405
+
406
+ return createPortal(
407
+ <div ref={layerRef} className="map-folder-label-layer">
408
+ {items.map((folder) => (
409
+ <div
410
+ key={folder.path}
411
+ className={folderLabelClass(folder, highlightedFolders, selectedFolder)}
412
+ >
413
+ <span className="map-folder-name">
414
+ {folderKindLabel(
415
+ folder.name,
416
+ highlightedFolders?.[folder.path] ?? null,
417
+ folder.added ?? false,
418
+ )}
419
+ </span>
420
+ </div>
421
+ ))}
422
+ </div>,
423
+ host,
424
+ )
425
+ }
426
+
315
427
  function folderKindLabel(
316
428
  name: string,
317
429
  kind: ChangeKind | null | undefined,
@@ -425,16 +425,20 @@ function CameraRig({
425
425
  function ThumbnailLabelFilter({
426
426
  candidates,
427
427
  zoom,
428
+ frozenRef,
428
429
  onChange,
429
430
  }: {
430
431
  candidates: LabelCandidate[]
431
432
  zoom: number
433
+ frozenRef: { current: boolean }
432
434
  onChange: (ids: Set<string>) => void
433
435
  }) {
434
- const { camera, size } = useThree()
436
+ const camera = useThree((state) => state.camera)
437
+ const size = useThree((state) => state.size)
435
438
  const visibleRef = useRef<Set<string>>(new Set())
436
439
 
437
440
  useFrame(() => {
441
+ if (frozenRef.current) return
438
442
  const next = visibleThumbnailLabels(
439
443
  candidates,
440
444
  camera,
@@ -464,6 +468,7 @@ function ThumbnailScene({
464
468
  panRef,
465
469
  orbitRef,
466
470
  cameraRef,
471
+ frozenRef,
467
472
  visibleLabelIds,
468
473
  onVisibleLabels,
469
474
  }: {
@@ -480,6 +485,7 @@ function ThumbnailScene({
480
485
  panRef: { current: Vec3 }
481
486
  orbitRef: { current: Orbit }
482
487
  cameraRef: { current: THREE.Camera | null }
488
+ frozenRef: { current: boolean }
483
489
  visibleLabelIds: Set<string>
484
490
  onVisibleLabels: (ids: Set<string>) => void
485
491
  }) {
@@ -541,6 +547,7 @@ function ThumbnailScene({
541
547
  <ThumbnailLabelFilter
542
548
  candidates={labelCandidates}
543
549
  zoom={zoom}
550
+ frozenRef={frozenRef}
544
551
  onChange={onVisibleLabels}
545
552
  />
546
553
  <FolderArea
@@ -977,6 +984,7 @@ export function SelectionThumbnail({
977
984
  panRef={panRef}
978
985
  orbitRef={orbitRef}
979
986
  cameraRef={cameraRef}
987
+ frozenRef={panningRef}
980
988
  visibleLabelIds={visibleLabelIds}
981
989
  onVisibleLabels={setVisibleLabelIds}
982
990
  />
@@ -0,0 +1,92 @@
1
+ import { useLayoutEffect, useRef } from 'react'
2
+ import { useFrame, useThree } from '@react-three/fiber'
3
+ import * as THREE from 'three'
4
+ import type { PlacedBridge, PlacedFile, PlacedFolder } from '../types'
5
+ import {
6
+ computeWalkLod,
7
+ sameWalkLod,
8
+ walkLodCell,
9
+ type WalkLod,
10
+ } from './walkLod'
11
+
12
+ const look = new THREE.Vector3()
13
+
14
+ type WalkLodTrackerProps = {
15
+ files: Record<string, PlacedFile>
16
+ folders: Record<string, PlacedFolder>
17
+ bridges: PlacedBridge[]
18
+ keepFileIds: Set<string>
19
+ keepFolderPaths: Set<string>
20
+ origin: [number, number]
21
+ onChange: (lod: WalkLod) => void
22
+ }
23
+
24
+ export function WalkLodTracker({
25
+ files,
26
+ folders,
27
+ bridges,
28
+ keepFileIds,
29
+ keepFolderPaths,
30
+ origin,
31
+ onChange,
32
+ }: WalkLodTrackerProps) {
33
+ const { camera } = useThree()
34
+ const prev = useRef<WalkLod | null>(null)
35
+ const cell = useRef('')
36
+ const keepFilesRef = useRef(keepFileIds)
37
+ const keepFoldersRef = useRef(keepFolderPaths)
38
+ const filesRef = useRef(files)
39
+ const foldersRef = useRef(folders)
40
+ const bridgesRef = useRef(bridges)
41
+ const onChangeRef = useRef(onChange)
42
+ keepFilesRef.current = keepFileIds
43
+ keepFoldersRef.current = keepFolderPaths
44
+ filesRef.current = files
45
+ foldersRef.current = folders
46
+ bridgesRef.current = bridges
47
+ onChangeRef.current = onChange
48
+
49
+ const publish = (x: number, z: number, y: number, lookX: number, lookZ: number) => {
50
+ const next = computeWalkLod({
51
+ x,
52
+ z,
53
+ y,
54
+ lookX,
55
+ lookZ,
56
+ files: filesRef.current,
57
+ folders: foldersRef.current,
58
+ bridges: bridgesRef.current,
59
+ keepFileIds: keepFilesRef.current,
60
+ keepFolderPaths: keepFoldersRef.current,
61
+ prev: prev.current,
62
+ })
63
+ if (sameWalkLod(prev.current, next)) return
64
+ prev.current = next
65
+ onChangeRef.current(next)
66
+ }
67
+
68
+ const keepKey = `${[...keepFileIds].join('|')}|${[...keepFolderPaths].join('|')}`
69
+
70
+ useLayoutEffect(() => {
71
+ camera.getWorldDirection(look)
72
+ const lx = look.x
73
+ const lz = look.z
74
+ if (Math.hypot(lx, lz) < 0.001) {
75
+ publish(origin[0], origin[1], camera.position.y, 0, 1)
76
+ return
77
+ }
78
+ publish(camera.position.x, camera.position.z, camera.position.y, lx, lz)
79
+ }, [bridges, camera, files, folders, keepKey, origin])
80
+
81
+ useFrame(() => {
82
+ camera.getWorldDirection(look)
83
+ const lx = look.x
84
+ const lz = look.z
85
+ const nextCell = `${walkLodCell(camera.position.x, camera.position.z, camera.position.y, lx, lz)}:${keepKey}`
86
+ if (nextCell === cell.current) return
87
+ cell.current = nextCell
88
+ publish(camera.position.x, camera.position.z, camera.position.y, lx, lz)
89
+ })
90
+
91
+ return null
92
+ }
@@ -1,13 +1,17 @@
1
+ import { useMemo, useState } from 'react'
1
2
  import { FolderArea } from './FolderArea'
2
3
  import { FileBlock } from './FileBlock'
4
+ import { DistantFileBlocks } from './DistantFileBlocks'
3
5
  import { Bridge } from './Bridge'
4
6
  import { RelationLines } from './RelationLines'
5
7
  import { Player } from './Player'
6
- import { MapView } from './MapView'
8
+ import { MapView, type MapBlueprintMenu } from './MapView'
7
9
  import { SelectionController } from './SelectionController'
8
10
  import { UserContextTracker } from './UserContextTracker'
9
11
  import { BlockPlacer } from './BlockPlacer'
10
12
  import { IslandPlacer } from './IslandPlacer'
13
+ import { WalkLodTracker } from './WalkLodTracker'
14
+ import { computeWalkLod, type WalkLod } from './walkLod'
11
15
  import {
12
16
  fileChangeKind,
13
17
  filesImporting,
@@ -60,6 +64,7 @@ type WorldProps = {
60
64
  namingId?: string | null
61
65
  onPlaceBlock?: (spot: { x: number; z: number; folder: string }) => void
62
66
  onPlaceIsland?: (parent: string) => void
67
+ onBlueprintMenu?: (menu: MapBlueprintMenu) => void
63
68
  onCommitName?: (id: string, name: string) => boolean
64
69
  onCancelName?: (id: string) => void
65
70
  userCreatedBlocks?: UserCreatedBlock[]
@@ -100,6 +105,7 @@ export function World({
100
105
  namingIslandId = null,
101
106
  onPlaceBlock,
102
107
  onPlaceIsland,
108
+ onBlueprintMenu,
103
109
  onCommitName,
104
110
  onCancelName,
105
111
  userCreatedBlocks = [],
@@ -150,6 +156,59 @@ export function World({
150
156
  deleted,
151
157
  viewLayout.folders,
152
158
  )
159
+ const ghostKey = Object.keys(ghosts).join('|')
160
+ const keepFileIds = useMemo(() => {
161
+ const ids = new Set<string>()
162
+ if (selectedId) ids.add(selectedId)
163
+ if (namingId) ids.add(namingId)
164
+ if (aimedRelation?.flyTo) ids.add(aimedRelation.flyTo)
165
+ if (ghostKey) {
166
+ for (const id of ghostKey.split('|')) ids.add(id)
167
+ }
168
+ return ids
169
+ }, [aimedRelation?.flyTo, ghostKey, namingId, selectedId])
170
+ const keepFolderPaths = useMemo(() => {
171
+ const paths = new Set<string>()
172
+ if (selectedFolder) paths.add(selectedFolder)
173
+ if (namingIslandId) paths.add(namingIslandId)
174
+ return paths
175
+ }, [namingIslandId, selectedFolder])
176
+ const originLod = useMemo(() => {
177
+ if (mapping) return null
178
+ return computeWalkLod({
179
+ x: landAt[0],
180
+ z: landAt[1],
181
+ lookX: 0,
182
+ lookZ: 1,
183
+ files: layout.files,
184
+ folders: layout.folders,
185
+ bridges: layout.bridges,
186
+ keepFileIds,
187
+ keepFolderPaths,
188
+ prev: null,
189
+ })
190
+ }, [
191
+ keepFileIds,
192
+ keepFolderPaths,
193
+ landAt,
194
+ layout.bridges,
195
+ layout.files,
196
+ layout.folders,
197
+ mapping,
198
+ ])
199
+ const landSig = `${landAt[0]},${landAt[1]}`
200
+ const [lodLand, setLodLand] = useState(landSig)
201
+ const [walkLod, setWalkLod] = useState<WalkLod | null>(null)
202
+ if (lodLand !== landSig) {
203
+ setLodLand(landSig)
204
+ setWalkLod(null)
205
+ }
206
+ const lod = mapping ? null : (walkLod ?? originLod)
207
+ const distantFiles: {
208
+ file: FileNode
209
+ placed: PlacedFile
210
+ dimmed: boolean
211
+ }[] = []
153
212
 
154
213
  return (
155
214
  <>
@@ -171,23 +230,33 @@ export function World({
171
230
  onSelect={onSelect}
172
231
  onSelectFolder={onSelectFolder}
173
232
  onTravelTo={onTravelTo}
233
+ onBlueprintMenu={
234
+ mapping && !placing && onBlueprintMenu ? onBlueprintMenu : undefined
235
+ }
174
236
  />
175
237
 
176
- {Object.values(viewLayout.folders).map((folder) => (
177
- <FolderArea
178
- key={folder.path}
179
- folder={folder}
180
- naming={folder.path === namingIslandId}
181
- selected={folder.path === selectedFolder}
182
- mapMode={mapping}
183
- highlightKind={
184
- mapping ? highlightedFolders[folder.path] ?? null : null
185
- }
186
- />
187
- ))}
188
- {viewLayout.bridges.map((bridge) => (
189
- <Bridge key={bridge.id} bridge={bridge} folders={viewLayout.folders} />
190
- ))}
238
+ {Object.values(viewLayout.folders).map((folder) => {
239
+ if (lod && !lod.folders.has(folder.path)) return null
240
+ return (
241
+ <FolderArea
242
+ key={folder.path}
243
+ folder={folder}
244
+ naming={folder.path === namingIslandId}
245
+ selected={folder.path === selectedFolder}
246
+ mapMode={mapping}
247
+ highlightKind={
248
+ mapping ? highlightedFolders[folder.path] ?? null : null
249
+ }
250
+ labelVisible={!lod || lod.folderLabels.has(folder.path)}
251
+ />
252
+ )
253
+ })}
254
+ {viewLayout.bridges.map((bridge) => {
255
+ if (lod && !lod.bridges.has(bridge.id)) return null
256
+ return (
257
+ <Bridge key={bridge.id} bridge={bridge} folders={viewLayout.folders} />
258
+ )
259
+ })}
191
260
  {viewGraph.files.map((file) => {
192
261
  const placed = viewLayout.files[file.id]
193
262
  if (!placed) return null
@@ -196,6 +265,21 @@ export function World({
196
265
  const isPlanned = planned.has(file.id) || deleted.has(file.id)
197
266
  const changeKind = fileChangeKind(file.id, planned, created, deleted)
198
267
  const naming = file.id === namingId
268
+ const aimed = file.id === aimedRelation?.flyTo
269
+ const detailed =
270
+ selected || isRelated || isPlanned || naming || aimed || Boolean(changeKind)
271
+ if (lod && !lod.files.has(file.id)) return null
272
+ const dimmed =
273
+ hasFocus &&
274
+ !selected &&
275
+ !isRelated &&
276
+ !changeKind &&
277
+ !patchLinked.has(file.id) &&
278
+ !folderFileIds.has(file.id)
279
+ if (lod && !detailed && !lod.labels.has(file.id)) {
280
+ distantFiles.push({ file, placed, dimmed })
281
+ return null
282
+ }
199
283
  return (
200
284
  <FileBlock
201
285
  key={file.id}
@@ -206,17 +290,11 @@ export function World({
206
290
  planned={isPlanned}
207
291
  changeKind={changeKind}
208
292
  added={created.has(file.id) || file.userCreated}
209
- aimed={file.id === aimedRelation?.flyTo}
210
- dimmed={
211
- hasFocus &&
212
- !selected &&
213
- !isRelated &&
214
- !changeKind &&
215
- !patchLinked.has(file.id) &&
216
- !folderFileIds.has(file.id)
217
- }
293
+ aimed={aimed}
294
+ dimmed={dimmed}
218
295
  naming={naming}
219
296
  mapMode={mapping}
297
+ labelVisible={!lod || lod.labels.has(file.id) || naming}
220
298
  onCommitName={
221
299
  naming && onCommitName
222
300
  ? (name) => {
@@ -230,6 +308,7 @@ export function World({
230
308
  />
231
309
  )
232
310
  })}
311
+ <DistantFileBlocks items={distantFiles} />
233
312
  {Object.values(ghosts).map((placed) => {
234
313
  const file: FileNode = {
235
314
  id: placed.id,
@@ -278,6 +357,17 @@ export function World({
278
357
  onWalkPosition={onWalkPosition}
279
358
  flyTo={flyTo}
280
359
  />
360
+ {!mapping && (
361
+ <WalkLodTracker
362
+ files={layout.files}
363
+ folders={layout.folders}
364
+ bridges={layout.bridges}
365
+ keepFileIds={keepFileIds}
366
+ keepFolderPaths={keepFolderPaths}
367
+ origin={landAt}
368
+ onChange={setWalkLod}
369
+ />
370
+ )}
281
371
  {onPlaceBlock && (
282
372
  <BlockPlacer
283
373
  enabled={!mapping && !placing}