@jkwd/inbase 0.1.21 → 0.1.22

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.
Files changed (58) hide show
  1. package/README.md +13 -7
  2. package/apps/explorer/package.json +1 -0
  3. package/apps/explorer/scripts/explain-store.d.ts +135 -0
  4. package/apps/explorer/scripts/explain-store.mjs +666 -0
  5. package/apps/explorer/scripts/patch-lib.mjs +4 -0
  6. package/apps/explorer/scripts/scan-target.mjs +22 -5
  7. package/apps/explorer/scripts/session-store.d.ts +86 -11
  8. package/apps/explorer/scripts/session-store.mjs +371 -58
  9. package/apps/explorer/scripts/target-config.d.ts +38 -3
  10. package/apps/explorer/scripts/target-config.mjs +147 -3
  11. package/apps/explorer/src/App.tsx +1073 -158
  12. package/apps/explorer/src/agentIntent.ts +61 -7
  13. package/apps/explorer/src/codebase.ts +1 -1
  14. package/apps/explorer/src/devTargets.ts +66 -0
  15. package/apps/explorer/src/explain.ts +312 -0
  16. package/apps/explorer/src/index.css +874 -222
  17. package/apps/explorer/src/layout.ts +55 -0
  18. package/apps/explorer/src/scene/DistantFileBlocks.tsx +4 -2
  19. package/apps/explorer/src/scene/FileBlock.tsx +95 -72
  20. package/apps/explorer/src/scene/FolderArea.tsx +55 -32
  21. package/apps/explorer/src/scene/MapView.tsx +506 -33
  22. package/apps/explorer/src/scene/RelationLines.tsx +7 -0
  23. package/apps/explorer/src/scene/World.tsx +146 -32
  24. package/apps/explorer/src/speech.ts +228 -0
  25. package/apps/explorer/src/theme.ts +29 -0
  26. package/apps/explorer/src/types.ts +98 -8
  27. package/apps/explorer/src/ui/CanvasErrorBoundary.tsx +1 -1
  28. package/apps/explorer/src/ui/ExplainAskCard.tsx +142 -0
  29. package/apps/explorer/src/ui/ExplainHud.tsx +524 -0
  30. package/apps/explorer/src/ui/ExplainInfoPanel.tsx +135 -0
  31. package/apps/explorer/src/ui/ExplainPointer.tsx +73 -0
  32. package/apps/explorer/src/ui/EyeIcon.tsx +38 -1
  33. package/apps/explorer/src/ui/HUD.tsx +1067 -795
  34. package/apps/explorer/src/ui/NameInput.tsx +114 -5
  35. package/apps/explorer/src/userContext.ts +0 -11
  36. package/apps/explorer/src/userCreated.ts +54 -1
  37. package/apps/explorer/vite.config.ts +173 -26
  38. package/bin/inbase.mjs +11 -2
  39. package/bin/project.mjs +1 -1
  40. package/bin/session.mjs +287 -38
  41. package/package.json +4 -1
  42. package/skill/commands/amber.md +23 -0
  43. package/skill/commands/blue.md +13 -0
  44. package/skill/commands/coral.md +23 -0
  45. package/skill/commands/explain.md +77 -0
  46. package/skill/commands/green.md +23 -0
  47. package/skill/commands/inbase.md +7 -5
  48. package/skill/commands/lime.md +23 -0
  49. package/skill/commands/orange.md +23 -0
  50. package/skill/commands/purple.md +23 -0
  51. package/skill/commands/red.md +23 -0
  52. package/skill/commands/skipinbase.md +1 -1
  53. package/skill/commands/violet.md +23 -0
  54. package/skill/commands/yellow.md +23 -0
  55. package/skill/inbase/SKILL.md +124 -76
  56. package/apps/explorer/src/scene/BlockPlacer.tsx +0 -78
  57. package/apps/explorer/src/scene/IslandPlacer.tsx +0 -31
  58. package/apps/explorer/src/scene/SelectionThumbnail.tsx +0 -1069
@@ -1,10 +1,12 @@
1
- import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
2
  import { useFrame, useThree } from '@react-three/fiber'
3
3
  import { Html, MapControls, OrthographicCamera } from '@react-three/drei'
4
4
  import * as THREE from 'three'
