@jkwd/inbase 0.1.12 → 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,11 @@
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'
8
9
 
9
10
  export type MapBlueprintMenu = {
10
11
  x: number
@@ -38,7 +39,10 @@ export function MapView({
38
39
  onBlueprintMenu,
39
40
  }: MapViewProps) {
40
41
  const size = useThree((state) => state.size)
41
- 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)
42
46
  const bounds = useMemo(() => worldBounds(layout), [layout])
43
47
  const drag = useRef({ x: 0, y: 0, moved: false, active: false })
44
48
  const sized = size.width > 16 && size.height > 16
@@ -305,44 +309,121 @@ export function MapView({
305
309
  maxZoom={Math.max(fitZoom * 10, 20)}
306
310
  />
307
311
  )}
308
- {enabled &&
309
- Object.values(layout.folders).map((folder) => (
310
- <Html
311
- key={folder.path}
312
- position={[folder.x, 14, folder.z + 1.35]}
313
- center
314
- zIndexRange={[80, 50]}
315
- style={{ pointerEvents: 'none' }}
316
- >
317
- <div
318
- className={[
319
- 'map-folder-label',
320
- highlightedFolders?.[folder.path]
321
- ? `map-folder-label-${highlightedFolders[folder.path]}`
322
- : folder.added
323
- ? 'map-folder-label-added'
324
- : selectedFolder === folder.path
325
- ? 'map-folder-label-selected'
326
- : '',
327
- ]
328
- .filter(Boolean)
329
- .join(' ')}
330
- >
331
- <span className="map-folder-name">
332
- {folderKindLabel(
333
- folder.name,
334
- highlightedFolders?.[folder.path] ?? null,
335
- folder.added ?? false,
336
- )}
337
- </span>
338
- </div>
339
- </Html>
340
- ))}
312
+ {enabled && (
313
+ <MapFolderLabels
314
+ folders={layout.folders}
315
+ highlightedFolders={highlightedFolders}
316
+ selectedFolder={selectedFolder}
317
+ />
318
+ )}
341
319
  {enabled && marker && <LandMarker marker={marker} />}
342
320
  </>
343
321
  )
344
322
  }
345
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
+
346
427
  function folderKindLabel(
347
428
  name: string,
348
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,5 +1,7 @@
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'
@@ -8,6 +10,8 @@ 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,
@@ -152,6 +156,59 @@ export function World({
152
156
  deleted,
153
157
  viewLayout.folders,
154
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
+ }[] = []
155
212
 
156
213
  return (
157
214
  <>
@@ -178,21 +235,28 @@ export function World({
178
235
  }
179
236
  />
180
237
 
181
- {Object.values(viewLayout.folders).map((folder) => (
182
- <FolderArea
183
- key={folder.path}
184
- folder={folder}
185
- naming={folder.path === namingIslandId}
186
- selected={folder.path === selectedFolder}
187
- mapMode={mapping}
188
- highlightKind={
189
- mapping ? highlightedFolders[folder.path] ?? null : null
190
- }
191
- />
192
- ))}
193
- {viewLayout.bridges.map((bridge) => (
194
- <Bridge key={bridge.id} bridge={bridge} folders={viewLayout.folders} />
195
- ))}
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
+ })}
196
260
  {viewGraph.files.map((file) => {
197
261
  const placed = viewLayout.files[file.id]
198
262
  if (!placed) return null
@@ -201,6 +265,21 @@ export function World({
201
265
  const isPlanned = planned.has(file.id) || deleted.has(file.id)
202
266
  const changeKind = fileChangeKind(file.id, planned, created, deleted)
203
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
+ }
204
283
  return (
205
284
  <FileBlock
206
285
  key={file.id}
@@ -211,17 +290,11 @@ export function World({
211
290
  planned={isPlanned}
212
291
  changeKind={changeKind}
213
292
  added={created.has(file.id) || file.userCreated}
214
- aimed={file.id === aimedRelation?.flyTo}
215
- dimmed={
216
- hasFocus &&
217
- !selected &&
218
- !isRelated &&
219
- !changeKind &&
220
- !patchLinked.has(file.id) &&
221
- !folderFileIds.has(file.id)
222
- }
293
+ aimed={aimed}
294
+ dimmed={dimmed}
223
295
  naming={naming}
224
296
  mapMode={mapping}
297
+ labelVisible={!lod || lod.labels.has(file.id) || naming}
225
298
  onCommitName={
226
299
  naming && onCommitName
227
300
  ? (name) => {
@@ -235,6 +308,7 @@ export function World({
235
308
  />
236
309
  )
237
310
  })}
311
+ <DistantFileBlocks items={distantFiles} />
238
312
  {Object.values(ghosts).map((placed) => {
239
313
  const file: FileNode = {
240
314
  id: placed.id,
@@ -283,6 +357,17 @@ export function World({
283
357
  onWalkPosition={onWalkPosition}
284
358
  flyTo={flyTo}
285
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
+ )}
286
371
  {onPlaceBlock && (
287
372
  <BlockPlacer
288
373
  enabled={!mapping && !placing}