@inerrata-corporation/errata 2.0.21 → 2.0.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.
@@ -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
- export const JUDGMENT_STYLE: Record<JudgmentKind, { glyph: string; color: number; verb: string }> = {
217
- cite: { glyph: '◎', color: 0x56b6c2, verb: 'cited' },
218
- confirm: { glyph: '✓', color: 0x98c379, verb: 'confirmed' },
219
- fix: { glyph: '✓', color: 0x98c379, verb: 'resolved' },
220
- refute: { glyph: '✗', color: 0xe06c75, verb: 'refuted' },
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
- * Radial layout of one burst: the seed at the centre, one ring per hop, each
274
- * node in its parent's angular sector, and within a ring a higher score sits
275
- * closer in. Positions are in the unit square.
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, x: 0.5, y: 0.5 }]
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 byDepth) {
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
- childrenOf.set(parent, [...(childrenOf.get(parent) ?? []), n])
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 kids = childrenOf.get(parentId) ?? []
296
- const [a0, a1] = sector.get(parentId) ?? [0, Math.PI * 2]
297
- const span = (a1 - a0) / Math.max(1, kids.length)
298
- kids.forEach((n, i) => {
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 tier an edge is coloured by: its non-seed end, the farther-travelled when both say. */
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 minified minimap for the status line, where the pane has no room: the
425
- * seed, how much entered context, and a glyph tally by type, knowledge first.
426
- * `errata ◉ src/engine.ts · burst 14 · ◆3 ●2 ƒ2 ⊞3 ≡2 □1 ¶1`
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<string, number>()
432
- for (const n of delivered) counts.set(n.label, (counts.get(n.label) ?? 0) + 1)
433
- const knowledge = (l: string) => (l in LABEL_GLYPH && !(l in CODE_LABELS) ? 0 : 1)
434
- const tally = [...counts]
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 head = `errata ◉ ${graph.title} · ${origin} ${delivered.length} · `
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
- const CODE_LABELS = { File: 1, Function: 1, Method: 1, Module: 1, Package: 1, Const: 1, Variable: 1, Interface: 1, Class: 1, Type: 1, Comment: 1 }
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 ''