@jkwd/inbase 0.1.5 → 0.1.7

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,6 +1,13 @@
1
1
  import { Suspense } from 'react'
2
2
  import { Html, Text } from '@react-three/drei'
3
- import { CHANGE_HIGHLIGHT, folderFloorColor, MAP_SELECTION, type ChangeKind } from '../theme'
3
+ import {
4
+ CHANGE_HIGHLIGHT,
5
+ CONFIG,
6
+ folderAisleColor,
7
+ folderFloorColor,
8
+ MAP_SELECTION,
9
+ type ChangeKind,
10
+ } from '../theme'
4
11
  import type { PlacedFolder } from '../types'
5
12
  import { MapSelectBorder } from './MapSelectBorder'
6
13
 
@@ -30,6 +37,11 @@ export function FolderArea({
30
37
  : addedOnly
31
38
  ? '#16323f'
32
39
  : folderFloorColor(folder.path)
40
+ const aisle = highlight
41
+ ? highlight.aisle
42
+ : addedOnly
43
+ ? '#23485a'
44
+ : folderAisleColor(folder.path)
33
45
  const label =
34
46
  highlightKind === 'remove'
35
47
  ? `- ${folder.name}`
@@ -62,22 +74,16 @@ export function FolderArea({
62
74
  />
63
75
  )}
64
76
  <mesh position={[0, 0.02, 0]} rotation={[-Math.PI / 2, 0, 0]}>
65
- <planeGeometry args={[2.2, folder.depth - 1.5]} />
66
- <meshBasicMaterial
67
- color={highlight ? highlight.aisle : addedOnly ? '#23485a' : '#2a3340'}
68
- />
77
+ <planeGeometry args={[CONFIG.bridgeWidth, folder.depth]} />
78
+ <meshBasicMaterial color={aisle} />
69
79
  </mesh>
70
80
  {folder.width > 28 && (
71
81
  <mesh
72
- position={[0, 0.02, folder.depth / 2 - 1.3]}
82
+ position={[0, 0.02, folder.depth / 2 - CONFIG.bridgeWidth / 2]}
73
83
  rotation={[-Math.PI / 2, 0, 0]}
74
84
  >
75
- <planeGeometry args={[folder.width - 2.4, 2.4]} />
76
- <meshBasicMaterial
77
- color={
78
- highlight ? highlight.aisle : addedOnly ? '#23485a' : '#2a3340'
79
- }
80
- />
85
+ <planeGeometry args={[folder.width, CONFIG.bridgeWidth]} />
86
+ <meshBasicMaterial color={aisle} />
81
87
  </mesh>
82
88
  )}
83
89
  <Suspense fallback={null}>
@@ -30,7 +30,7 @@ export function MapView({
30
30
  onTravelTo,
31
31
  }: MapViewProps) {
32
32
  const size = useThree((state) => state.size)
33
- const { camera, gl, scene } = useThree()
33
+ const { camera, gl, invalidate, scene } = useThree()
34
34
  const bounds = useMemo(() => worldBounds(layout), [layout])
35
35
  const drag = useRef({ x: 0, y: 0, moved: false, active: false })
36
36
  const sized = size.width > 16 && size.height > 16
@@ -49,7 +49,7 @@ export function MapView({
49
49
  // Ortho up is -Z, so +Z is down the screen. Shift the view so the map sits
50
50
  // above the bottom HUD instead of centering under it.
51
51
  const cz = bounds.cz + hudReserve / 2 / zoom
52
- const target = useMemo(() => [bounds.cx, 0, cz] as [number, number, number], [bounds.cx, cz])
52
+ const controlsRef = useRef<{ target: THREE.Vector3; update: () => void }>(null)
53
53
 
54
54
  useLayoutEffect(() => {
55
55
  if (!enabled || !(camera instanceof THREE.OrthographicCamera)) return
@@ -60,7 +60,12 @@ export function MapView({
60
60
  camera.far = 2000
61
61
  camera.zoom = zoom
62
62
  camera.updateProjectionMatrix()
63
- }, [bounds.cx, camera, cz, enabled, zoom])
63
+ const controls = controlsRef.current
64
+ if (controls) {
65
+ controls.target.set(bounds.cx, 0, cz)
66
+ controls.update()
67
+ }
68
+ }, [bounds.cx, camera, cz, enabled, sized, zoom])
64
69
 
65
70
  useEffect(() => {
66
71
  if (!enabled) return
@@ -170,21 +175,79 @@ export function MapView({
170
175
  if (event.ctrlKey) event.preventDefault()
171
176
  }
172
177
 
178
+ const ground = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
179
+ const ndc = new THREE.Vector2()
180
+ const before = new THREE.Vector3()
181
+ const after = new THREE.Vector3()
182
+ const raycaster = new THREE.Raycaster()
183
+ const minZoom = Math.max(fitZoom * 0.35, 0.05)
184
+ const maxZoom = Math.max(fitZoom * 10, 20)
185
+ const zoomScale = Math.pow(0.95, 1.15)
186
+
187
+ const worldUnderCursor = (clientX: number, clientY: number, target: THREE.Vector3) => {
188
+ const rect = element.getBoundingClientRect()
189
+ if (rect.width < 2 || rect.height < 2) return false
190
+ ndc.set(
191
+ ((clientX - rect.left) / rect.width) * 2 - 1,
192
+ -((clientY - rect.top) / rect.height) * 2 + 1,
193
+ )
194
+ raycaster.setFromCamera(ndc, camera)
195
+ return Boolean(raycaster.ray.intersectPlane(ground, target))
196
+ }
197
+
198
+ const zoomAtCursor = (clientX: number, clientY: number, dollyScale: number) => {
199
+ if (!(camera instanceof THREE.OrthographicCamera)) return
200
+ if (!worldUnderCursor(clientX, clientY, before)) return
201
+ const nextZoom = Math.min(maxZoom, Math.max(minZoom, camera.zoom / dollyScale))
202
+ if (nextZoom === camera.zoom) return
203
+ camera.zoom = nextZoom
204
+ camera.updateProjectionMatrix()
205
+ if (!worldUnderCursor(clientX, clientY, after)) return
206
+ const dx = before.x - after.x
207
+ const dz = before.z - after.z
208
+ camera.position.x += dx
209
+ camera.position.z += dz
210
+ const controls = controlsRef.current
211
+ if (controls) {
212
+ controls.target.x += dx
213
+ controls.target.z += dz
214
+ controls.update()
215
+ }
216
+ invalidate()
217
+ }
218
+
219
+ const onWheel = (event: WheelEvent) => {
220
+ const overlay = document.elementFromPoint(event.clientX, event.clientY)
221
+ if (overlay?.closest('.hud-thumbnail')) {
222
+ event.preventDefault()
223
+ event.stopImmediatePropagation()
224
+ return
225
+ }
226
+ event.preventDefault()
227
+ event.stopImmediatePropagation()
228
+ if (event.deltaY < 0) zoomAtCursor(event.clientX, event.clientY, zoomScale)
229
+ else if (event.deltaY > 0) zoomAtCursor(event.clientX, event.clientY, 1 / zoomScale)
230
+ }
231
+
173
232
  element.addEventListener('pointerdown', onDown)
174
233
  window.addEventListener('pointermove', onMove)
175
234
  window.addEventListener('pointerup', onUp)
176
235
  element.addEventListener('contextmenu', onContextMenu)
236
+ element.addEventListener('wheel', onWheel, { capture: true, passive: false })
177
237
  return () => {
178
238
  element.style.cursor = ''
179
239
  element.removeEventListener('pointerdown', onDown)
180
240
  window.removeEventListener('pointermove', onMove)
181
241
  window.removeEventListener('pointerup', onUp)
182
242
  element.removeEventListener('contextmenu', onContextMenu)
243
+ element.removeEventListener('wheel', onWheel, { capture: true })
183
244
  }
184
245
  }, [
185
246
  camera,
186
247
  enabled,
248
+ fitZoom,
187
249
  gl.domElement,
250
+ invalidate,
188
251
  layout,
189
252
  onLand,
190
253
  onSelect,
@@ -196,25 +259,19 @@ export function MapView({
196
259
  return (
197
260
  <>
198
261
  {enabled && (
199
- <OrthographicCamera
200
- makeDefault
201
- position={[bounds.cx, 120, cz]}
202
- zoom={zoom}
203
- near={1}
204
- far={2000}
205
- up={[0, 0, -1]}
206
- />
262
+ <OrthographicCamera makeDefault near={1} far={2000} up={[0, 0, -1]} />
207
263
  )}
208
264
  {enabled && sized && (
209
265
  <MapControls
266
+ ref={controlsRef}
210
267
  enableRotate={false}
211
268
  enableDamping
212
269
  dampingFactor={0.12}
213
270
  screenSpacePanning
271
+ zoomToCursor
214
272
  zoomSpeed={1.15}
215
273
  minZoom={Math.max(fitZoom * 0.35, 0.05)}
216
274
  maxZoom={Math.max(fitZoom * 10, 20)}
217
- target={target}
218
275
  />
219
276
  )}
220
277
  {enabled &&
@@ -226,7 +226,15 @@ export function Player({
226
226
  makeDefault
227
227
  selector=".stage canvas"
228
228
  enabled={steering && lockEnabled}
229
- onLock={() => onLockedChange(true)}
229
+ onLock={() => {
230
+ if (!lockEnabled) {
231
+ controlsRef.current?.unlock()
232
+ document.exitPointerLock()
233
+ onLockedChange(false)
234
+ return
235
+ }
236
+ onLockedChange(true)
237
+ }}
230
238
  onUnlock={() => onLockedChange(false)}
231
239
  />
232
240
  </>
@@ -8,6 +8,8 @@ type SelectionControllerProps = {
8
8
  locked: boolean
9
9
  files: Record<string, PlacedFile>
10
10
  onSelect: (fileId: string | null) => void
11
+ onInspect?: (fileId: string) => void
12
+ onAimFile?: (fileId: string | null) => void
11
13
  onAimRelation?: (aim: AimedRelation | null) => void
12
14
  onTravelTo: (fromId: string, toId: string) => void
13
15
  }
@@ -36,35 +38,50 @@ function aimKey(aim: AimedRelation | null) {
36
38
  return aim ? `${aim.from}->${aim.to}:${aim.flyTo}` : ''
37
39
  }
38
40
 
41
+ function aimedFileId(hits: THREE.Intersection[]): string | null {
42
+ const file = hits.find((item) => item.object.userData.fileId)
43
+ return file ? (file.object.userData.fileId as string) : null
44
+ }
45
+
39
46
  export function SelectionController({
40
47
  locked,
41
48
  files,
42
49
  onSelect,
50
+ onInspect,
51
+ onAimFile,
43
52
  onAimRelation,
44
53
  onTravelTo,
45
54
  }: SelectionControllerProps) {
46
55
  const { camera, scene } = useThree()
47
56
  const lastAim = useRef('')
57
+ const lastAimedFile = useRef<string | null>(null)
48
58
 
49
59
  useFrame(() => {
50
- if (!onAimRelation) return
60
+ if (!onAimRelation && !onAimFile) return
51
61
  if (!locked) {
52
62
  if (lastAim.current) {
53
63
  lastAim.current = ''
54
- onAimRelation(null)
64
+ onAimRelation?.(null)
65
+ }
66
+ if (lastAimedFile.current) {
67
+ lastAimedFile.current = null
68
+ onAimFile?.(null)
55
69
  }
56
70
  return
57
71
  }
58
72
  raycaster.setFromCamera(ndc, camera)
59
- const next = aimedRelation(
60
- raycaster.intersectObjects(scene.children, true),
61
- camera.position,
62
- files,
63
- )
73
+ const hits = raycaster.intersectObjects(scene.children, true)
74
+ const next = aimedRelation(hits, camera.position, files)
64
75
  const key = aimKey(next)
65
- if (key === lastAim.current) return
66
- lastAim.current = key
67
- onAimRelation(next)
76
+ if (onAimRelation && key !== lastAim.current) {
77
+ lastAim.current = key
78
+ onAimRelation(next)
79
+ }
80
+ const fileId = aimedFileId(hits)
81
+ if (onAimFile && fileId !== lastAimedFile.current) {
82
+ lastAimedFile.current = fileId
83
+ onAimFile(fileId)
84
+ }
68
85
  })
69
86
 
70
87
  useEffect(() => {
@@ -86,12 +103,18 @@ export function SelectionController({
86
103
  }, 280)
87
104
  return
88
105
  }
89
- const file = hits.find((item) => item.object.userData.fileId)
90
- onSelect(file ? (file.object.userData.fileId as string) : null)
106
+ const fileId = aimedFileId(hits)
107
+ onSelect(fileId)
91
108
  }
92
109
 
93
110
  const onDblClick = () => {
94
111
  if (travelTimer) window.clearTimeout(travelTimer)
112
+ if (!locked) return
113
+ raycaster.setFromCamera(ndc, camera)
114
+ const fileId = aimedFileId(raycaster.intersectObjects(scene.children, true))
115
+ if (!fileId) return
116
+ onSelect(fileId)
117
+ onInspect?.(fileId)
95
118
  }
96
119
 
97
120
  window.addEventListener('click', onClick)
@@ -101,7 +124,7 @@ export function SelectionController({
101
124
  window.removeEventListener('click', onClick)
102
125
  window.removeEventListener('dblclick', onDblClick)
103
126
  }
104
- }, [camera, files, locked, onSelect, onTravelTo, scene])
127
+ }, [camera, files, locked, onInspect, onSelect, onTravelTo, scene])
105
128
 
106
129
  return null
107
130
  }