@jkwd/inbase 0.1.0
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 +76 -0
- package/apps/explorer/index.html +12 -0
- package/apps/explorer/package.json +28 -0
- package/apps/explorer/scripts/js-source.mjs +188 -0
- package/apps/explorer/scripts/patch-lib.d.ts +115 -0
- package/apps/explorer/scripts/patch-lib.mjs +472 -0
- package/apps/explorer/scripts/scan-target.mjs +188 -0
- package/apps/explorer/scripts/session-store.d.ts +156 -0
- package/apps/explorer/scripts/session-store.mjs +809 -0
- package/apps/explorer/scripts/target-config.d.ts +8 -0
- package/apps/explorer/scripts/target-config.mjs +42 -0
- package/apps/explorer/src/App.tsx +941 -0
- package/apps/explorer/src/agentIntent.ts +182 -0
- package/apps/explorer/src/codebase.ts +15 -0
- package/apps/explorer/src/index.css +632 -0
- package/apps/explorer/src/layout.ts +508 -0
- package/apps/explorer/src/main.tsx +16 -0
- package/apps/explorer/src/scene/BlockPlacer.tsx +87 -0
- package/apps/explorer/src/scene/Bridge.tsx +290 -0
- package/apps/explorer/src/scene/FileBlock.tsx +256 -0
- package/apps/explorer/src/scene/FolderArea.tsx +96 -0
- package/apps/explorer/src/scene/IslandPlacer.tsx +40 -0
- package/apps/explorer/src/scene/MapSelectBorder.tsx +57 -0
- package/apps/explorer/src/scene/MapView.tsx +247 -0
- package/apps/explorer/src/scene/Player.tsx +245 -0
- package/apps/explorer/src/scene/RelationLines.tsx +223 -0
- package/apps/explorer/src/scene/SelectionController.tsx +89 -0
- package/apps/explorer/src/scene/UserContextTracker.tsx +152 -0
- package/apps/explorer/src/scene/World.tsx +323 -0
- package/apps/explorer/src/theme.ts +111 -0
- package/apps/explorer/src/types.ts +245 -0
- package/apps/explorer/src/ui/CanvasErrorBoundary.tsx +38 -0
- package/apps/explorer/src/ui/HUD.tsx +1090 -0
- package/apps/explorer/src/ui/NameInput.tsx +45 -0
- package/apps/explorer/src/userContext.ts +73 -0
- package/apps/explorer/src/userCreated.ts +354 -0
- package/apps/explorer/src/vite-env.d.ts +1 -0
- package/apps/explorer/tsconfig.json +21 -0
- package/apps/explorer/vite.config.ts +295 -0
- package/bin/inbase.mjs +170 -0
- package/bin/project.mjs +94 -0
- package/bin/session.mjs +241 -0
- package/package.json +63 -0
- package/skill/inbase/SKILL.md +167 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { CONFIG, fileHeight } from './theme'
|
|
2
|
+
import type {
|
|
3
|
+
CodebaseGraph,
|
|
4
|
+
FileNode,
|
|
5
|
+
FolderNode,
|
|
6
|
+
PatchImport,
|
|
7
|
+
PlacedBridge,
|
|
8
|
+
PlacedFile,
|
|
9
|
+
PlacedFolder,
|
|
10
|
+
WorldLayout,
|
|
11
|
+
} from './types'
|
|
12
|
+
|
|
13
|
+
function indexGraph(graph: CodebaseGraph) {
|
|
14
|
+
const folders = new Map(graph.folders.map((folder) => [folder.path, folder]))
|
|
15
|
+
const files = new Map(graph.files.map((file) => [file.id, file]))
|
|
16
|
+
return { folders, files }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function contentWidth() {
|
|
20
|
+
const fileOffset = CONFIG.aisleWidth / 2 + CONFIG.fileWidth / 2 + 0.7
|
|
21
|
+
const fileOuter = fileOffset + CONFIG.fileWidth / 2
|
|
22
|
+
return (fileOuter + 1.1) * 2
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function dockPitch() {
|
|
26
|
+
return CONFIG.bridgeWidth + CONFIG.bridgeDockGap
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function subtreeWidth(
|
|
30
|
+
folder: FolderNode,
|
|
31
|
+
folders: Map<string, FolderNode>,
|
|
32
|
+
): number {
|
|
33
|
+
return Math.max(contentWidth(), childrenSpan(folder, folders), docksWidth(folder))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function childrenSpan(
|
|
37
|
+
folder: FolderNode,
|
|
38
|
+
folders: Map<string, FolderNode>,
|
|
39
|
+
): number {
|
|
40
|
+
if (folder.children.length === 0) return 0
|
|
41
|
+
|
|
42
|
+
return folder.children.reduce((total, childPath, index) => {
|
|
43
|
+
const child = folders.get(childPath)
|
|
44
|
+
if (!child) return total
|
|
45
|
+
const gap = index === 0 ? 0 : CONFIG.siblingGap
|
|
46
|
+
return total + gap + subtreeWidth(child, folders)
|
|
47
|
+
}, 0)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function docksWidth(folder: FolderNode) {
|
|
51
|
+
if (folder.children.length === 0) return 0
|
|
52
|
+
const edge = CONFIG.bridgeWidth / 2 + 1.4
|
|
53
|
+
return (folder.children.length - 1) * dockPitch() + CONFIG.bridgeWidth + edge * 2
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function areaDepthForCount(fileCount: number) {
|
|
57
|
+
const rows = Math.max(1, Math.ceil(fileCount / 2))
|
|
58
|
+
return CONFIG.areaPadding * 2 + rows * CONFIG.fileSpacing
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function areaDepth(folder: FolderNode) {
|
|
62
|
+
return areaDepthForCount(folder.files.length)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function placeFolder(
|
|
66
|
+
folder: FolderNode,
|
|
67
|
+
originX: number,
|
|
68
|
+
originZ: number,
|
|
69
|
+
graph: ReturnType<typeof indexGraph>,
|
|
70
|
+
files: Record<string, PlacedFile>,
|
|
71
|
+
folders: Record<string, PlacedFolder>,
|
|
72
|
+
bridges: PlacedBridge[],
|
|
73
|
+
) {
|
|
74
|
+
const depth = areaDepth(folder)
|
|
75
|
+
const childSpans = folder.children.map((childPath) => {
|
|
76
|
+
const child = graph.folders.get(childPath)
|
|
77
|
+
return child ? subtreeWidth(child, graph.folders) : 0
|
|
78
|
+
})
|
|
79
|
+
const span = childrenSpan(folder, graph.folders)
|
|
80
|
+
const width = Math.max(contentWidth(), span, docksWidth(folder))
|
|
81
|
+
|
|
82
|
+
folders[folder.path] = {
|
|
83
|
+
path: folder.path,
|
|
84
|
+
name: folder.name,
|
|
85
|
+
x: originX,
|
|
86
|
+
z: originZ,
|
|
87
|
+
width,
|
|
88
|
+
depth,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
folder.files.forEach((fileId, index) => {
|
|
92
|
+
const file = graph.files.get(fileId)
|
|
93
|
+
if (!file) return
|
|
94
|
+
const height = fileHeight(file.lines)
|
|
95
|
+
const side: 1 | -1 = index % 2 === 0 ? -1 : 1
|
|
96
|
+
const row = Math.floor(index / 2)
|
|
97
|
+
const z = originZ + CONFIG.areaPadding + row * CONFIG.fileSpacing
|
|
98
|
+
const x =
|
|
99
|
+
originX + side * (CONFIG.aisleWidth / 2 + CONFIG.fileWidth / 2 + 0.7)
|
|
100
|
+
|
|
101
|
+
files[fileId] = {
|
|
102
|
+
id: fileId,
|
|
103
|
+
position: [x, height / 2, z],
|
|
104
|
+
size: [CONFIG.fileWidth, height, CONFIG.fileDepth],
|
|
105
|
+
aisleFace: side === -1 ? 1 : -1,
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
if (folder.children.length === 0) return
|
|
110
|
+
|
|
111
|
+
let cursorX = originX - span / 2
|
|
112
|
+
const parentExitZ = originZ + depth
|
|
113
|
+
const childStartZ = parentExitZ + CONFIG.bridgeLength
|
|
114
|
+
|
|
115
|
+
folder.children.forEach((childPath, index) => {
|
|
116
|
+
const child = graph.folders.get(childPath)
|
|
117
|
+
if (!child) return
|
|
118
|
+
const childSpan = childSpans[index]
|
|
119
|
+
const childX = cursorX + childSpan / 2
|
|
120
|
+
cursorX += childSpan + CONFIG.siblingGap
|
|
121
|
+
|
|
122
|
+
bridges.push({
|
|
123
|
+
id: `${folder.path}→${child.path}`,
|
|
124
|
+
label: child.name,
|
|
125
|
+
fromLabel: folder.name,
|
|
126
|
+
points: [
|
|
127
|
+
[childX, parentExitZ - CONFIG.bridgeOverlap],
|
|
128
|
+
[childX, childStartZ + CONFIG.bridgeOverlap],
|
|
129
|
+
],
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
placeFolder(child, childX, childStartZ, graph, files, folders, bridges)
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function standInFront(file: PlacedFile): [number, number] {
|
|
137
|
+
return [
|
|
138
|
+
file.position[0] + file.aisleFace * (file.size[0] / 2 + 2.6),
|
|
139
|
+
file.position[2],
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function relationTravelTarget(
|
|
144
|
+
fromId: string,
|
|
145
|
+
toId: string,
|
|
146
|
+
x: number,
|
|
147
|
+
z: number,
|
|
148
|
+
files: Record<string, PlacedFile>,
|
|
149
|
+
): string {
|
|
150
|
+
const from = files[fromId]
|
|
151
|
+
const to = files[toId]
|
|
152
|
+
if (!from) return toId
|
|
153
|
+
if (!to) return fromId
|
|
154
|
+
const distFrom = Math.hypot(x - from.position[0], z - from.position[2])
|
|
155
|
+
const distTo = Math.hypot(x - to.position[0], z - to.position[2])
|
|
156
|
+
return distFrom <= distTo ? toId : fromId
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function filesImporting(files: FileNode[], id: string) {
|
|
160
|
+
return files.filter((file) => file.imports.includes(id))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function folderOfFile(fileId: string) {
|
|
164
|
+
return fileId.includes('/') ? fileId.split('/').slice(0, -1).join('/') : '.'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function folderParent(folderPath: string) {
|
|
168
|
+
if (!folderPath || folderPath === '.') return null
|
|
169
|
+
return folderPath.includes('/') ? folderPath.split('/').slice(0, -1).join('/') : '.'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function folderName(folderPath: string, rootName: string) {
|
|
173
|
+
if (!folderPath || folderPath === '.') return rootName
|
|
174
|
+
return folderPath.split('/').pop() ?? folderPath
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function existingAncestor(
|
|
178
|
+
folderPath: string,
|
|
179
|
+
folders: Record<string, PlacedFolder>,
|
|
180
|
+
) {
|
|
181
|
+
let current = folderPath
|
|
182
|
+
while (current && current !== '.') {
|
|
183
|
+
if (folders[current]) return current
|
|
184
|
+
current = current.includes('/')
|
|
185
|
+
? current.split('/').slice(0, -1).join('/')
|
|
186
|
+
: '.'
|
|
187
|
+
}
|
|
188
|
+
if (folders['.']) return '.'
|
|
189
|
+
return Object.keys(folders)[0] ?? '.'
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function collectCreateFolders(
|
|
193
|
+
creates: string[],
|
|
194
|
+
existingFolders: Iterable<string>,
|
|
195
|
+
) {
|
|
196
|
+
const known = new Set(existingFolders)
|
|
197
|
+
const created = new Set<string>()
|
|
198
|
+
for (const id of creates) {
|
|
199
|
+
let current = folderOfFile(id)
|
|
200
|
+
while (current && current !== '.') {
|
|
201
|
+
if (!known.has(current)) created.add(current)
|
|
202
|
+
current = folderParent(current) ?? '.'
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return [...created].sort(
|
|
206
|
+
(left, right) =>
|
|
207
|
+
left.split('/').filter(Boolean).length - right.split('/').filter(Boolean).length ||
|
|
208
|
+
left.localeCompare(right),
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function placeFileOnFolder(
|
|
213
|
+
id: string,
|
|
214
|
+
folder: PlacedFolder,
|
|
215
|
+
index: number,
|
|
216
|
+
lines: number,
|
|
217
|
+
): PlacedFile {
|
|
218
|
+
const height = fileHeight(lines)
|
|
219
|
+
const side: 1 | -1 = index % 2 === 0 ? -1 : 1
|
|
220
|
+
const row = Math.floor(index / 2)
|
|
221
|
+
return {
|
|
222
|
+
id,
|
|
223
|
+
position: [
|
|
224
|
+
folder.x + side * (CONFIG.aisleWidth / 2 + CONFIG.fileWidth / 2 + 0.7),
|
|
225
|
+
height / 2,
|
|
226
|
+
folder.z + CONFIG.areaPadding + row * CONFIG.fileSpacing,
|
|
227
|
+
],
|
|
228
|
+
size: [CONFIG.fileWidth, height, CONFIG.fileDepth],
|
|
229
|
+
aisleFace: side === -1 ? 1 : -1,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function ensurePreviewFolder(
|
|
234
|
+
folders: Map<string, FolderNode>,
|
|
235
|
+
folderPath: string,
|
|
236
|
+
rootName: string,
|
|
237
|
+
) {
|
|
238
|
+
if (!folderPath || folders.has(folderPath)) return
|
|
239
|
+
const parent = folderParent(folderPath)
|
|
240
|
+
if (parent) ensurePreviewFolder(folders, parent, rootName)
|
|
241
|
+
folders.set(folderPath, {
|
|
242
|
+
path: folderPath,
|
|
243
|
+
name: folderName(folderPath, rootName),
|
|
244
|
+
parent,
|
|
245
|
+
files: [],
|
|
246
|
+
children: [],
|
|
247
|
+
})
|
|
248
|
+
if (parent) {
|
|
249
|
+
const parentFolder = folders.get(parent)
|
|
250
|
+
if (parentFolder && !parentFolder.children.includes(folderPath)) {
|
|
251
|
+
parentFolder.children.push(folderPath)
|
|
252
|
+
parentFolder.children.sort((left, right) => left.localeCompare(right))
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function withPreviewGraph(
|
|
258
|
+
graph: CodebaseGraph,
|
|
259
|
+
creates: string[],
|
|
260
|
+
createLines: Record<string, number> = {},
|
|
261
|
+
createFolders: string[] = [],
|
|
262
|
+
imports: PatchImport[] = [],
|
|
263
|
+
): CodebaseGraph {
|
|
264
|
+
const files = new Map(
|
|
265
|
+
graph.files.map((file) => [file.id, { ...file, imports: [...file.imports] }]),
|
|
266
|
+
)
|
|
267
|
+
const folders = new Map(
|
|
268
|
+
graph.folders.map((folder) => [
|
|
269
|
+
folder.path,
|
|
270
|
+
{ ...folder, files: [...folder.files], children: [...folder.children] },
|
|
271
|
+
]),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
for (const folderPath of createFolders) {
|
|
275
|
+
ensurePreviewFolder(folders, folderPath, graph.targetName)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const id of creates) {
|
|
279
|
+
const folderPath = folderOfFile(id)
|
|
280
|
+
ensurePreviewFolder(folders, folderPath, graph.targetName)
|
|
281
|
+
if (!files.has(id)) {
|
|
282
|
+
files.set(id, {
|
|
283
|
+
id,
|
|
284
|
+
name: id.split('/').pop() ?? id,
|
|
285
|
+
path: id,
|
|
286
|
+
folder: folderPath,
|
|
287
|
+
lines: createLines[id] ?? 12,
|
|
288
|
+
language: id.split('.').pop()?.toLowerCase() ?? 'txt',
|
|
289
|
+
symbols: [],
|
|
290
|
+
imports: imports.filter((edge) => edge.from === id).map((edge) => edge.to),
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
const folder = folders.get(folderPath)
|
|
294
|
+
if (folder && !folder.files.includes(id)) {
|
|
295
|
+
folder.files.push(id)
|
|
296
|
+
folder.files.sort((left, right) => left.localeCompare(right))
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
...graph,
|
|
302
|
+
files: [...files.values()],
|
|
303
|
+
folders: [...folders.values()],
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function markCreatedFolders(layout: WorldLayout, createFolders: string[]) {
|
|
308
|
+
for (const path of createFolders) {
|
|
309
|
+
const folder = layout.folders[path]
|
|
310
|
+
if (folder) layout.folders[path] = { ...folder, added: true }
|
|
311
|
+
}
|
|
312
|
+
return layout
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export type PreviewOverlay = {
|
|
316
|
+
files: Record<string, PlacedFile>
|
|
317
|
+
folders: Record<string, PlacedFolder>
|
|
318
|
+
bridges: PlacedBridge[]
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function placePreviewCreates(
|
|
322
|
+
graph: CodebaseGraph,
|
|
323
|
+
layout: WorldLayout,
|
|
324
|
+
creates: string[],
|
|
325
|
+
createLines: Record<string, number> = {},
|
|
326
|
+
): PreviewOverlay {
|
|
327
|
+
const extraFiles: Record<string, PlacedFile> = {}
|
|
328
|
+
const extraFolders: Record<string, PlacedFolder> = {}
|
|
329
|
+
const extraBridges: PlacedBridge[] = []
|
|
330
|
+
const allFolders = () => ({ ...layout.folders, ...extraFolders })
|
|
331
|
+
const newFolders = collectCreateFolders(creates, Object.keys(layout.folders))
|
|
332
|
+
const filesByFolder = new Map<string, string[]>()
|
|
333
|
+
|
|
334
|
+
for (const id of creates) {
|
|
335
|
+
const folderPath = folderOfFile(id)
|
|
336
|
+
const list = filesByFolder.get(folderPath) ?? []
|
|
337
|
+
list.push(id)
|
|
338
|
+
filesByFolder.set(folderPath, list)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const folderPath of newFolders) {
|
|
342
|
+
const parentPath = existingAncestor(folderParent(folderPath) ?? '.', allFolders())
|
|
343
|
+
const parent = allFolders()[parentPath]
|
|
344
|
+
if (!parent) continue
|
|
345
|
+
|
|
346
|
+
const fileCount = filesByFolder.get(folderPath)?.length ?? 0
|
|
347
|
+
const width = contentWidth()
|
|
348
|
+
const depth = areaDepthForCount(fileCount)
|
|
349
|
+
const siblings = Object.values(allFolders()).filter(
|
|
350
|
+
(folder) => folderParent(folder.path) === parentPath,
|
|
351
|
+
)
|
|
352
|
+
const x =
|
|
353
|
+
siblings.length === 0
|
|
354
|
+
? parent.x
|
|
355
|
+
: Math.max(...siblings.map((folder) => folder.x + folder.width / 2)) +
|
|
356
|
+
CONFIG.siblingGap +
|
|
357
|
+
width / 2
|
|
358
|
+
const z = parent.z + parent.depth + CONFIG.bridgeLength
|
|
359
|
+
|
|
360
|
+
extraFolders[folderPath] = {
|
|
361
|
+
path: folderPath,
|
|
362
|
+
name: folderName(folderPath, graph.targetName),
|
|
363
|
+
x,
|
|
364
|
+
z,
|
|
365
|
+
width,
|
|
366
|
+
depth,
|
|
367
|
+
added: true,
|
|
368
|
+
}
|
|
369
|
+
extraBridges.push({
|
|
370
|
+
id: `${parentPath}→${folderPath}`,
|
|
371
|
+
label: folderName(folderPath, graph.targetName),
|
|
372
|
+
fromLabel: parent.name,
|
|
373
|
+
points: [
|
|
374
|
+
[x, parent.z + parent.depth - CONFIG.bridgeOverlap],
|
|
375
|
+
[x, z + CONFIG.bridgeOverlap],
|
|
376
|
+
],
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const used = new Map(
|
|
381
|
+
graph.folders.map((folder) => [folder.path, folder.files.length]),
|
|
382
|
+
)
|
|
383
|
+
for (const path of Object.keys(extraFolders)) used.set(path, 0)
|
|
384
|
+
|
|
385
|
+
for (const id of creates) {
|
|
386
|
+
if (layout.files[id] || extraFiles[id]) continue
|
|
387
|
+
const folderPath = folderOfFile(id)
|
|
388
|
+
const folders = allFolders()
|
|
389
|
+
const folder = folders[folderPath] ?? folders[existingAncestor(folderPath, folders)]
|
|
390
|
+
if (!folder) continue
|
|
391
|
+
const index = used.get(folder.path) ?? 0
|
|
392
|
+
used.set(folder.path, index + 1)
|
|
393
|
+
extraFiles[id] = placeFileOnFolder(id, folder, index, createLines[id] ?? 12)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return { files: extraFiles, folders: extraFolders, bridges: extraBridges }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function withPreviewLayout(
|
|
400
|
+
layout: WorldLayout,
|
|
401
|
+
overlay: PreviewOverlay,
|
|
402
|
+
): WorldLayout {
|
|
403
|
+
return {
|
|
404
|
+
...layout,
|
|
405
|
+
files: { ...layout.files, ...overlay.files },
|
|
406
|
+
folders: { ...layout.folders, ...overlay.folders },
|
|
407
|
+
bridges: [...layout.bridges, ...overlay.bridges],
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function layoutWorld(graph: CodebaseGraph): WorldLayout {
|
|
412
|
+
const indexed = indexGraph(graph)
|
|
413
|
+
const root = indexed.folders.get(graph.root)
|
|
414
|
+
if (!root) {
|
|
415
|
+
throw new Error('Codebase graph is missing the root folder')
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const files: Record<string, PlacedFile> = {}
|
|
419
|
+
const folders: Record<string, PlacedFolder> = {}
|
|
420
|
+
const bridges: PlacedBridge[] = []
|
|
421
|
+
|
|
422
|
+
placeFolder(root, 0, 0, indexed, files, folders, bridges)
|
|
423
|
+
|
|
424
|
+
const rootFolder = folders[root.path]
|
|
425
|
+
return {
|
|
426
|
+
files,
|
|
427
|
+
folders,
|
|
428
|
+
bridges,
|
|
429
|
+
spawn: [rootFolder.x, CONFIG.eyeHeight, rootFolder.z + 2.4],
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function folderAt(
|
|
434
|
+
x: number,
|
|
435
|
+
z: number,
|
|
436
|
+
layout: WorldLayout,
|
|
437
|
+
): PlacedFolder | null {
|
|
438
|
+
let current: PlacedFolder | null = null
|
|
439
|
+
for (const folder of Object.values(layout.folders)) {
|
|
440
|
+
const insideX = Math.abs(x - folder.x) <= folder.width / 2
|
|
441
|
+
const insideZ = z >= folder.z && z <= folder.z + folder.depth
|
|
442
|
+
if (insideX && insideZ) current = folder
|
|
443
|
+
}
|
|
444
|
+
return current
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function distanceToSegment(
|
|
448
|
+
px: number,
|
|
449
|
+
pz: number,
|
|
450
|
+
ax: number,
|
|
451
|
+
az: number,
|
|
452
|
+
bx: number,
|
|
453
|
+
bz: number,
|
|
454
|
+
) {
|
|
455
|
+
const dx = bx - ax
|
|
456
|
+
const dz = bz - az
|
|
457
|
+
const lengthSq = dx * dx + dz * dz
|
|
458
|
+
if (lengthSq === 0) return Math.hypot(px - ax, pz - az)
|
|
459
|
+
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSq))
|
|
460
|
+
return Math.hypot(px - (ax + t * dx), pz - (az + t * dz))
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function locationLabel(x: number, z: number, layout: WorldLayout) {
|
|
464
|
+
const folder = folderAt(x, z, layout)
|
|
465
|
+
if (folder) return folder.name
|
|
466
|
+
|
|
467
|
+
for (const bridge of layout.bridges) {
|
|
468
|
+
for (let i = 1; i < bridge.points.length; i += 1) {
|
|
469
|
+
const from = bridge.points[i - 1]
|
|
470
|
+
const to = bridge.points[i]
|
|
471
|
+
const distance = distanceToSegment(x, z, from[0], from[1], to[0], to[1])
|
|
472
|
+
if (distance <= CONFIG.bridgeWidth / 2 + 0.5) {
|
|
473
|
+
return `bridge to ${bridge.label}`
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return 'open ground'
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function worldBounds(layout: WorldLayout) {
|
|
482
|
+
let minX = Infinity
|
|
483
|
+
let maxX = -Infinity
|
|
484
|
+
let minZ = Infinity
|
|
485
|
+
let maxZ = -Infinity
|
|
486
|
+
|
|
487
|
+
for (const folder of Object.values(layout.folders)) {
|
|
488
|
+
minX = Math.min(minX, folder.x - folder.width / 2)
|
|
489
|
+
maxX = Math.max(maxX, folder.x + folder.width / 2)
|
|
490
|
+
minZ = Math.min(minZ, folder.z)
|
|
491
|
+
maxZ = Math.max(maxZ, folder.z + folder.depth)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (!Number.isFinite(minX)) {
|
|
495
|
+
return { minX: -20, maxX: 20, minZ: -20, maxZ: 20, cx: 0, cz: 0, width: 40, depth: 40 }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
minX,
|
|
500
|
+
maxX,
|
|
501
|
+
minZ,
|
|
502
|
+
maxZ,
|
|
503
|
+
cx: (minX + maxX) / 2,
|
|
504
|
+
cz: (minZ + maxZ) / 2,
|
|
505
|
+
width: maxX - minX,
|
|
506
|
+
depth: maxZ - minZ,
|
|
507
|
+
}
|
|
508
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { StrictMode } from 'react'
|
|
2
|
+
import { createRoot } from 'react-dom/client'
|
|
3
|
+
import App from './App'
|
|
4
|
+
import './index.css'
|
|
5
|
+
|
|
6
|
+
const rootElement = document.getElementById('root')
|
|
7
|
+
|
|
8
|
+
if (!rootElement) {
|
|
9
|
+
throw new Error('Root element #root was not found')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
createRoot(rootElement).render(
|
|
13
|
+
<StrictMode>
|
|
14
|
+
<App />
|
|
15
|
+
</StrictMode>,
|
|
16
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { useThree } from '@react-three/fiber'
|
|
3
|
+
import * as THREE from 'three'
|
|
4
|
+
import { folderAt } from '../layout'
|
|
5
|
+
import type { WorldLayout } from '../types'
|
|
6
|
+
|
|
7
|
+
type BlockPlacerProps = {
|
|
8
|
+
enabled: boolean
|
|
9
|
+
layout: WorldLayout
|
|
10
|
+
onPlace: (spot: { x: number; z: number; folder: string }) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ndc = new THREE.Vector2(0, 0)
|
|
14
|
+
const raycaster = new THREE.Raycaster()
|
|
15
|
+
const ground = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
|
|
16
|
+
const hit = new THREE.Vector3()
|
|
17
|
+
const forward = new THREE.Vector3()
|
|
18
|
+
|
|
19
|
+
const PLACE_MIN = 3
|
|
20
|
+
const PLACE_MAX = 22
|
|
21
|
+
|
|
22
|
+
function typingInField(target: EventTarget | null) {
|
|
23
|
+
return (
|
|
24
|
+
target instanceof HTMLElement &&
|
|
25
|
+
(target.tagName === 'TEXTAREA' ||
|
|
26
|
+
target.tagName === 'INPUT' ||
|
|
27
|
+
target.tagName === 'SELECT' ||
|
|
28
|
+
target.isContentEditable)
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function lookPoint(camera: THREE.Camera): { x: number; z: number } {
|
|
33
|
+
raycaster.setFromCamera(ndc, camera)
|
|
34
|
+
const reached = raycaster.ray.intersectPlane(ground, hit)
|
|
35
|
+
camera.getWorldDirection(forward)
|
|
36
|
+
forward.y = 0
|
|
37
|
+
if (forward.lengthSq() === 0) forward.set(0, 0, 1)
|
|
38
|
+
else forward.normalize()
|
|
39
|
+
|
|
40
|
+
let x: number
|
|
41
|
+
let z: number
|
|
42
|
+
if (reached) {
|
|
43
|
+
x = hit.x
|
|
44
|
+
z = hit.z
|
|
45
|
+
} else {
|
|
46
|
+
x = camera.position.x + forward.x * 8
|
|
47
|
+
z = camera.position.z + forward.z * 8
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const dx = x - camera.position.x
|
|
51
|
+
const dz = z - camera.position.z
|
|
52
|
+
const distance = Math.hypot(dx, dz)
|
|
53
|
+
if (distance < 0.001) {
|
|
54
|
+
return {
|
|
55
|
+
x: camera.position.x + forward.x * PLACE_MIN,
|
|
56
|
+
z: camera.position.z + forward.z * PLACE_MIN,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const clamped = Math.min(PLACE_MAX, Math.max(PLACE_MIN, distance))
|
|
60
|
+
const scale = clamped / distance
|
|
61
|
+
return {
|
|
62
|
+
x: camera.position.x + dx * scale,
|
|
63
|
+
z: camera.position.z + dz * scale,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function BlockPlacer({ enabled, layout, onPlace }: BlockPlacerProps) {
|
|
68
|
+
const { camera } = useThree()
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const onKey = (event: KeyboardEvent) => {
|
|
72
|
+
if (!enabled || event.repeat || event.code !== 'Space') return
|
|
73
|
+
if (typingInField(event.target)) return
|
|
74
|
+
event.preventDefault()
|
|
75
|
+
const { x, z } = lookPoint(camera)
|
|
76
|
+
const island =
|
|
77
|
+
folderAt(x, z, layout) ??
|
|
78
|
+
folderAt(camera.position.x, camera.position.z, layout)
|
|
79
|
+
onPlace({ x, z, folder: island?.path ?? '.' })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
window.addEventListener('keydown', onKey)
|
|
83
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
84
|
+
}, [camera, enabled, layout, onPlace])
|
|
85
|
+
|
|
86
|
+
return null
|
|
87
|
+
}
|