@inerrata-corporation/errata 2.0.21 → 2.0.22-dev.4049
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/consolidate-worker.mjs +502 -187
- package/errata.mjs +7106 -5336
- package/generalize-worker.mjs +17489 -0
- package/install.ps1 +1 -1
- package/package.json +2 -1
- package/pass-worker.mjs +3147 -2640
- package/resources/claude-mods/errata-minimap/.claude-plugin/plugin.json +2 -2
- package/resources/claude-mods/errata-minimap/hooks/bookcase.ts +450 -0
- package/resources/claude-mods/errata-minimap/hooks/chains.ts +427 -0
- package/resources/claude-mods/errata-minimap/hooks/graph.ts +349 -0
- package/resources/claude-mods/errata-minimap/hooks/minimap.ts +39 -351
- package/resources/claude-mods/errata-minimap/hooks/register.tsx +440 -148
- package/resources/claude-mods/errata-minimap/hooks/tags.ts +129 -0
- package/resources/claude-mods/errata-minimap/types/index.d.ts +42 -8
- package/resources/context-plugin/.codex-plugin/plugin.json +1 -1
- package/resources/context-plugin/SHA256SUMS +1 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Burst, BurstNode, MapGraph, MapNode, Scope } from '../types'
|
|
2
|
+
import { BINDING_GLYPH, bindingOf } from './bookcase'
|
|
3
|
+
import type { Binding } from './bookcase'
|
|
2
4
|
|
|
3
5
|
export function relPath(path: string, cwd: string): string {
|
|
4
6
|
const p = path.replace(/\\/g, '/')
|
|
@@ -44,7 +46,7 @@ export function scopeOf(id: string, provenance?: string): Scope | undefined {
|
|
|
44
46
|
|
|
45
47
|
type BurstJson = {
|
|
46
48
|
seed?: string
|
|
47
|
-
results?: { id: string; label?: string; name?: string; score?: number; hops?: number; provenance?: string; via?: { edgeType?: string; parentId?: string } }[]
|
|
49
|
+
results?: { id: string; label?: string; name?: string; score?: number; hops?: number; provenance?: string; community?: { id?: unknown; label?: unknown } | null; via?: { edgeType?: string; parentId?: string; forward?: boolean } }[]
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
/** An `errata_burst` result (MCP JSON) → the neighborhood the agent just read. */
|
|
@@ -66,13 +68,14 @@ export function parseAgentBurst(texts: string[], seedHint: string): Burst | null
|
|
|
66
68
|
hops: Math.max(1, r.hops ?? 1),
|
|
67
69
|
parent: r.via?.parentId,
|
|
68
70
|
edge: r.via?.edgeType,
|
|
71
|
+
forward: r.via?.forward,
|
|
72
|
+
...(typeof r.community?.id === 'number' ? { community: { id: r.community.id, label: typeof r.community.label === 'string' ? r.community.label : null } } : {}),
|
|
69
73
|
scope: scopeOf(r.id, r.provenance),
|
|
70
74
|
}))
|
|
71
75
|
return {
|
|
72
76
|
origin: 'agent',
|
|
73
77
|
seed: { id: seedId, label: 'Seed', name: seedHint || seedId },
|
|
74
78
|
nodes,
|
|
75
|
-
extra: [],
|
|
76
79
|
isEnriched: false,
|
|
77
80
|
at: Date.now(),
|
|
78
81
|
}
|
|
@@ -113,7 +116,6 @@ export function parseAgentSearch(texts: string[], query: string): Burst | null {
|
|
|
113
116
|
origin: 'agent',
|
|
114
117
|
seed: { id: `search:${query}`, label: 'Search', name: `“${query}”` },
|
|
115
118
|
nodes,
|
|
116
|
-
extra: [],
|
|
117
119
|
isEnriched: false,
|
|
118
120
|
at: Date.now(),
|
|
119
121
|
}
|
|
@@ -143,7 +145,6 @@ export function parseFileRecall(text: string, file: string): Burst | null {
|
|
|
143
145
|
origin: 'daemon-file',
|
|
144
146
|
seed: { id: `file:${file}`, label: 'File', name: file },
|
|
145
147
|
nodes,
|
|
146
|
-
extra: [],
|
|
147
148
|
isEnriched: false,
|
|
148
149
|
at: Date.now(),
|
|
149
150
|
}
|
|
@@ -164,6 +165,9 @@ export function parsePromptPrime(text: string, handles: HandleTable): Burst | nu
|
|
|
164
165
|
let isNetwork = false
|
|
165
166
|
for (const raw of text.split('\n')) {
|
|
166
167
|
if (raw.startsWith('### ')) isNetwork = raw.includes('From the Errata Network')
|
|
168
|
+
// A prior is a top-level bullet; an indented one annotates it (a RECURRED
|
|
169
|
+
// block's "previously resolved as: … `dprob_…`" names the same prior's past).
|
|
170
|
+
if (/^\s+-/.test(raw)) continue
|
|
167
171
|
const handle = HANDLE.exec(raw)?.[1]
|
|
168
172
|
const entry = handle ? handles[handle] : undefined
|
|
169
173
|
const id = entry?.id ?? RAW_ID.exec(raw)?.[1]
|
|
@@ -186,7 +190,6 @@ export function parsePromptPrime(text: string, handles: HandleTable): Burst | nu
|
|
|
186
190
|
origin: 'daemon-prompt',
|
|
187
191
|
seed: { id: 'prompt', label: 'Prompt', name: 'prompt context' },
|
|
188
192
|
nodes,
|
|
189
|
-
extra: [],
|
|
190
193
|
isEnriched: false,
|
|
191
194
|
at: Date.now(),
|
|
192
195
|
}
|
|
@@ -213,11 +216,12 @@ export function seedNode(texts: string[]): { label: string; name: string } | und
|
|
|
213
216
|
export type JudgmentKind = 'cite' | 'confirm' | 'fix' | 'refute'
|
|
214
217
|
export type Judgment = { kind: JudgmentKind; ref: string }
|
|
215
218
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
/** How a verdict is stamped; its ink comes from the room (bookcase.ts PALETTE). */
|
|
220
|
+
export const JUDGMENT_STYLE: Record<JudgmentKind, { glyph: string; verb: string }> = {
|
|
221
|
+
cite: { glyph: '◎', verb: 'cited' },
|
|
222
|
+
confirm: { glyph: '✓', verb: 'confirmed' },
|
|
223
|
+
fix: { glyph: '✓', verb: 'resolved' },
|
|
224
|
+
refute: { glyph: '✗', verb: 'refuted' },
|
|
221
225
|
}
|
|
222
226
|
|
|
223
227
|
/** A stronger verdict on the same prior wins: refute › confirm/fix › cite. */
|
|
@@ -252,379 +256,63 @@ export function resolveJudgmentRef(ref: string, handles: HandleTable): string |
|
|
|
252
256
|
return handles[ref]?.id ?? (NODE_ID.test(ref) ? ref : null)
|
|
253
257
|
}
|
|
254
258
|
|
|
255
|
-
/** `{ out, in }` of an `errata_neighbors` result → the ids it touches. */
|
|
256
|
-
export function neighborIds(texts: string[]): { id: string; type: string }[] {
|
|
257
|
-
for (const text of texts) {
|
|
258
|
-
try {
|
|
259
|
-
const json = JSON.parse(text) as { out?: { type: string; to?: { id: string } }[]; in?: { type: string; from?: { id: string } }[] }
|
|
260
|
-
if (!json.out && !json.in) continue
|
|
261
|
-
return [
|
|
262
|
-
...(json.out ?? []).flatMap(e => (e.to ? [{ id: e.to.id, type: e.type }] : [])),
|
|
263
|
-
...(json.in ?? []).flatMap(e => (e.from ? [{ id: e.from.id, type: e.type }] : [])),
|
|
264
|
-
]
|
|
265
|
-
} catch {
|
|
266
|
-
continue
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
return []
|
|
270
|
-
}
|
|
271
259
|
|
|
272
260
|
/**
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
261
|
+
* One burst in walk order: the seed first, then each prior right after the
|
|
262
|
+
* one it was reached from, stronger siblings first — the order the shelves
|
|
263
|
+
* read in (chains.ts). `parent` is the index of that node, -1 for the seed.
|
|
276
264
|
*/
|
|
277
265
|
export function layoutBurst(burst: Burst): MapGraph {
|
|
278
|
-
const nodes: MapNode[] = [{ id: burst.seed.id, label: burst.seed.label, name: burst.seed.name, isSeed: true, score: 1, hops: 0,
|
|
266
|
+
const nodes: MapNode[] = [{ id: burst.seed.id, label: burst.seed.label, name: burst.seed.name, isSeed: true, score: 1, hops: 0, parent: -1 }]
|
|
279
267
|
const index = new Map<string, number>([[burst.seed.id, 0]])
|
|
280
|
-
const edges: [number, number, boolean][] = []
|
|
281
|
-
const maxHops = Math.max(1, ...burst.nodes.map(n => n.hops))
|
|
282
|
-
const scores = burst.nodes.map(n => n.score)
|
|
283
|
-
const [lo, hi] = [Math.min(...scores), Math.max(...scores)]
|
|
284
|
-
const norm = (s: number) => (hi > lo ? (s - lo) / (hi - lo) : 1)
|
|
285
|
-
|
|
286
|
-
const sector = new Map<string, [number, number]>([[burst.seed.id, [0, Math.PI * 2]]])
|
|
287
|
-
const byDepth = [...burst.nodes].sort((a, b) => a.hops - b.hops || b.score - a.score)
|
|
288
268
|
const childrenOf = new Map<string, BurstNode[]>()
|
|
289
|
-
for (const n of
|
|
269
|
+
for (const n of [...burst.nodes].sort((a, b) => a.hops - b.hops || b.score - a.score)) {
|
|
290
270
|
const parent = n.parent && (n.parent === burst.seed.id || burst.nodes.some(m => m.id === n.parent)) ? n.parent : burst.seed.id
|
|
291
|
-
|
|
271
|
+
// A walk parent outside the burst re-hangs the node off the seed; the edge it named no longer joins them.
|
|
272
|
+
const hung = parent === n.parent ? n : { ...n, edge: undefined, forward: undefined }
|
|
273
|
+
childrenOf.set(parent, [...(childrenOf.get(parent) ?? []), hung])
|
|
292
274
|
}
|
|
293
|
-
|
|
294
275
|
const place = (parentId: string) => {
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if (index.has(n.id)) return
|
|
300
|
-
const angle = a0 + span * (i + 0.5) - Math.PI / 2
|
|
301
|
-
const ring = n.hops / maxHops
|
|
302
|
-
const r = 0.48 * ring * (0.72 + 0.28 * (1 - norm(n.score)))
|
|
303
|
-
const at = nodes.length
|
|
304
|
-
nodes.push({ id: n.id, label: n.label, name: n.name, isSeed: false, score: n.score, hops: n.hops, scope: n.scope, x: 0.5 + r * Math.cos(angle), y: 0.5 + r * Math.sin(angle) })
|
|
305
|
-
index.set(n.id, at)
|
|
306
|
-
edges.push([index.get(parentId) ?? 0, at, true])
|
|
307
|
-
sector.set(n.id, [a0 + span * i, a0 + span * (i + 1)])
|
|
276
|
+
for (const n of childrenOf.get(parentId) ?? []) {
|
|
277
|
+
if (index.has(n.id)) continue
|
|
278
|
+
index.set(n.id, nodes.length)
|
|
279
|
+
nodes.push({ id: n.id, label: n.label, name: n.name, isSeed: false, score: n.score, hops: n.hops, scope: n.scope, parent: index.get(parentId) ?? 0, edge: n.edge, forward: n.forward, community: n.community })
|
|
308
280
|
place(n.id)
|
|
309
|
-
}
|
|
281
|
+
}
|
|
310
282
|
}
|
|
311
283
|
place(burst.seed.id)
|
|
312
|
-
|
|
313
|
-
const seen = new Set(edges.map(([a, b]) => key(a, b)))
|
|
314
|
-
for (const [s, t] of burst.extra) {
|
|
315
|
-
const a = index.get(s), b = index.get(t)
|
|
316
|
-
if (a === undefined || b === undefined || a === b || seen.has(key(a, b))) continue
|
|
317
|
-
seen.add(key(a, b))
|
|
318
|
-
edges.push([a, b, false])
|
|
319
|
-
}
|
|
320
|
-
return { title: displayName(burst.seed.label, burst.seed.name), seedLabel: burst.seed.label, origin: burst.origin, nodes, edges, at: burst.at }
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function key(a: number, b: number): string {
|
|
324
|
-
return a < b ? `${a},${b}` : `${b},${a}`
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const DEFAULT = 0x01000000
|
|
328
|
-
// Bright enough to read on a dark terminal: the first cut's #3a3f4b vanished there.
|
|
329
|
-
const EDGE_WALK = 0x8a96b4
|
|
330
|
-
const EDGE_CROSS = 0x5c6680
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* An edge takes the colour of the membrane its far end lives in, so the map
|
|
334
|
-
* reads "how far did this prior travel to reach me": machine-only, the
|
|
335
|
-
* project / team / org membranes, or the public Errata Network.
|
|
336
|
-
*/
|
|
337
|
-
export const SCOPE_COLOR: Record<Scope, number> = {
|
|
338
|
-
machine: 0x8a96b4, // slate — never left this machine
|
|
339
|
-
project: 0x5fa8ff, // blue
|
|
340
|
-
team: 0x3fc5b0, // teal
|
|
341
|
-
org: 0xf0b34a, // amber
|
|
342
|
-
public: 0xd27cf0, // magenta — The Errata Network
|
|
284
|
+
return { title: displayName(burst.seed.label, burst.seed.name), seedLabel: burst.seed.label, origin: burst.origin, nodes, at: burst.at }
|
|
343
285
|
}
|
|
344
|
-
export const SCOPE_ORDER: Scope[] = ['machine', 'project', 'team', 'org', 'public']
|
|
345
286
|
|
|
346
|
-
/** The
|
|
347
|
-
export function edgeScope(a: MapNode, b: MapNode): Scope | undefined {
|
|
348
|
-
const ends = [a, b].filter(v => !v.isSeed && v.scope).map(v => v.scope!)
|
|
349
|
-
return ends.sort((p, q) => SCOPE_ORDER.indexOf(q) - SCOPE_ORDER.indexOf(p))[0]
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function dim(color: number): number {
|
|
353
|
-
return (((color >> 16) & 255) * 0.6) << 16 | (((color >> 8) & 255) * 0.6) << 8 | ((color & 255) * 0.6)
|
|
354
|
-
}
|
|
355
|
-
const NAME = 0x9aa3b5
|
|
356
|
-
|
|
357
|
-
export const LABEL_COLOR: Record<string, number> = {
|
|
358
|
-
Problem: 0xe06c75,
|
|
359
|
-
Vulnerability: 0xff5f87,
|
|
360
|
-
Solution: 0x98c379,
|
|
361
|
-
RootCause: 0xd19a66,
|
|
362
|
-
Pattern: 0xc678dd,
|
|
363
|
-
AntiPattern: 0xbe5046,
|
|
364
|
-
Technique: 0x56b6c2,
|
|
365
|
-
Constraint: 0xe5c07b,
|
|
366
|
-
File: 0x61afef,
|
|
367
|
-
Function: 0x7fb8d8,
|
|
368
|
-
Method: 0x7fb8d8,
|
|
369
|
-
Module: 0xa9b1c4,
|
|
370
|
-
Package: 0xa9b1c4,
|
|
371
|
-
Const: 0x8fbfa0,
|
|
372
|
-
Variable: 0x8fbfa0,
|
|
373
|
-
Interface: 0xb4a7d6,
|
|
374
|
-
Class: 0xb4a7d6,
|
|
375
|
-
Type: 0xb4a7d6,
|
|
376
|
-
Comment: 0x7c8494,
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
export const LABEL_GLYPH: Record<string, number> = {
|
|
380
|
-
Problem: 0x25c6, // ◆
|
|
381
|
-
Vulnerability: 0x25c6,
|
|
382
|
-
Solution: 0x25cf, // ●
|
|
383
|
-
RootCause: 0x25b2, // ▲
|
|
384
|
-
Pattern: 0x2726, // ✦
|
|
385
|
-
AntiPattern: 0x2726,
|
|
386
|
-
Technique: 0x25c7, // ◇
|
|
387
|
-
Constraint: 0x25a0, // ■
|
|
388
|
-
File: 0x25aa, // ▪
|
|
389
|
-
// Code structure a burst walks through (CALLS / IMPORTS / DEFINES / CONTAINS).
|
|
390
|
-
Function: 0x0192, // ƒ
|
|
391
|
-
Method: 0x0192,
|
|
392
|
-
Module: 0x229e, // ⊞
|
|
393
|
-
Package: 0x229e,
|
|
394
|
-
Const: 0x2261, // ≡
|
|
395
|
-
Variable: 0x2261,
|
|
396
|
-
Interface: 0x25a1, // □
|
|
397
|
-
Class: 0x25a1,
|
|
398
|
-
Type: 0x25a1,
|
|
399
|
-
Comment: 0x00b6, // ¶
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/** The name as the map shows it: a file path by its last two segments, a module by its last. */
|
|
287
|
+
/** The name as the shelves show it: a file path by its last two segments, a module by its last. */
|
|
403
288
|
export function displayName(label: string, name: string): string {
|
|
404
289
|
if (label === 'File' || /^[\w.@-]+(\/[\w.@-]+){2,}$/.test(name)) return name.split('/').slice(-2).join('/')
|
|
405
290
|
if (label === 'Module' || label === 'Package') return name.split('/').pop() ?? name
|
|
406
291
|
return name
|
|
407
292
|
}
|
|
408
293
|
|
|
409
|
-
export function glyphOf(label: string): number {
|
|
410
|
-
return LABEL_GLYPH[label] ?? 0x2022 // •
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
export function colorOf(label: string): number {
|
|
414
|
-
return LABEL_COLOR[label] ?? 0x6b7280
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
export function hex(color: number): string {
|
|
418
|
-
return `#${color.toString(16).padStart(6, '0')}`
|
|
419
|
-
}
|
|
420
|
-
|
|
421
294
|
const ORIGIN_SHORT = { agent: 'burst', 'daemon-file': 'recall', 'daemon-prompt': 'prime' } as const
|
|
295
|
+
const BINDING_ORDER: Binding[] = ['problem', 'cause', 'fix', 'claim', 'pattern', 'code']
|
|
422
296
|
|
|
423
297
|
/**
|
|
424
|
-
* The
|
|
425
|
-
* seed, how
|
|
426
|
-
* `errata ◉ src/engine.ts · burst 14 · ◆3 ●2
|
|
298
|
+
* The shelves folded into the status line, where the pane has no room: the
|
|
299
|
+
* seed, how many volumes entered context, and a tally by binding.
|
|
300
|
+
* `errata ◉ src/engine.ts · burst 14 · ◆3 ▲1 ●2 §8`
|
|
427
301
|
*/
|
|
428
302
|
export function statusLine(graph: MapGraph, max = 100): string | undefined {
|
|
429
303
|
const delivered = graph.nodes.filter(n => !n.isSeed)
|
|
430
304
|
if (delivered.length === 0) return undefined
|
|
431
|
-
const counts = new Map<
|
|
432
|
-
for (const n of delivered) counts.set(n.label, (counts.get(n.label) ?? 0) + 1)
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
.sort((a, b) => knowledge(a[0]) - knowledge(b[0]) || b[1] - a[1])
|
|
436
|
-
.map(([label, n]) => `${String.fromCodePoint(glyphOf(label))}${n}`)
|
|
305
|
+
const counts = new Map<Binding, number>()
|
|
306
|
+
for (const n of delivered) counts.set(bindingOf(n.label), (counts.get(bindingOf(n.label)) ?? 0) + 1)
|
|
307
|
+
const tally = BINDING_ORDER.filter(b => counts.has(b))
|
|
308
|
+
.map(b => `${BINDING_GLYPH[b]}${counts.get(b)}`)
|
|
437
309
|
.join(' ')
|
|
438
310
|
const origin = graph.origin ? ORIGIN_SHORT[graph.origin] : 'burst'
|
|
439
|
-
const
|
|
440
|
-
const line = head + tally
|
|
311
|
+
const line = `errata ◉ ${graph.title} · ${origin} ${delivered.length} · ${tally}`
|
|
441
312
|
return line.length <= max ? line : `${line.slice(0, max - 1)}…`
|
|
442
313
|
}
|
|
443
314
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
/** How far the reveal has run: routes grow out ring by ring (`grow`), then names type out (`type`). Both 0..1. */
|
|
447
|
-
export type Frame = { grow: number; type: number }
|
|
448
|
-
export const FULL: Frame = { grow: 1, type: 1 }
|
|
449
|
-
|
|
450
|
-
/** What "transparent" fades toward: the background of the terminal theme. */
|
|
451
|
-
export const DARK_BG = 0x1a1b1e
|
|
452
|
-
export const LIGHT_BG = 0xf7f7f5
|
|
453
|
-
|
|
454
|
-
/** Fraction of a colour kept at the weakest signal: faint, never gone. */
|
|
455
|
-
const FLOOR = 0.28
|
|
456
|
-
|
|
457
|
-
/** Signal strength per node, 0..1 over this burst's score range; the seed is full strength. */
|
|
458
|
-
export function strengths(graph: MapGraph): number[] {
|
|
459
|
-
const scores = graph.nodes.filter(v => !v.isSeed).map(v => v.score)
|
|
460
|
-
const [lo, hi] = [Math.min(...scores), Math.max(...scores)]
|
|
461
|
-
return graph.nodes.map(v => (v.isSeed ? 1 : hi > lo ? (v.score - lo) / (hi - lo) : 1))
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/** Blends `color` toward `bg` as signal weakens: strength 1 keeps it, 0 keeps FLOOR of it. */
|
|
465
|
-
export function fade(color: number, strength: number, bg: number): number {
|
|
466
|
-
const keep = FLOOR + (1 - FLOOR) * Math.max(0, Math.min(1, strength))
|
|
467
|
-
const ch = (c: number, shift: number) => Math.round(((c >> shift) & 255) * keep + ((bg >> shift) & 255) * (1 - keep))
|
|
468
|
-
return (ch(color, 16) << 16) | (ch(color, 8) << 8) | ch(color, 0)
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* The minimap as Raster cells (`[codePoint, fg, bg]` u32 triplets, row-major,
|
|
473
|
-
* base64) at one frame of the reveal. Positions and label slots are planned on
|
|
474
|
-
* the finished map, so an animated frame only ever draws a prefix of it —
|
|
475
|
-
* nothing jumps as it fills in.
|
|
476
|
-
*/
|
|
477
|
-
export type RasterOptions = {
|
|
478
|
-
frame?: Frame
|
|
479
|
-
/** The theme background weak signal fades toward. */
|
|
480
|
-
bg?: number
|
|
481
|
-
/** A node the agent is bursting off: drawn inverted, its route to the seed lit. */
|
|
482
|
-
highlight?: string
|
|
483
|
-
/** Which half of the highlight's pulse this frame shows. */
|
|
484
|
-
isPulseOn?: boolean
|
|
485
|
-
/** Priors the agent just judged: each pulses in its verdict's colour. */
|
|
486
|
-
marks?: Record<string, JudgmentKind>
|
|
487
|
-
/** Which half of the marks' pulse this frame shows. */
|
|
488
|
-
isMarkOn?: boolean
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
const HIGHLIGHT = 0xffd75f
|
|
492
|
-
|
|
493
|
-
export function rasterize(graph: MapGraph, columns: number, rows: number, options: RasterOptions = {}): string {
|
|
494
|
-
const { frame = FULL, bg = DARK_BG, highlight, isPulseOn = true, marks = {}, isMarkOn = true } = options
|
|
495
|
-
const markColor = (id: string): number | undefined => {
|
|
496
|
-
const kind = marks[id]
|
|
497
|
-
return kind ? JUDGMENT_STYLE[kind].color : undefined
|
|
498
|
-
}
|
|
499
|
-
const words = new Uint32Array(columns * rows * 3)
|
|
500
|
-
for (let i = 0; i < columns * rows; i++) words.set([0x20, DEFAULT, DEFAULT], i * 3)
|
|
501
|
-
const occupied = new Uint8Array(columns * rows)
|
|
502
|
-
const cx = (x: number) => 1 + Math.round(x * Math.max(0, columns - 3))
|
|
503
|
-
const cy = (y: number) => Math.round(y * Math.max(0, rows - 1))
|
|
504
|
-
const put = (x: number, y: number, ch: number, fg: number, weight: number, cellBg = DEFAULT) => {
|
|
505
|
-
if (x < 0 || y < 0 || x >= columns || y >= rows) return false
|
|
506
|
-
const i = y * columns + x
|
|
507
|
-
if (occupied[i]! > weight) return false
|
|
508
|
-
occupied[i] = weight
|
|
509
|
-
words.set([ch, fg, cellBg], i * 3)
|
|
510
|
-
return true
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
const pos: [number, number][] = graph.nodes.map(v => [cx(v.x), cy(v.y)])
|
|
514
|
-
// Two nodes in one cell: walk the later one outward to the nearest free cell.
|
|
515
|
-
const taken = new Set<number>()
|
|
516
|
-
graph.nodes.forEach((_, i) => {
|
|
517
|
-
let [x, y] = pos[i]!
|
|
518
|
-
for (let r = 0; taken.has(y * columns + x) && r < 6; r++) {
|
|
519
|
-
const ring = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1]].map(([dx, dy]) => [x + dx! * (r + 1), y + dy! * (r + 1)] as const)
|
|
520
|
-
const free = ring.find(([a, b]) => a >= 0 && b >= 0 && a < columns && b < rows && !taken.has(b * columns + a))
|
|
521
|
-
if (free) [x, y] = free
|
|
522
|
-
}
|
|
523
|
-
taken.add(y * columns + x)
|
|
524
|
-
pos[i] = [x, y]
|
|
525
|
-
})
|
|
526
|
-
|
|
527
|
-
const strength = strengths(graph)
|
|
528
|
-
// The highlighted node and the walk edges from it back to the seed.
|
|
529
|
-
const lit = graph.nodes.findIndex(v => v.id === highlight)
|
|
530
|
-
const route = new Set<number>()
|
|
531
|
-
for (let at = lit, guard = 0; at > 0 && guard < 16; guard++) {
|
|
532
|
-
const k = graph.edges.findIndex(([, c, isWalk]) => isWalk && c === at)
|
|
533
|
-
if (k < 0) break
|
|
534
|
-
route.add(k)
|
|
535
|
-
at = graph.edges[k]![0]
|
|
536
|
-
}
|
|
537
|
-
const depth = Math.max(1, ...graph.nodes.map(v => v.hops))
|
|
538
|
-
// How much of the walk edge INTO node i has grown: ring h fills during the h-th slice of `grow`.
|
|
539
|
-
const reach = (i: number) => (graph.nodes[i]!.isSeed ? 1 : Math.max(0, Math.min(1, frame.grow * depth - (graph.nodes[i]!.hops - 1))))
|
|
540
|
-
|
|
541
|
-
// Label slots, planned on the finished map, best first: nodes and earlier labels block, edges do not.
|
|
542
|
-
const blocked = new Set(pos.map(([x, y]) => y * columns + x))
|
|
543
|
-
const labels: { i: number; at: number; text: string }[] = []
|
|
544
|
-
const ranked = graph.nodes.map((v, i) => [v, i] as const).filter(([v]) => !v.isSeed).sort((p, q) => q[0].score - p[0].score)
|
|
545
|
-
for (const [v, i] of ranked) {
|
|
546
|
-
const [x, y] = pos[i]!
|
|
547
|
-
const free = (from: number, len: number) => {
|
|
548
|
-
if (from < 0 || from + len > columns) return false
|
|
549
|
-
for (let k = 0; k < len; k++) if (blocked.has(y * columns + from + k)) return false
|
|
550
|
-
return true
|
|
551
|
-
}
|
|
552
|
-
const fits = (len: number) => (free(x + 2, len) ? x + 2 : free(x - 1 - len, len) ? x - 1 - len : -1)
|
|
553
|
-
let text = shortName(displayName(v.label, v.name), 18)
|
|
554
|
-
while (text.length >= 4 && fits(text.length) < 0) text = shortName(text.replace(/~$/, ''), text.length - 2)
|
|
555
|
-
const at = text.length >= 4 ? fits(text.length) : -1
|
|
556
|
-
if (at < 0) continue
|
|
557
|
-
for (let k = 0; k < text.length; k++) blocked.add(y * columns + at + k)
|
|
558
|
-
labels.push({ i, at, text })
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Edges go down at braille resolution — 2×4 dots per cell — so a line of any
|
|
562
|
-
// slope is one continuous stroke. Per-cell box glyphs could not do that: a
|
|
563
|
-
// cell is ~2× taller than wide, so a visual diagonal steps two cells sideways
|
|
564
|
-
// per row and adjacent ╱╱ never join.
|
|
565
|
-
const dots = new Uint8Array(columns * rows)
|
|
566
|
-
const tone = new Uint8Array(columns * rows) // 2 = a walk edge crosses the cell, 1 = cross edges only
|
|
567
|
-
const hue = new Uint32Array(columns * rows) // the colour of the strongest edge through the cell
|
|
568
|
-
const plot = (px: number, py: number, weight: number, color: number) => {
|
|
569
|
-
const x = px >> 1, y = py >> 2
|
|
570
|
-
if (x < 0 || y < 0 || x >= columns || y >= rows) return
|
|
571
|
-
const i = y * columns + x
|
|
572
|
-
dots[i] = dots[i]! | BRAILLE_BIT[(py & 3) * 2 + (px & 1)]!
|
|
573
|
-
if (weight >= tone[i]!) hue[i] = color
|
|
574
|
-
tone[i] = Math.max(tone[i]!, weight)
|
|
575
|
-
}
|
|
576
|
-
for (const [k, [a, b, isWalk]] of graph.edges.entries()) {
|
|
577
|
-
// A walk edge grows parent → child; a cross edge fades in while names type.
|
|
578
|
-
const progress = isWalk ? reach(b) : frame.type
|
|
579
|
-
if (progress <= 0) continue
|
|
580
|
-
const weaker = graph.nodes[a]!.isSeed ? b : graph.nodes[b]!.isSeed ? a : strength[a]! < strength[b]! ? a : b
|
|
581
|
-
const scope = edgeScope(graph.nodes[a]!, graph.nodes[b]!)
|
|
582
|
-
const base = scope ? SCOPE_COLOR[scope] : EDGE_WALK
|
|
583
|
-
const color = route.has(k) ? HIGHLIGHT : fade(isWalk ? base : scope ? dim(base) : EDGE_CROSS, strength[weaker]!, bg)
|
|
584
|
-
let x0 = pos[a]![0] * 2 + 1, y0 = pos[a]![1] * 4 + 2
|
|
585
|
-
const x1 = pos[b]![0] * 2 + 1, y1 = pos[b]![1] * 4 + 2
|
|
586
|
-
const dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0)
|
|
587
|
-
const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1
|
|
588
|
-
const limit = Math.round(progress * Math.max(dx, -dy))
|
|
589
|
-
let err = dx + dy
|
|
590
|
-
// Cross edges are dashed so a walk edge reads as the path taken.
|
|
591
|
-
for (let step = 0; step <= limit; step++) {
|
|
592
|
-
if (isWalk || step % 4 < 2) plot(x0, y0, route.has(k) ? 4 : isWalk ? 2 : 1, color)
|
|
593
|
-
if (x0 === x1 && y0 === y1) break
|
|
594
|
-
const e2 = 2 * err
|
|
595
|
-
if (e2 >= dy) { err += dy; x0 += sx }
|
|
596
|
-
if (e2 <= dx) { err += dx; y0 += sy }
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
for (let i = 0; i < columns * rows; i++) {
|
|
600
|
-
if (dots[i]) put(i % columns, Math.floor(i / columns), 0x2800 + dots[i]!, hue[i]!, tone[i]!)
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
graph.nodes.forEach((v, i) => {
|
|
604
|
-
if (reach(i) < 1) return
|
|
605
|
-
const [x, y] = pos[i]!
|
|
606
|
-
if (v.isSeed) put(x, y, 0x25c9, 0xffd75f, 9) // ◉
|
|
607
|
-
else if (i === lit) put(x, y, glyphOf(v.label), isPulseOn ? bg : HIGHLIGHT, 10, isPulseOn ? HIGHLIGHT : DEFAULT)
|
|
608
|
-
else if (markColor(v.id) !== undefined) {
|
|
609
|
-
const c = markColor(v.id)!
|
|
610
|
-
put(x, y, glyphOf(v.label), isMarkOn ? bg : c, 10, isMarkOn ? c : DEFAULT)
|
|
611
|
-
}
|
|
612
|
-
else put(x, y, glyphOf(v.label), fade(colorOf(v.label), strength[i]!, bg), 8)
|
|
613
|
-
})
|
|
614
|
-
|
|
615
|
-
for (const { i, at, text } of labels) {
|
|
616
|
-
if (reach(i) < 1) continue
|
|
617
|
-
const shown = Math.floor(text.length * frame.type)
|
|
618
|
-
const color = i === lit ? HIGHLIGHT : markColor(graph.nodes[i]!.id) ?? fade(NAME, strength[i]!, bg)
|
|
619
|
-
for (let k = 0; k < shown; k++) put(at + k, pos[i]![1], text.codePointAt(k)!, color, 3)
|
|
620
|
-
}
|
|
621
|
-
return new Uint8Array(words.buffer).toBase64()
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/** Unicode braille dot bits, indexed by `row * 2 + column` within the 2×4 cell (U+2800 + bits). */
|
|
625
|
-
const BRAILLE_BIT = [0x01, 0x08, 0x02, 0x10, 0x04, 0x20, 0x40, 0x80]
|
|
626
|
-
|
|
627
|
-
/** A name short enough to sit on the map: one printable width-1 run, the tail clipped. */
|
|
315
|
+
/** A name short enough for a header or the status line: one printable width-1 run, the tail clipped. */
|
|
628
316
|
export function shortName(name: string, max: number): string {
|
|
629
317
|
const flat = name.replace(/\s+/g, ' ').replace(/[^\x20-\x7e]/g, '').trim()
|
|
630
318
|
if (max <= 1) return ''
|