5
5
  import { folderAt, worldBounds } from '../layout'
6
6
  import type { ChangeKind } from '../theme'
7
+ import { blueprintPalette } from '../theme'
7
8
  import type { PlacedFolder, WorldLayout } from '../types'
9
+ import { eyeIconMarkup } from '../ui/EyeIcon'
8
10
 
9
11
  export type MapBlueprintMenu = {
10
12
  x: number
@@ -12,6 +14,111 @@ export type MapBlueprintMenu = {
12
14
  folder: string
13
15
  }
14
16
 
17
+ export type MapFileLabel = {
18
+ id: string
19
+ name: string
20
+ x: number
21
+ z: number
22
+ width: number
23
+ depth: number
24
+ outer: 1 | -1
25
+ selected: boolean
26
+ pointed: boolean
27
+ pointedColor?: string
28
+ dimmed?: boolean
29
+ focused?: boolean
30
+ }
31
+
32
+ export type MapFocusBounds = {
33
+ cx: number
34
+ cz: number
35
+ width: number
36
+ depth: number
37
+ }
38
+
39
+ type MapPose = {
40
+ cx: number
41
+ cz: number
42
+ width: number
43
+ depth: number
44
+ }
45
+
46
+ type MapFlight = {
47
+ from: MapPose
48
+ via: MapPose
49
+ to: MapPose
50
+ start: number
51
+ duration: number
52
+ split: number
53
+ }
54
+
55
+ const FOCUS_FLY_IN_MS = 900
56
+ const FOCUS_FLY_OUT_IN_MS = 1500
57
+ const FOCUS_FLY_SPLIT = 0.4
58
+
59
+ function poseOf(bounds: MapPose): MapPose {
60
+ return { cx: bounds.cx, cz: bounds.cz, width: bounds.width, depth: bounds.depth }
61
+ }
62
+
63
+ function lerp(a: number, b: number, t: number) {
64
+ return a + (b - a) * t
65
+ }
66
+
67
+ function lerpPose(a: MapPose, b: MapPose, t: number): MapPose {
68
+ return {
69
+ cx: lerp(a.cx, b.cx, t),
70
+ cz: lerp(a.cz, b.cz, t),
71
+ width: lerp(a.width, b.width, t),
72
+ depth: lerp(a.depth, b.depth, t),
73
+ }
74
+ }
75
+
76
+ function easeInOutCubic(t: number) {
77
+ return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2
78
+ }
79
+
80
+ function flightPose(flight: MapFlight, now: number) {
81
+ const t = Math.min(1, (now - flight.start) / flight.duration)
82
+ const pose =
83
+ flight.split <= 0
84
+ ? lerpPose(flight.from, flight.to, easeInOutCubic(t))
85
+ : t < flight.split
86
+ ? lerpPose(flight.from, flight.via, easeInOutCubic(t / flight.split))
87
+ : lerpPose(
88
+ flight.via,
89
+ flight.to,
90
+ easeInOutCubic((t - flight.split) / (1 - flight.split)),
91
+ )
92
+ return { t, pose }
93
+ }
94
+
95
+ function applyMapPose(
96
+ camera: THREE.OrthographicCamera,
97
+ pose: MapPose,
98
+ viewWidth: number,
99
+ viewHeight: number,
100
+ hudReserve: number,
101
+ controls: { target: THREE.Vector3; update: () => void } | null,
102
+ ) {
103
+ const fitZoom = Math.min(
104
+ viewWidth / Math.max(pose.width + 36, 1),
105
+ viewHeight / Math.max(pose.depth + 36, 1),
106
+ )
107
+ const zoom = Math.max(fitZoom * 0.92, 0.08)
108
+ const cz = pose.cz + hudReserve / 2 / zoom
109
+ camera.up.set(0, 0, -1)
110
+ camera.position.set(pose.cx, 120, cz)
111
+ camera.lookAt(pose.cx, 0, cz)
112
+ camera.near = 1
113
+ camera.far = 2000
114
+ camera.zoom = zoom
115
+ camera.updateProjectionMatrix()
116
+ if (controls) {
117
+ controls.target.set(pose.cx, 0, cz)
118
+ controls.update()
119
+ }
120
+ }
121
+
15
122
  type MapViewProps = {
16
123
  layout: WorldLayout
17
124
  enabled: boolean
@@ -19,9 +126,20 @@ type MapViewProps = {
19
126
  highlightedFolders?: Partial<Record<string, ChangeKind>>
20
127
  selectedFolder?: string | null
21
128
  namingFolderPath?: string | null
129
+ namingFileId?: string | null
130
+ pointedFolderPaths?: string[]
131
+ pointedFolderColors?: Record<string, string[]>
132
+ fileLabels?: MapFileLabel[]
133
+ focusBounds?: MapFocusBounds | null
134
+ focusFlightKey?: string | number
135
+ hudReserve?: number
136
+ topReserve?: number
137
+ landEnabled?: boolean
138
+ dimmedFolderPaths?: string[]
22
139
  onLand: (x: number, z: number) => void
23
140
  onSelect: (fileId: string | null) => void
24
141
  onSelectFolder: (folderPath: string | null) => void
142
+ pickingImport?: boolean
25
143
  onBlueprintMenu?: (menu: MapBlueprintMenu) => void
26
144
  }
27
145
 
@@ -32,21 +150,32 @@ export function MapView({
32
150
  highlightedFolders,
33
151
  selectedFolder = null,
34
152
  namingFolderPath = null,
153
+ namingFileId = null,
154
+ pointedFolderPaths = [],
155
+ pointedFolderColors = {},
156
+ fileLabels = [],
157
+ focusBounds = null,
158
+ focusFlightKey = 0,
159
+ hudReserve = 88,
160
+ topReserve = 28,
161
+ landEnabled = true,
162
+ dimmedFolderPaths = [],
35
163
  onLand,
36
164
  onSelect,
37
165
  onSelectFolder,
38
166
  onBlueprintMenu,
167
+ pickingImport = false,
39
168
  }: MapViewProps) {
40
169
  const size = useThree((state) => state.size)
41
170
  const camera = useThree((state) => state.camera)
42
171
  const gl = useThree((state) => state.gl)
43
172
  const invalidate = useThree((state) => state.invalidate)
44
173
  const scene = useThree((state) => state.scene)
45
- const bounds = useMemo(() => worldBounds(layout), [layout])
174
+ const world = useMemo(() => worldBounds(layout), [layout])
175
+ const focusing = Boolean(focusBounds)
176
+ const bounds = focusBounds ?? world
46
177
  const drag = useRef({ x: 0, y: 0, moved: false, active: false })
47
178
  const sized = size.width > 16 && size.height > 16
48
- const hudReserve = 88
49
- const topReserve = 28
50
179
  const viewWidth = Math.max(size.width, 1)
51
180
  const viewHeight = Math.max(size.height - hudReserve - topReserve, 1)
52
181
 
@@ -56,34 +185,115 @@ export function MapView({
56
185
  viewHeight / Math.max(bounds.depth + 36, 1),
57
186
  )
58
187
  : 8
59
- const zoom = Math.max(fitZoom * 0.92, 0.08)
60
- // Ortho up is -Z, so +Z is down the screen. Shift the view so the map sits
61
- // above the bottom HUD instead of centering under it.
62
- const cz = bounds.cz + hudReserve / 2 / zoom
63
188
  const controlsRef = useRef<{ target: THREE.Vector3; update: () => void }>(null)
189
+ const poseRef = useRef<MapPose>(poseOf(bounds))
190
+ const flightRef = useRef<MapFlight | null>(null)
191
+ const flightKeyRef = useRef<number | string | null>(null)
192
+ const focusingRef = useRef(false)
193
+ const [flying, setFlying] = useState(false)
194
+
195
+ const snapTo = (pose: MapPose) => {
196
+ if (!(camera instanceof THREE.OrthographicCamera)) return
197
+ flightRef.current = null
198
+ poseRef.current = pose
199
+ applyMapPose(
200
+ camera,
201
+ pose,
202
+ viewWidth,
203
+ viewHeight,
204
+ hudReserve,
205
+ controlsRef.current,
206
+ )
207
+ setFlying(false)
208
+ invalidate()
209
+ }
64
210
 
65
211
  useLayoutEffect(() => {
66
212
  if (!enabled || !(camera instanceof THREE.OrthographicCamera)) return
67
- camera.up.set(0, 0, -1)
68
- camera.position.set(bounds.cx, 120, cz)
69
- camera.lookAt(bounds.cx, 0, cz)
70
- camera.near = 1
71
- camera.far = 2000
72
- camera.zoom = zoom
73
- camera.updateProjectionMatrix()
74
- const controls = controlsRef.current
75
- if (controls) {
76
- controls.target.set(bounds.cx, 0, cz)
77
- controls.update()
213
+ const target = poseOf(bounds)
214
+ const key = focusing ? focusFlightKey : 'map'
215
+ const wasFocusing = focusingRef.current
216
+ const prevKey = flightKeyRef.current
217
+ focusingRef.current = focusing
218
+ flightKeyRef.current = key
219
+
220
+ if (!sized) {
221
+ snapTo(target)
222
+ return
223
+ }
224
+
225
+ if (focusing && prevKey !== null && prevKey !== key) {
226
+ flightRef.current = {
227
+ from: poseRef.current,
228
+ via: wasFocusing ? poseOf(world) : poseRef.current,
229
+ to: target,
230
+ start: performance.now(),
231
+ duration: wasFocusing ? FOCUS_FLY_OUT_IN_MS : FOCUS_FLY_IN_MS,
232
+ split: wasFocusing ? FOCUS_FLY_SPLIT : 0,
233
+ }
234
+ setFlying(true)
235
+ invalidate()
236
+ return
78
237
  }
79
- }, [bounds.cx, camera, cz, enabled, sized, zoom])
238
+
239
+ if (flightRef.current) return
240
+
241
+ snapTo(target)
242
+ }, [
243
+ bounds.cx,
244
+ bounds.cz,
245
+ bounds.depth,
246
+ bounds.width,
247
+ camera,
248
+ enabled,
249
+ focusFlightKey,
250
+ focusing,
251
+ hudReserve,
252
+ invalidate,
253
+ sized,
254
+ viewHeight,
255
+ viewWidth,
256
+ world.cx,
257
+ world.cz,
258
+ world.depth,
259
+ world.width,
260
+ ])
261
+
262
+ useFrame(() => {
263
+ const flight = flightRef.current
264
+ if (!flight || !enabled || !(camera instanceof THREE.OrthographicCamera)) return
265
+ const { t, pose } = flightPose(flight, performance.now())
266
+ poseRef.current = pose
267
+ applyMapPose(
268
+ camera,
269
+ pose,
270
+ viewWidth,
271
+ viewHeight,
272
+ hudReserve,
273
+ controlsRef.current,
274
+ )
275
+ if (t < 1) return
276
+ flightRef.current = null
277
+ poseRef.current = flight.to
278
+ applyMapPose(
279
+ camera,
280
+ flight.to,
281
+ viewWidth,
282
+ viewHeight,
283
+ hudReserve,
284
+ controlsRef.current,
285
+ )
286
+ setFlying(false)
287
+ })
80
288
 
81
289
  useEffect(() => {
82
290
  if (!enabled) return
83
291
  const element = gl.domElement
84
- element.style.cursor = 'grab'
292
+ const restCursor = pickingImport ? 'crosshair' : 'grab'
293
+ element.style.cursor = restCursor
85
294
 
86
- const isWalkClick = (event: PointerEvent | MouseEvent) => event.altKey
295
+ const isWalkClick = (event: PointerEvent | MouseEvent) =>
296
+ landEnabled && event.altKey
87
297
 
88
298
  const isWalkButton = (event: PointerEvent) => event.button === 0
89
299
 
@@ -144,12 +354,16 @@ export function MapView({
144
354
  }
145
355
 
146
356
  const onUp = (event: PointerEvent) => {
147
- element.style.cursor = 'grab'
357
+ element.style.cursor = restCursor
148
358
  const startedOnCanvas = drag.current.active
149
359
  drag.current.active = false
150
360
  if (!startedOnCanvas || !isWalkButton(event) || drag.current.moved) return
151
361
 
152
- if (isWalkClick(event) && landAtPointer(event.clientX, event.clientY, true)) {
362
+ if (
363
+ !pickingImport &&
364
+ isWalkClick(event) &&
365
+ landAtPointer(event.clientX, event.clientY, true)
366
+ ) {
153
367
  return
154
368
  }
155
369
 
@@ -161,6 +375,8 @@ export function MapView({
161
375
  return
162
376
  }
163
377
 
378
+ if (pickingImport) return
379
+
164
380
  const hit = new THREE.Vector3()
165
381
  const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
166
382
  if (pick.raycaster.ray.intersectPlane(plane, hit)) {
@@ -241,12 +457,6 @@ export function MapView({
241
457
  }
242
458
 
243
459
  const onWheel = (event: WheelEvent) => {
244
- const overlay = document.elementFromPoint(event.clientX, event.clientY)
245
- if (overlay?.closest('.hud-thumbnail')) {
246
- event.preventDefault()
247
- event.stopImmediatePropagation()
248
- return
249
- }
250
460
  event.preventDefault()
251
461
  event.stopImmediatePropagation()
252
462
  if (event.deltaY < 0) zoomAtCursor(event.clientX, event.clientY, zoomScale)
@@ -273,10 +483,12 @@ export function MapView({
273
483
  gl.domElement,
274
484
  invalidate,
275
485
  layout,
486
+ landEnabled,
276
487
  onLand,
277
488
  onBlueprintMenu,
278
489
  onSelect,
279
490
  onSelectFolder,
491
+ pickingImport,
280
492
  scene,
281
493
  selectedFolder,
282
494
  ])
@@ -289,6 +501,7 @@ export function MapView({
289
501
  {enabled && sized && (
290
502
  <MapControls
291
503
  ref={controlsRef}
504
+ enabled={!flying}
292
505
  enableRotate={false}
293
506
  enableDamping
294
507
  dampingFactor={0.12}
@@ -305,8 +518,14 @@ export function MapView({
305
518
  highlightedFolders={highlightedFolders}
306
519
  selectedFolder={selectedFolder}
307
520
  namingFolderPath={namingFolderPath}
521
+ pointedFolderPaths={pointedFolderPaths}
522
+ pointedFolderColors={pointedFolderColors}
523
+ dimmedFolderPaths={dimmedFolderPaths}
308
524
  />
309
525
  )}
526
+ {enabled && (
527
+ <MapFileLabels files={fileLabels} namingFileId={namingFileId} />
528
+ )}
310
529
  {enabled && marker && <LandMarker marker={marker} />}
311
530
  </>
312
531
  )
@@ -315,6 +534,15 @@ export function MapView({
315
534
  const PROJECT = new THREE.Vector3()
316
535
  const MIN_FOLDER_LABEL_PX = 28
317
536
  const FOLDER_ENTRANCE_Z = 1.35
537
+ const MIN_FILE_LABEL_PX = 16
538
+ const FILE_LABEL_HEIGHT = 15
539
+ const FILE_LABEL_GAP = 4
540
+ const MAX_FILE_LABELS = 28
541
+ const FILE_LABEL_CHAR_W = 7.2
542
+ const FILE_LABEL_PAD_X = 12
543
+ const FILE_LABEL_MIN_W = 108
544
+ const FILE_LABEL_MAX_W = 220
545
+ const FILE_LABEL_BLOCK_SCALE = 5.2
318
546
 
319
547
  function projectToScreen(
320
548
  x: number,
@@ -336,6 +564,8 @@ function folderLabelClass(
336
564
  folder: PlacedFolder,
337
565
  highlightedFolders: Partial<Record<string, ChangeKind>> | undefined,
338
566
  selectedFolder: string | null,
567
+ pointed: boolean,
568
+ dimmed: boolean,
339
569
  ) {
340
570
  return [
341
571
  'map-folder-label',
@@ -346,6 +576,8 @@ function folderLabelClass(
346
576
  : selectedFolder === folder.path
347
577
  ? 'map-folder-label-selected'
348
578
  : '',
579
+ pointed ? 'map-folder-label-pointed' : '',
580
+ dimmed ? 'map-folder-label-dimmed' : '',
349
581
  ]
350
582
  .filter(Boolean)
351
583
  .join(' ')
@@ -356,17 +588,33 @@ function MapFolderLabels({
356
588
  highlightedFolders,
357
589
  selectedFolder,
358
590
  namingFolderPath,
591
+ pointedFolderPaths,
592
+ pointedFolderColors,
593
+ dimmedFolderPaths,
359
594
  }: {
360
595
  folders: Record<string, PlacedFolder>
361
596
  highlightedFolders?: Partial<Record<string, ChangeKind>>
362
597
  selectedFolder: string | null
363
598
  namingFolderPath: string | null
599
+ pointedFolderPaths: string[]
600
+ pointedFolderColors: Record<string, string[]>
601
+ dimmedFolderPaths: string[]
364
602
  }) {
365
603
  const camera = useThree((state) => state.camera)
366
604
  const gl = useThree((state) => state.gl)
367
605
  const size = useThree((state) => state.size)
368
606
  const layerRef = useRef<HTMLDivElement | null>(null)
369
607
  const items = useMemo(() => Object.values(folders), [folders])
608
+ const pointedKey = pointedFolderPaths.join('\0')
609
+ const pointedFolders = useMemo(
610
+ () => new Set(pointedKey ? pointedKey.split('\0') : []),
611
+ [pointedKey],
612
+ )
613
+ const dimmedKey = dimmedFolderPaths.join('\0')
614
+ const dimmedFolders = useMemo(
615
+ () => new Set(dimmedKey ? dimmedKey.split('\0') : []),
616
+ [dimmedKey],
617
+ )
370
618
 
371
619
  useLayoutEffect(() => {
372
620
  const parent = gl.domElement.parentElement
@@ -376,12 +624,32 @@ function MapFolderLabels({
376
624
  layer.style.cssText =
377
625
  'position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:80;background:transparent;'
378
626
  for (const folder of items) {
627
+ const pointed = pointedFolders.has(folder.path)
628
+ const dimmed = dimmedFolders.has(folder.path)
379
629
  const el = document.createElement('div')
380
- el.className = folderLabelClass(folder, highlightedFolders, selectedFolder)
630
+ el.className = folderLabelClass(
631
+ folder,
632
+ highlightedFolders,
633
+ selectedFolder,
634
+ pointed,
635
+ dimmed,
636
+ )
381
637
  el.style.position = 'absolute'
382
638
  el.style.top = '0'
383
639
  el.style.left = '0'
384
640
  el.style.visibility = 'hidden'
641
+ if (pointed) {
642
+ const colors = pointedFolderColors[folder.path] ?? []
643
+ const hex = colors[colors.length - 1]
644
+ if (hex) el.style.setProperty('--session-color', hex)
645
+ for (const color of colors.length > 0 ? colors : ['#9ad8ff']) {
646
+ const eye = document.createElement('span')
647
+ eye.className = 'map-folder-eye'
648
+ eye.style.color = color
649
+ eye.innerHTML = eyeIconMarkup(13)
650
+ el.appendChild(eye)
651
+ }
652
+ }
385
653
  const name = document.createElement('span')
386
654
  name.className = 'map-folder-name'
387
655
  name.textContent = folderKindLabel(
@@ -389,6 +657,11 @@ function MapFolderLabels({
389
657
  highlightedFolders?.[folder.path] ?? null,
390
658
  folder.added ?? false,
391
659
  )
660
+ if (folder.added && folder.colorHex) {
661
+ const tint = blueprintPalette(folder.colorHex)
662
+ el.style.setProperty('--blueprint-color', tint.color)
663
+ el.style.setProperty('--blueprint-label', tint.label)
664
+ }
392
665
  el.appendChild(name)
393
666
  layer.appendChild(el)
394
667
  }
@@ -398,7 +671,15 @@ function MapFolderLabels({
398
671
  layer.remove()
399
672
  layerRef.current = null
400
673
  }
401
- }, [gl, highlightedFolders, items, selectedFolder])
674
+ }, [
675
+ dimmedFolders,
676
+ gl,
677
+ highlightedFolders,
678
+ items,
679
+ pointedFolderColors,
680
+ pointedFolders,
681
+ selectedFolder,
682
+ ])
402
683
 
403
684
  useFrame(() => {
404
685
  const layer = layerRef.current
@@ -420,9 +701,12 @@ function MapFolderLabels({
420
701
  const x = screen.x
421
702
  const y = screen.y
422
703
  const span = Math.max(folder.width, folder.depth) * zoom
704
+ const dimmed = dimmedFolders.has(folder.path)
423
705
  const force =
424
706
  selectedFolder === folder.path ||
425
- Boolean(highlightedFolders?.[folder.path] || folder.added)
707
+ pointedFolders.has(folder.path) ||
708
+ Boolean(highlightedFolders?.[folder.path] || folder.added) ||
709
+ (dimmedFolders.size > 0 && !dimmed)
426
710
  const onScreen =
427
711
  !screen.behind &&
428
712
  x > -120 &&
@@ -461,6 +745,195 @@ function folderKindLabel(
461
745
  return name
462
746
  }
463
747
 
748
+ function fileLabelClass(file: MapFileLabel) {
749
+ return [
750
+ 'map-file-label',
751
+ file.selected ? 'map-file-label-selected' : '',
752
+ file.pointed ? 'map-file-label-pointed' : '',
753
+ file.focused ? 'map-file-label-focused' : '',
754
+ file.dimmed ? 'map-file-label-dimmed' : '',
755
+ ]
756
+ .filter(Boolean)
757
+ .join(' ')
758
+ }
759
+
760
+ function labelsOverlap(
761
+ left: number,
762
+ top: number,
763
+ right: number,
764
+ bottom: number,
765
+ placed: { l: number; t: number; r: number; b: number }[],
766
+ ) {
767
+ for (let i = 0; i < placed.length; i += 1) {
768
+ const box = placed[i]
769
+ if (left < box.r && right > box.l && top < box.b && bottom > box.t) return true
770
+ }
771
+ return false
772
+ }
773
+
774
+ function MapFileLabels({
775
+ files,
776
+ namingFileId,
777
+ }: {
778
+ files: MapFileLabel[]
779
+ namingFileId: string | null
780
+ }) {
781
+ const camera = useThree((state) => state.camera)
782
+ const gl = useThree((state) => state.gl)
783
+ const size = useThree((state) => state.size)
784
+ const layerRef = useRef<HTMLDivElement | null>(null)
785
+ const filesRef = useRef(files)
786
+ const namingRef = useRef(namingFileId)
787
+ filesRef.current = files
788
+ namingRef.current = namingFileId
789
+
790
+ useLayoutEffect(() => {
791
+ const parent = gl.domElement.parentElement
792
+ if (!parent) return
793
+ const layer = document.createElement('div')
794
+ layer.className = 'map-file-label-layer'
795
+ layer.style.cssText =
796
+ 'position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:70;background:transparent;'
797
+ parent.appendChild(layer)
798
+ layerRef.current = layer
799
+ return () => {
800
+ layer.remove()
801
+ layerRef.current = null
802
+ }
803
+ }, [gl])
804
+
805
+ useFrame(() => {
806
+ const layer = layerRef.current
807
+ const items = filesRef.current
808
+ if (!layer) return
809
+ const zoom = 'zoom' in camera ? Number(camera.zoom) : 1
810
+ const cx = size.width * 0.5
811
+ const cy = size.height * 0.5
812
+ const candidates: {
813
+ file: MapFileLabel
814
+ x: number
815
+ y: number
816
+ w: number
817
+ outer: 1 | -1 | 0
818
+ rank: number
819
+ dist: number
820
+ }[] = []
821
+
822
+ for (let i = 0; i < items.length; i += 1) {
823
+ const file = items[i]
824
+ if (file.id === namingRef.current) continue
825
+ const block = Math.min(file.width, file.depth) * zoom
826
+ const force = file.selected || file.pointed || file.focused
827
+ if (!force && block < MIN_FILE_LABEL_PX) continue
828
+ const screen = projectToScreen(
829
+ file.x,
830
+ 0,
831
+ file.z,
832
+ camera,
833
+ size.width,
834
+ size.height,
835
+ )
836
+ if (
837
+ screen.behind ||
838
+ screen.x < -80 ||
839
+ screen.x > size.width + 80 ||
840
+ screen.y < -40 ||
841
+ screen.y > size.height + 40
842
+ ) {
843
+ continue
844
+ }
845
+ const maxWidth = Math.max(
846
+ FILE_LABEL_MIN_W,
847
+ Math.min(FILE_LABEL_MAX_W, block * FILE_LABEL_BLOCK_SCALE),
848
+ )
849
+ const width = Math.min(
850
+ maxWidth,
851
+ file.name.length * FILE_LABEL_CHAR_W + FILE_LABEL_PAD_X,
852
+ )
853
+ const onBlock = block >= 48 && width <= block * 0.9
854
+ const edgeX = onBlock
855
+ ? screen.x
856
+ : screen.x + file.outer * ((file.width * zoom) / 2 + FILE_LABEL_GAP)
857
+ candidates.push({
858
+ file,
859
+ x: edgeX,
860
+ y: screen.y,
861
+ w: width,
862
+ outer: onBlock ? 0 : file.outer,
863
+ rank: file.selected ? 0 : file.pointed || file.focused ? 1 : 2,
864
+ dist: Math.hypot(screen.x - cx, screen.y - cy),
865
+ })
866
+ }
867
+
868
+ candidates.sort((a, b) => a.rank - b.rank || a.dist - b.dist)
869
+
870
+ const placed: { l: number; t: number; r: number; b: number }[] = []
871
+ const visible: typeof candidates = []
872
+ for (let i = 0; i < candidates.length && visible.length < MAX_FILE_LABELS; i += 1) {
873
+ const next = candidates[i]
874
+ const left =
875
+ next.outer === 1 ? next.x : next.outer === -1 ? next.x - next.w : next.x - next.w / 2
876
+ const right =
877
+ next.outer === 1 ? next.x + next.w : next.outer === -1 ? next.x : next.x + next.w / 2
878
+ const top = next.y - FILE_LABEL_HEIGHT / 2
879
+ const bottom = next.y + FILE_LABEL_HEIGHT / 2
880
+ if (labelsOverlap(left, top - 2, right, bottom + 2, placed)) continue
881
+ placed.push({ l: left, t: top, r: right, b: bottom })
882
+ visible.push(next)
883
+ }
884
+
885
+ while (layer.children.length < visible.length) {
886
+ const el = document.createElement('div')
887
+ el.className = 'map-file-label'
888
+ el.style.position = 'absolute'
889
+ el.style.top = '0'
890
+ el.style.left = '0'
891
+ el.style.visibility = 'hidden'
892
+ const name = document.createElement('span')
893
+ name.className = 'map-file-name'
894
+ el.appendChild(name)
895
+ layer.appendChild(el)
896
+ }
897
+
898
+ const nodes = layer.children
899
+ for (let i = 0; i < nodes.length; i += 1) {
900
+ const el = nodes[i] as HTMLElement
901
+ const next = visible[i]
902
+ if (!next) {
903
+ if (el.style.visibility !== 'hidden') el.style.visibility = 'hidden'
904
+ continue
905
+ }
906
+ const name = el.firstElementChild as HTMLElement | null
907
+ const className = fileLabelClass(next.file)
908
+ if (el.className !== className) el.className = className
909
+ if (el.dataset.id !== next.file.id) {
910
+ el.dataset.id = next.file.id
911
+ }
912
+ if (next.file.pointed && next.file.pointedColor) {
913
+ el.style.setProperty('--session-color', next.file.pointedColor)
914
+ } else {
915
+ el.style.removeProperty('--session-color')
916
+ }
917
+ if (name && name.textContent !== next.file.name) name.textContent = next.file.name
918
+ el.style.maxWidth = `${Math.round(next.w)}px`
919
+ el.style.textAlign =
920
+ next.outer === 1 ? 'left' : next.outer === -1 ? 'right' : 'center'
921
+ const tx = Math.round(next.x)
922
+ const ty = Math.round(next.y)
923
+ const pos = `${tx},${ty},${next.outer}`
924
+ const origin =
925
+ next.outer === 1 ? '0, -50%' : next.outer === -1 ? '-100%, -50%' : '-50%, -50%'
926
+ if (el.dataset.pos !== pos) {
927
+ el.dataset.pos = pos
928
+ el.style.transform = `translate3d(${tx}px, ${ty}px, 0) translate(${origin})`
929
+ }
930
+ if (el.style.visibility !== 'visible') el.style.visibility = 'visible'
931
+ }
932
+ })
933
+
934
+ return null
935
+ }
936
+
464
937
  function LandMarker({ marker }: { marker: [number, number] }) {
465
938
  const camera = useThree((state) => state.camera)
466
939
  const ring = useRef<THREE.Group>(null)