diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..d04a4c7
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,16 @@
+name: Test
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ - run: npm test
diff --git a/README.md b/README.md
index adca7fd..1531680 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ Static files, no build step, no server, zero running cost. Published with GitHub
| --- | --- |
| `index.html`, `en/index.html` | The entrance: the era at a glance, Chinese and English |
| `archive/2219-signal/` | The full record of the 2219 signal from Ross 128 b: the log, arrival times, the beacon decoder, letters |
+| `script/` | The grid script: how the translating machine writes down what Ross 128 b sends (`script/`), the online dictionary (`script/dictionary/`) and a converter between page source and drawings (`script/convert/`), Chinese with English under `en/`. `lib/glyph.js` is a JavaScript port of [glyph-cli](https://github.com/InterImm/glyph-cli) 0.2.0 and `lib/glyph-vocab.json` is its bundled vocabulary; `lib/glyph-zh.json` holds the Chinese glosses |
| `pulsars/` | The pulsar ledger, live: the pulsars that keep the MC clock, the ledger nodes and the timing residuals where the beacon repeats. Self-contained (its own page, `app/pulsars.js` and `app/pulsars.css`) so it can move to its own repo later |
| `kit/` | The phase 2 design kit, 137, published at https://interstellar.interimm.org/kit/ ([kit/README.md](kit/README.md)) |
| `app/` | Page scripts and styles |
@@ -17,6 +18,10 @@ Static files, no build step, no server, zero running cost. Published with GitHub
Run it locally with any static server, for example `python3 -m http.server`, and open http://localhost:8000/.
+`npm test` checks `lib/glyph.js` against what glyph-cli itself outputs (`tests/glyph-cli-fixtures.json`). After a new glyph-cli
+release or a vocabulary change, copy its `vocab.json` to `lib/glyph-vocab.json`, add the Chinese glosses to `lib/glyph-zh.json`,
+and regenerate the fixtures with `pip install glyph-cli` and `python3 scripts/glyph_fixtures.py`.
+
The previous Hugo site (2022, "飞向恒星 / we must leave the solar system") is in the git history. The draft about
the first Earth–Mars conflict lives on the branch `cms/interstellar/草稿`.
diff --git a/app/script.css b/app/script.css
new file mode 100644
index 0000000..f683dee
--- /dev/null
+++ b/app/script.css
@@ -0,0 +1,117 @@
+/* The grid script pages only. Everything shared is in kit/interimm.css. */
+.gs-hero h1 { font-size: var(--step-4); }
+.gs-hero .lede { max-width: 46rem; }
+.gs-tabs { margin-top: 1.75rem; background: var(--deep); }
+
+/* drawings: square pixels, never smoothed */
+.gs-draw svg { display: block; width: 100%; height: auto; image-rendering: pixelated; }
+.gs-draw-part { width: 3.25rem; flex: none; }
+.gs-draw-word { width: 6rem; flex: none; }
+.gs-draw { border: 2px solid var(--border-strong); background: var(--bg); }
+
+.gs-intro { display: grid; gap: 1rem; max-width: 42rem; }
+.gs-intro p { margin: 0; }
+.gs-points { display: grid; gap: 0.6rem; margin: 0; padding-left: 1.25rem; }
+.gs-points li::marker { color: var(--them); font-family: var(--font-mono); }
+
+/* the sixteen parts */
+.gs-parts { display: grid; gap: 0.5rem; grid-template-columns: repeat(auto-fill, minmax(min(100%, 13.5rem), 1fr)); list-style: none; margin: 0; padding: 0; }
+.gs-part { display: flex; gap: 0.85rem; align-items: center; padding: 0.6rem; background: var(--surface); border: 2px solid var(--border); }
+.gs-part > div:last-child { display: grid; gap: 0.1rem; min-width: 0; }
+.gs-part code, .gs-word code, .gs-entry code { font-family: var(--font-mono); font-size: 0.75rem; letter-spacing: 0.06em; color: var(--them); }
+.gs-part strong { font-size: 1rem; }
+.gs-part span { font-size: 0.85rem; color: var(--muted); }
+.gs-part span::before { content: "→ "; color: var(--us); }
+
+/* words */
+.gs-words { display: flex; flex-wrap: wrap; gap: 0.75rem; list-style: none; margin: 0; padding: 0; }
+.gs-word { display: grid; gap: 0.3rem; justify-items: start; padding: 0.6rem; background: var(--surface); border: 2px solid var(--border); }
+.gs-word span { font-size: 0.92rem; }
+
+/* worked examples: a drawing beside its source and the graph it reads */
+.gs-examples { display: grid; gap: 1.5rem; }
+.gs-example { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1.25rem; align-items: start; margin: 0; padding: 1rem; background: var(--surface); border: 2px solid var(--border-strong); box-shadow: var(--shadow); }
+.gs-example > .gs-draw { max-width: 30rem; }
+.gs-example figcaption { display: grid; gap: 0.75rem; justify-items: start; min-width: 0; }
+.gs-example h3 { margin: 0; font-size: 1.05rem; }
+.gs-src, .gs-rows { margin: 0; padding: 0.6rem 0.75rem; max-width: 100%; overflow-x: auto; background: var(--deep); border: 2px solid var(--border); font-family: var(--font-mono); font-size: 0.82rem; line-height: 1.45; white-space: pre; color: var(--text); }
+.gs-rows { line-height: 1.15; letter-spacing: 0.08em; }
+
+/* the graph a page reads as */
+.gs-graph { display: grid; gap: 0.6rem; margin: 0; padding: 0; list-style: none; counter-reset: edge; }
+.gs-graph li { counter-increment: edge; display: grid; gap: 0.25rem; }
+.gs-graph p { margin: 0; }
+.gs-edge { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
+.gs-edge::before { content: counter(edge) "."; font-family: var(--font-mono); font-size: 0.8rem; color: var(--muted); }
+.gs-node { padding: 0.05rem 0.5rem; border: 2px solid var(--text); background: var(--bg); }
+.gs-rel { font-family: var(--font-mono); font-size: 0.82rem; color: var(--muted); }
+.gs-rel::before { content: "—"; }
+.gs-rel::after { content: "→"; }
+.gs-reply { display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem; padding-left: 1.4rem; font-size: 0.9rem; color: var(--muted); }
+.gs-reply i { font-style: normal; font-family: var(--font-mono); font-size: 0.78rem; color: var(--text); }
+.gs-sym { display: inline-grid; place-items: center; width: 1.4rem; height: 1.4rem; background: var(--ink); color: var(--deep); font-family: var(--font-mono); font-size: 0.85rem; }
+
+/* numbers */
+.gs-number { display: flex; flex-wrap: wrap; gap: 1.25rem 2rem; align-items: center; padding: 1rem; background: var(--surface); border: 2px solid var(--border-strong); }
+.gs-number .gs-draw { width: 9rem; }
+.gs-number-controls { display: grid; gap: 0.6rem; min-width: min(100%, 16rem); flex: 1; }
+.gs-number-controls input[type=range] { width: 100%; accent-color: var(--them); }
+.gs-number-word { font-family: var(--font-mono); color: var(--them); }
+.gs-bits { display: grid; grid-template-columns: repeat(3, 3rem); gap: 2px; font-family: var(--font-mono); font-size: 0.75rem; text-align: center; }
+.gs-bits span { padding: 0.25rem 0; background: var(--deep); color: var(--muted); }
+.gs-bits span.on { background: var(--them); color: var(--them-ink); }
+
+/* the dictionary */
+.gs-search { display: grid; gap: 0.9rem; margin-bottom: 1.5rem; }
+.gs-search .field { max-width: 32rem; }
+.gs-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }
+.gs-chips button { font: inherit; font-size: 0.85rem; padding: 0.25rem 0.7rem; color: var(--muted); background: none; border: 2px solid var(--border-strong); cursor: pointer; }
+.gs-chips button:hover { color: var(--text); border-color: var(--text); }
+.gs-chips button[aria-pressed="true"] { color: var(--them-ink); background: var(--them); border-color: var(--them); }
+.gs-count { font-family: var(--font-mono); font-size: 0.8rem; color: var(--muted); }
+.gs-built { display: flex; gap: 1.25rem; align-items: center; margin-bottom: 1.25rem; padding: 1rem; background: var(--surface-2); border: 2px solid var(--us); box-shadow: 6px 6px 0 var(--us-soft); }
+.gs-built[hidden] { display: none; }
+.gs-built .kicker { margin: 0 0 0.25rem; color: var(--us); }
+.gs-built p { margin: 0; }
+.gs-entries { display: grid; gap: 0.6rem; grid-template-columns: repeat(auto-fill, minmax(min(100%, 21rem), 1fr)); list-style: none; margin: 0; padding: 0; }
+.gs-entry { display: flex; gap: 1rem; align-items: flex-start; padding: 0.75rem; background: var(--surface); border: 2px solid var(--border); scroll-margin-top: 5rem; }
+.gs-entry:target { border-color: var(--them); }
+.gs-entry-text { display: grid; gap: 0.2rem; min-width: 0; }
+.gs-entry-text p, .gs-built .gs-entry-head { margin: 0; }
+.gs-entry-head { display: flex; flex-wrap: wrap; gap: 0.25rem 0.6rem; align-items: baseline; }
+.gs-entry-head strong { font-size: 1.05rem; }
+.gs-entry-en, .gs-entry-note { font-size: 0.88rem; color: var(--muted); }
+.gs-entry-meta { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; font-size: 0.78rem; color: var(--muted); }
+.gs-entry-meta span { font-family: var(--font-mono); }
+.gs-none { color: var(--muted); }
+.gs-parts-table .gs-draw-part { width: 2.25rem; }
+.gs-parts-table td { vertical-align: middle; }
+
+/* the converter */
+.gs-pane { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); gap: 1.5rem; align-items: start; }
+.gs-pane > div { display: grid; gap: 0.9rem; min-width: 0; align-content: start; }
+.gs-pane .field textarea { font-family: var(--font-mono); font-size: 0.88rem; min-height: 10rem; white-space: pre; overflow-x: auto; }
+#gs-drawing { min-height: 12rem; line-height: 1.15; letter-spacing: 0.08em; }
+.gs-error { margin: 0; padding: 0.5rem 0.75rem; border: 2px solid var(--us); color: var(--us); background: var(--us-soft); font-size: 0.9rem; }
+.gs-out .gs-draw { max-width: 28rem; }
+.gs-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
+.gs-editor { touch-action: none; cursor: crosshair; max-width: 30rem; }
+.gs-palette { display: grid; grid-template-columns: repeat(auto-fill, minmax(4.4rem, 1fr)); gap: 0.35rem; }
+.gs-palette button { display: grid; justify-items: center; gap: 0.25rem; padding: 0.35rem 0.2rem; font: inherit; font-family: var(--font-mono); font-size: 0.62rem; letter-spacing: 0.04em; color: var(--muted); background: var(--deep); border: 2px solid var(--border); cursor: pointer; }
+.gs-palette button .gs-draw-part { width: 1.6rem; border: 0; }
+.gs-palette button:hover { color: var(--text); border-color: var(--text); }
+.gs-palette button[aria-pressed="true"] { color: var(--text); border-color: var(--them); background: var(--surface-2); }
+.gs-controls { display: flex; flex-wrap: wrap; gap: 0.75rem 1.25rem; align-items: end; }
+.gs-controls .field { min-width: 6rem; }
+.gs-symbols { display: flex; flex-wrap: wrap; gap: 0.25rem 0.9rem; align-items: center; margin: 0; padding: 0; border: 0; }
+.gs-symbols legend { padding: 0; margin-bottom: 0.35rem; font-size: 0.9rem; color: var(--muted); }
+.gs-symbols label { display: inline-flex; gap: 0.35rem; align-items: center; font-size: 0.92rem; }
+.gs-symbols input[type=text] { width: 2.4rem; padding: 0.15rem 0.3rem; font: inherit; font-family: var(--font-mono); text-align: center; color: var(--text); background: var(--deep); border: 2px solid var(--border-strong); }
+.gs-ink-them { color: var(--them); font-family: var(--font-mono); }
+.gs-ink-us { color: var(--us); font-family: var(--font-mono); }
+.gs-ink-third { color: var(--c4); font-family: var(--font-mono); }
+.gs-steps { display: grid; gap: 0.5rem; margin: 0; padding-left: 1.4rem; max-width: 44rem; }
+
+@media (max-width: 52rem) {
+ .gs-example, .gs-pane { grid-template-columns: minmax(0, 1fr); }
+}
diff --git a/app/script.js b/app/script.js
new file mode 100644
index 0000000..8021a1b
--- /dev/null
+++ b/app/script.js
@@ -0,0 +1,396 @@
+// The grid script pages: the explainer, the dictionary and the converter (one script for all three).
+// Everything about the script itself is in lib/glyph.js (a port of glyph-cli); this file only draws it
+// and wires up the controls. The page says which parts it has with data-gs-* attributes.
+import {
+ Vocabulary, GlyphError, parsePage, formatPage, render, renderSvg, rowsSvg, decode, readGraph, bandRows,
+ pageSymbols, wordStr, isEmpty, structure, ink, numberShape, WIDTH, POSITIONS, EMPTY_CELL, BACKGROUND, GRID,
+} from '../lib/glyph.js';
+
+const zh = document.documentElement.lang.toLowerCase().startsWith('zh');
+const $ = (s, el = document) => el.querySelector(s);
+const $$ = (s, el = document) => [...el.querySelectorAll(s)];
+const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
+const LIB = new URL('../lib/', import.meta.url);
+const HERE = new URL('./', location.href);
+// the section's pages: CN at script/…/, EN at script/…/en/
+const SECTION = new URL('../script/', import.meta.url);
+const pageUrl = (name, source) => {
+ const url = new URL(`${name ? `${name}/` : ''}${zh ? '' : 'en/'}`, SECTION);
+ if (source !== undefined) url.searchParams.set('s', source);
+ return url.href;
+};
+
+const T = zh ? {
+ slots: { subject: '前节点', relation: '关系', object: '后节点' }, halves: { kind: '类', which: '指' },
+ meaning: { yes: '是', no: '不', instead: '换成', 'and also': '还有' },
+ to: (s) => `(回应 ${s})`, quote: (s) => `“${s}”`, noComment: '在场,未表态',
+ statement: (n) => `[第 ${n} 句]`, thatNothing: '[“那”:上面没有句子]', notInVocabulary: '(不在词典中)',
+ part: '部件', asThing: '作为事物', asRelation: '作为关系', none: '—',
+ results: (n) => `${n} 个词`, noResults: '没有找到。试试别的词,或者直接写出一个词,比如 BODY.STAR。',
+ composed: '拼出来的词', inVocab: '词典里的词', notIn: '还不在词典里:这是按部件拼出的读法。', number: '数', numberNote: '数是内置的:COUNT 后面的九个比特。',
+ all: '全部', draw: '在转换器里画', copied: '已复制', copy: '复制', linkCopied: '链接已复制',
+ empty: '空', voice: (s) => `声部 ${s}`, edges: '读出的图', nodes: '节点',
+ errLine: (n) => `第 ${n} 行:`,
+ err: {
+ unknownPart: (a) => `未知的部件:${a.part}`, range: (a) => `数超出 0–511:${a.n}`,
+ numberKind: (a) => `数要以 COUNT 为类,比如 COUNT.${a.n}(写的是 ${a.s})`,
+ countWhich: (a) => `COUNT 的“指”只能是数,比如 COUNT.12(写的是 ${a.s})`,
+ symbol: (a) => `符号是一个字符,不能是空格或 # . : _ |(写的是 “${a.symbol}”)`,
+ format: () => '应写成“符号: 节点 | 关系 | 节点”', slots: (a) => `要正好三个位置,用 | 分开,现在是 ${a.n} 个`,
+ width: (a) => `第 ${a.row} 行有 ${a.len} 格宽;文字总是 23 格(不在格点上:是一幅图吗?)`,
+ between: (a) => `第 ${a.row} 行第 ${a.col} 列:标记落在两个位置之间(是一幅图吗?)`,
+ structure: () => '这些行排不成句子和声部;这是一幅图,而不是文字吗?',
+ mixed: (a) => `第 ${a.from}–${a.to} 行:一个声部里混用了几种符号`,
+ notPart: (a) => `第 ${a.from}–${a.to} 行,第 ${a.pos} 个位置:不是部件`,
+ notWhich: (a) => `第 ${a.from}–${a.to} 行,第 ${a.pos} 个位置:这里既不是部件,也不是数`,
+ },
+} : {
+ slots: { subject: 'subject', relation: 'relation', object: 'object' }, halves: { kind: 'kind', which: 'which' },
+ meaning: { yes: 'yes', no: 'no', instead: 'instead', 'and also': 'and also' },
+ to: (s) => ` (to ${s})`, quote: (s) => `"${s}"`, noComment: 'present, no comment',
+ statement: (n) => `[statement ${n}]`, thatNothing: '[that: nothing above]', notInVocabulary: '(not in vocabulary)',
+ part: 'part', asThing: 'as a thing', asRelation: 'as a relation', none: '—',
+ results: (n) => `${n} word${n === 1 ? '' : 's'}`, noResults: 'Nothing found. Try another word, or write one out, like BODY.STAR.',
+ composed: 'A word you built', inVocab: 'In the dictionary', notIn: 'Not in the dictionary yet: this is how its parts read.', number: 'A number', numberNote: 'Numbers are built in: nine bits after COUNT.',
+ all: 'All', draw: 'Draw in the converter', copied: 'Copied', copy: 'Copy', linkCopied: 'Link copied',
+ empty: 'empty', voice: (s) => `voice ${s}`, edges: 'The graph it reads', nodes: 'Nodes',
+ errLine: () => '', err: {},
+};
+
+const message = (err) => {
+ if (!(err instanceof GlyphError) || !zh || !T.err[err.code]) return err.message;
+ return (err.args.line ? T.errLine(err.args.line) : '') + T.err[err.code](err.args);
+};
+
+// ------------------------------------------------------------------ data
+
+async function loadVocab() {
+ const get = (f) => fetch(new URL(f, LIB)).then((r) => { if (!r.ok) throw new Error(`${f}: ${r.status}`); return r.json(); });
+ const [data, z] = await Promise.all([get('glyph-vocab.json'), get('glyph-zh.json')]);
+ const en = new Vocabulary(data);
+ if (!zh) return { en, voc: en, z: null };
+ const local = structuredClone(data);
+ for (const [name, p] of Object.entries(local.parts)) Object.assign(p, z.parts[name] || {});
+ local.markers = { ...local.markers, ...z.markers };
+ for (const w of local.words) {
+ const t = z.words[wordStr({ kind: w.kind, which: w.which })] || {};
+ w.gloss = t.gloss ?? w.gloss; w.note = t.note ?? w.note; w.domain = z.domains[w.domain] ?? w.domain;
+ }
+ const voc = new Vocabulary(local, {
+ notInVocabulary: T.notInVocabulary, statement: T.statement, thatNothing: T.thatNothing, quote: T.quote, to: T.to,
+ });
+ return { en, voc, z };
+}
+
+// ------------------------------------------------------------------ drawing helpers
+
+const svgOf = (rows, symbols, cell = 12, width = WIDTH) => rowsSvg(rows, symbols, { cell, width });
+const wordSvg = (vocab, w, symbol = '+') => svgOf(bandRows(vocab, [w.kind, w.which], symbol), [symbol], 12, 7);
+const partSvg = (shape, symbol = '+') => svgOf(shape.map((r) => r.replaceAll('#', symbol)), [symbol], 12, 3);
+
+function graphHtml(voc, g, symbols) {
+ const label = (n) => (n.statement !== null ? (n.statement ? T.statement(n.statement) : T.thatNothing) : n.gloss);
+ const sym = (s) => `${esc(s)} `;
+ return `
${g.edges.map((e) => `
+ ${sym(e.symbol)} ${esc(label(e.subject))}
+ ${esc(e.relation || T.none)}
+ ${esc(label(e.object))}
+ ${e.replies.map((r) => `${sym(r.symbol)} ${r.responses.length ? r.responses.map((x) =>
+ `${T.slots[x.slot]}.${T.halves[x.half]} ${T.meaning[x.meaning]}${x.meaning === 'yes' || x.meaning === 'no' ? '' : ' ' + esc(T.quote(x.part))}${x.to ? esc(T.to(x.to)) : ''} `).join('') : `${T.noComment} `}
`).join('')}
+ `).join('')} `;
+}
+
+async function copyText(text, btn, done = T.copied) {
+ try { await navigator.clipboard.writeText(text); } catch { return; }
+ const old = btn.textContent; btn.textContent = done; setTimeout(() => { btn.textContent = old; }, 1400);
+}
+
+// ------------------------------------------------------------------ the explainer
+
+function explainer({ en, voc }) {
+ for (const el of $$('[data-gs-parts]')) {
+ el.innerHTML = Object.values(voc.parts).map((p) => `
+ ${partSvg(p.shape)}
+ ${p.name}${esc(p.thing)} ${p.relation ? `${esc(p.relation)} ` : ''}
`).join('');
+ }
+ for (const el of $$('[data-gs-words]')) {
+ el.innerHTML = el.dataset.gsWords.split(/\s+/).filter(Boolean).map((s) => {
+ const w = en.parse(s);
+ return `${wordSvg(en, w)}
${wordStr(w)}${esc(voc.gloss(w))} `;
+ }).join('');
+ }
+ for (const el of $$('[data-gs-example]')) {
+ const source = el.dataset.gsExample.replaceAll('\\n', '\n');
+ const page = parsePage(source, en);
+ el.innerHTML = `${renderSvg(en, page, { cell: 14 })}
+ ${esc(formatPage(page).trim())} ${graphHtml(voc, readGraph(voc, page), pageSymbols(page))}
+ ${zh ? '在转换器里打开 →' : 'Open in the converter →'} `;
+ }
+ for (const el of $$('[data-gs-number]')) {
+ const input = $('input[type=range]', el), num = $('input[type=number]', el), out = $('.gs-draw', el), bits = $('.gs-bits', el);
+ const show = (n) => {
+ n = Math.max(0, Math.min(511, Math.round(+n || 0)));
+ input.value = num.value = n;
+ const w = en.parse(`COUNT.${n}`);
+ out.innerHTML = wordSvg(en, w);
+ const sh = numberShape(n);
+ const vals = [256, 128, 64, 32, 16, 8, 4, 2, 1];
+ bits.innerHTML = sh.join('').split('').map((c, i) => `${vals[i]} `).join('');
+ $('.gs-number-word', el).textContent = wordStr(w);
+ };
+ input.addEventListener('input', () => show(input.value));
+ num.addEventListener('input', () => show(num.value));
+ show(137);
+ }
+}
+
+// ------------------------------------------------------------------ the dictionary
+
+function dictionary({ en, voc }) {
+ const q = $('#gs-q'), list = $('#gs-results'), count = $('#gs-count'), chips = $('#gs-domains'), built = $('#gs-built');
+ const parts = $('#gs-parts-table');
+ if (parts) {
+ parts.innerHTML = Object.values(voc.parts).map((p) => `${partSvg(p.shape)}
+ ${p.name}${esc(p.thing)} ${esc(p.relation || T.none)} `).join('');
+ }
+ const entries = voc.entries.map((e, i) => ({ e, en: en.entries[i] }));
+ const domains = voc.domains();
+ let domain = '';
+ chips.innerHTML = [`${T.all} `,
+ ...domains.map((d) => `${esc(d)} `)].join('');
+ chips.addEventListener('click', (ev) => {
+ const b = ev.target.closest('button'); if (!b) return;
+ domain = b.dataset.d;
+ $$('button', chips).forEach((x) => x.setAttribute('aria-pressed', String(x === b)));
+ update();
+ });
+ const card = ({ e, en: ee }) => {
+ const code = wordStr(e.word);
+ return `
+ ${wordSvg(en, e.word)}
+
+
${esc(e.gloss)} ${code}
+ ${zh ? `
${esc(ee.gloss)}
` : ''}
+ ${e.note ? `
${esc(e.note)}
` : ''}
+
${esc(e.domain)} ${T.draw}
+
`;
+ };
+ const update = () => {
+ const t = q.value.trim().toLowerCase();
+ // a word written out (BODY.STAR, count.42): show how it draws and reads, in the dictionary or not
+ built.hidden = true;
+ if (t && /^[a-z_]+(\.[a-z0-9_]*)?$/i.test(t)) {
+ try {
+ const w = en.parse(t);
+ if (!isEmpty(w)) {
+ const known = en.lookup(w);
+ const num = w.kind === 'COUNT';
+ built.hidden = false;
+ built.innerHTML = `${wordSvg(en, w)}
+
${num ? T.number : known ? T.inVocab : T.composed}
+
${esc(voc.gloss(w))} ${wordStr(w)}
+ ${known ? '' : `
${num ? T.numberNote : T.notIn}
`}
+
${T.draw}
`;
+ }
+ } catch { /* not a word: search the meanings instead */ }
+ }
+ const hits = entries.filter(({ e, en: ee }) => (!domain || e.domain === domain) && (!t || [
+ wordStr(e.word), e.gloss, e.note, ee.gloss, ee.note,
+ ].some((s) => s.toLowerCase().includes(t))));
+ list.innerHTML = hits.map(card).join('') || `${T.noResults} `;
+ count.textContent = T.results(hits.length);
+ };
+ q.addEventListener('input', update);
+ const initial = new URLSearchParams(location.search).get('q');
+ if (initial) q.value = initial;
+ update();
+ if (location.hash) document.getElementById(decodeURIComponent(location.hash.slice(1)))?.scrollIntoView();
+}
+
+// ------------------------------------------------------------------ the converter
+
+const SYMBOLS = ['+', '×', '*'];
+
+function converter({ en, voc }) {
+ const src = $('#gs-src'), out = $('#gs-out'), rowsOut = $('#gs-rows'), graphOut = $('#gs-graph'), errOut = $('#gs-src-err');
+ const examples = $('#gs-examples');
+ let lastRows = [];
+
+ // ---- source -> drawing
+ const fromSource = () => {
+ try {
+ const page = parsePage(src.value, en);
+ lastRows = render(en, page);
+ out.innerHTML = page.lines.length ? renderSvg(en, page, { cell: 16 }) : '';
+ rowsOut.textContent = lastRows.join('\n');
+ graphOut.innerHTML = page.lines.length ? graphHtml(voc, readGraph(voc, page), pageSymbols(page)) : '';
+ errOut.hidden = true;
+ src.removeAttribute('aria-invalid');
+ } catch (err) {
+ errOut.textContent = message(err); errOut.hidden = false;
+ src.setAttribute('aria-invalid', 'true');
+ }
+ };
+ src.addEventListener('input', fromSource);
+ examples.addEventListener('change', () => {
+ const opt = examples.selectedOptions[0];
+ if (!opt.value) return;
+ src.value = opt.value.replaceAll('\\n', '\n'); fromSource();
+ });
+ $('#gs-copy-rows').addEventListener('click', (ev) => copyText(lastRows.join('\n') + '\n', ev.currentTarget));
+ $('#gs-svg').addEventListener('click', () => {
+ try {
+ const blob = new Blob([renderSvg(en, parsePage(src.value, en))], { type: 'image/svg+xml' });
+ const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: 'grid-script.svg' });
+ a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
+ } catch { /* the error is already on screen */ }
+ });
+ $('#gs-link').addEventListener('click', (ev) => {
+ const url = new URL(HERE); url.search = ''; url.searchParams.set('s', src.value);
+ history.replaceState(null, '', url);
+ copyText(url.href, ev.currentTarget, T.linkCopied);
+ });
+ $('#gs-to-editor').addEventListener('click', () => {
+ if (!lastRows.length) return;
+ drawing.value = lastRows.join('\n'); fromDrawing(true);
+ $('#reverse').scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
+ });
+
+ // ---- drawing -> source: an editor grid, mirrored in a text area
+ const drawing = $('#gs-drawing'), editor = $('#gs-editor'), back = $('#gs-back'), backGraph = $('#gs-back-graph'), backErr = $('#gs-back-err');
+ const linesIn = $('#gs-lines'), voicesIn = $('#gs-voices'), palette = $('#gs-palette'), numIn = $('#gs-num');
+ let grid = []; // rows of characters
+ let symbol = '+';
+ let tool = 'BODY'; // a part name, 'NUMBER', 'CELL' or 'ERASE'
+ const CELL = 18;
+
+ const heightFor = (lines, voices) => lines * (4 * voices - 1) + LINE_GAP_ROWS * (lines - 1);
+ const LINE_GAP_ROWS = 3;
+ const resize = (h) => {
+ const blank = () => Array(WIDTH).fill(EMPTY_CELL);
+ grid = Array.from({ length: h }, (_, r) => grid[r] || blank());
+ };
+ const drawEditor = () => {
+ const symbols = [...new Set(SYMBOLS.concat(grid.flat().filter((c) => c !== EMPTY_CELL)))];
+ // lattice positions are a shade lighter, so the grid shows where parts go
+ const voices = +voicesIn.value, period = 4 * voices - 1 + LINE_GAP_ROWS;
+ const w = (WIDTH + 2) * CELL, h = (grid.length + 2) * CELL, size = CELL - 2;
+ const out = [` `];
+ grid.forEach((row, r) => {
+ const at = r % period;
+ const onRow = at < 4 * voices - 1 && at % 4 !== 3;
+ row.forEach((ch, c) => {
+ const fill = ch !== EMPTY_CELL ? ink(ch, symbols) : onRow && c % 4 !== 3 ? '#26339a' : GRID;
+ out.push(` `);
+ });
+ });
+ editor.innerHTML = out.join('') + ' ';
+ drawing.value = grid.map((r) => r.join('')).join('\n');
+ };
+ const fromDrawing = (load = false) => {
+ if (load) {
+ const rows = drawing.value.split(/\r?\n/).map((r) => r.trim()).filter(Boolean);
+ if (rows.length && rows.every((r) => [...r].length === WIDTH)) {
+ grid = rows.map((r) => [...r]);
+ // the voices per line as the machine reads them, so the editor shades the right positions
+ try {
+ const v = structure(rows);
+ if (v <= 3) { voicesIn.value = v; linesIn.value = (rows.length + LINE_GAP_ROWS) / (4 * v - 1 + LINE_GAP_ROWS); }
+ } catch { /* not text (yet): keep the layout */ }
+ drawEditor();
+ }
+ }
+ try {
+ const page = decode(en, drawing.value);
+ back.textContent = formatPage(page);
+ backGraph.innerHTML = page.lines.length ? graphHtml(voc, readGraph(voc, page), pageSymbols(page)) : '';
+ backErr.hidden = true;
+ } catch (err) {
+ back.textContent = ''; backGraph.innerHTML = '';
+ backErr.textContent = message(err); backErr.hidden = false;
+ }
+ };
+ const relayout = () => { resize(heightFor(+linesIn.value, +voicesIn.value)); drawEditor(); fromDrawing(); };
+ linesIn.addEventListener('change', () => { linesIn.value = Math.max(1, Math.min(12, Math.round(+linesIn.value || 1))); relayout(); });
+ voicesIn.addEventListener('change', relayout);
+ drawing.addEventListener('input', () => fromDrawing(true));
+ $('#gs-clear').addEventListener('click', () => { grid = []; relayout(); });
+
+ // the palette: one button per part, a number, a single cell, the eraser
+ const tools = [...Object.values(en.parts).map((p) => ({ id: p.name, label: p.name, shape: p.shape })),
+ { id: 'NUMBER', label: zh ? '数' : 'number', shape: numberShape(5) },
+ { id: 'CELL', label: zh ? '单格' : 'one cell', shape: ['...', '.#.', '...'] },
+ { id: 'ERASE', label: zh ? '擦除' : 'erase', shape: ['...', '...', '...'] }];
+ palette.innerHTML = tools.map((t) => `
+ ${partSvg(t.shape)} ${t.label} `).join('');
+ palette.addEventListener('click', (ev) => {
+ const b = ev.target.closest('button'); if (!b) return;
+ tool = b.dataset.tool;
+ $$('button', palette).forEach((x) => x.setAttribute('aria-pressed', String(x === b)));
+ numIn.closest('.field').hidden = tool !== 'NUMBER';
+ });
+ numIn.closest('.field').hidden = true;
+ for (const r of $$('input[name=gs-symbol]')) r.addEventListener('change', () => { symbol = r.value === 'other' ? ($('#gs-own').value || '*') : r.value; });
+ $('#gs-own').addEventListener('input', (ev) => {
+ const v = [...ev.target.value].pop();
+ if (!v || '.#|:_ '.includes(v)) return;
+ ev.target.value = v;
+ $('input[name=gs-symbol][value=other]').checked = true; symbol = v;
+ });
+
+ const paint = (ev, dragging) => {
+ const box = editor.firstElementChild.getBoundingClientRect();
+ const scale = box.width / ((WIDTH + 2) * CELL);
+ const c = Math.floor((ev.clientX - box.left) / scale / CELL) - 1;
+ const r = Math.floor((ev.clientY - box.top) / scale / CELL) - 1;
+ if (r < 0 || c < 0 || r >= grid.length || c >= WIDTH) return;
+ if (tool === 'CELL') {
+ if (dragging) grid[r][c] = paint.mode;
+ else { paint.mode = grid[r][c] === EMPTY_CELL ? symbol : EMPTY_CELL; grid[r][c] = paint.mode; }
+ } else {
+ if (dragging) return;
+ // stamp a whole part into the lattice position under the pointer
+ const p = Math.floor(c / 4);
+ const voices = +voicesIn.value, period = 4 * voices - 1 + LINE_GAP_ROWS;
+ const at = r % period;
+ if (at >= 4 * voices - 1) return;
+ const top = r - (at % 4);
+ const shape = tool === 'ERASE' ? ['...', '...', '...'] : tool === 'NUMBER'
+ ? numberShape(Math.max(0, Math.min(511, Math.round(+numIn.value || 0)))) : en.parts[tool].shape;
+ const ch = tool === 'ERASE' ? EMPTY_CELL : symbol;
+ for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) {
+ if (top + i < grid.length) grid[top + i][p * 4 + j] = shape[i][j] === '#' ? ch : EMPTY_CELL;
+ }
+ }
+ drawEditor(); fromDrawing();
+ };
+ editor.addEventListener('pointerdown', (ev) => { ev.preventDefault(); editor.setPointerCapture(ev.pointerId); paint(ev, false); });
+ editor.addEventListener('pointermove', (ev) => { if (ev.buttons && tool === 'CELL') paint(ev, true); });
+ $('#gs-to-source').addEventListener('click', () => {
+ if (!back.textContent) return;
+ src.value = back.textContent; fromSource();
+ $('#forward').scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
+ });
+
+ // start: a shared link, or the first example; the editor starts from its drawing
+ const shared = new URLSearchParams(location.search).get('s');
+ src.value = shared ?? examples.options[1].value.replaceAll('\\n', '\n');
+ fromSource();
+ drawing.value = lastRows.join('\n');
+ fromDrawing(true);
+ if (!grid.length) relayout();
+}
+
+// ------------------------------------------------------------------ start
+
+const main = $('main[data-gs]');
+loadVocab().then((v) => {
+ const page = main?.dataset.gs;
+ if (page === 'explainer') explainer(v);
+ if (page === 'dictionary') dictionary(v);
+ if (page === 'converter') converter(v);
+}).catch((err) => {
+ console.error(err);
+ const box = $('[data-gs-fail]');
+ if (box) box.hidden = false;
+});
diff --git a/kit/nav.cn.json b/kit/nav.cn.json
index c75b427..8ae3c89 100644
--- a/kit/nav.cn.json
+++ b/kit/nav.cn.json
@@ -30,6 +30,12 @@
"url": "https://interstellar.interimm.org/signals/",
"children": []
},
+ {
+ "id": "script",
+ "name": "网格文字",
+ "url": "https://interstellar.interimm.org/script/",
+ "children": []
+ },
{
"id": "pulsars",
"name": "脉冲星账本",
@@ -86,6 +92,10 @@
"title": "五段发射(可收听)",
"url": "https://interstellar.interimm.org/signals/"
},
+ {
+ "title": "网格文字 · 词典与转换器",
+ "url": "https://interstellar.interimm.org/script/"
+ },
{
"title": "脉冲星账本(实时)",
"url": "https://interstellar.interimm.org/pulsars/"
diff --git a/kit/nav.en.json b/kit/nav.en.json
index 9e7c74b..2d3ec03 100644
--- a/kit/nav.en.json
+++ b/kit/nav.en.json
@@ -30,6 +30,12 @@
"url": "https://interstellar.interimm.org/signals/en/",
"children": []
},
+ {
+ "id": "script",
+ "name": "Grid script",
+ "url": "https://interstellar.interimm.org/script/en/",
+ "children": []
+ },
{
"id": "pulsars",
"name": "Pulsar ledger",
@@ -86,6 +92,10 @@
"title": "Five transmissions (listen)",
"url": "https://interstellar.interimm.org/signals/en/"
},
+ {
+ "title": "Grid script · dictionary and converter",
+ "url": "https://interstellar.interimm.org/script/en/"
+ },
{
"title": "Pulsar ledger (live)",
"url": "https://interstellar.interimm.org/pulsars/en/"
diff --git a/lib/glyph-vocab.json b/lib/glyph-vocab.json
new file mode 100644
index 0000000..fa3d907
--- /dev/null
+++ b/lib/glyph-vocab.json
@@ -0,0 +1,545 @@
+{
+ "version": 1,
+ "parts": {
+ "SELF": {
+ "shape": [
+ "###",
+ "#.#",
+ "###"
+ ],
+ "thing": "we",
+ "relation": "know"
+ },
+ "OTHER": {
+ "shape": [
+ "###",
+ "#..",
+ "###"
+ ],
+ "thing": "you",
+ "relation": "answer"
+ },
+ "STAR": {
+ "shape": [
+ ".#.",
+ "###",
+ ".#."
+ ],
+ "thing": "star",
+ "relation": "shine on"
+ },
+ "LIGHT": {
+ "shape": [
+ "#.#",
+ ".#.",
+ "#.#"
+ ],
+ "thing": "light",
+ "relation": "see"
+ },
+ "BODY": {
+ "shape": [
+ "###",
+ "###",
+ "###"
+ ],
+ "thing": "world, matter",
+ "relation": "have, contain"
+ },
+ "AIR": {
+ "shape": [
+ "#.#",
+ "...",
+ "#.#"
+ ],
+ "thing": "air",
+ "relation": "carry"
+ },
+ "WATER": {
+ "shape": [
+ "###",
+ "...",
+ "###"
+ ],
+ "thing": "water",
+ "relation": "flow to"
+ },
+ "TIME": {
+ "shape": [
+ "#.#",
+ "#.#",
+ "#.#"
+ ],
+ "thing": "time",
+ "relation": "last, wait"
+ },
+ "VOICE": {
+ "shape": [
+ "#..",
+ "##.",
+ "###"
+ ],
+ "thing": "sound",
+ "relation": "say, send"
+ },
+ "PATH": {
+ "shape": [
+ "...",
+ "###",
+ "..."
+ ],
+ "thing": "way, between",
+ "relation": "go to, reach"
+ },
+ "NOT": {
+ "shape": [
+ "#..",
+ ".#.",
+ "..#"
+ ],
+ "thing": "not",
+ "relation": null
+ },
+ "ONE": {
+ "shape": [
+ "...",
+ ".#.",
+ "..."
+ ],
+ "thing": "one, this, the same",
+ "relation": "is, equals"
+ },
+ "BEFORE": {
+ "shape": [
+ "...",
+ "#..",
+ "..."
+ ],
+ "thing": "earlier",
+ "relation": null
+ },
+ "AFTER": {
+ "shape": [
+ "...",
+ "..#",
+ "..."
+ ],
+ "thing": "later",
+ "relation": null
+ },
+ "OPEN": {
+ "shape": [
+ ".#.",
+ "#.#",
+ ".#."
+ ],
+ "thing": "what goes here?",
+ "relation": null
+ },
+ "COUNT": {
+ "shape": [
+ "...",
+ "...",
+ "###"
+ ],
+ "thing": "number",
+ "relation": null
+ }
+ },
+ "markers": {
+ "_": "no tense",
+ "BEFORE": "past",
+ "ONE": "now",
+ "AFTER": "future",
+ "PATH": "always",
+ "NOT": "not",
+ "OPEN": "asked: does it?"
+ },
+ "words": [
+ {
+ "kind": "SELF",
+ "which": null,
+ "gloss": "we",
+ "domain": "People and voices",
+ "note": "the one drawing"
+ },
+ {
+ "kind": "OTHER",
+ "which": null,
+ "gloss": "you",
+ "domain": "People and voices",
+ "note": "the one reading"
+ },
+ {
+ "kind": "OTHER",
+ "which": "OTHER",
+ "gloss": "a third party",
+ "domain": "People and voices",
+ "note": "\"another you\""
+ },
+ {
+ "kind": "ONE",
+ "which": "SELF",
+ "gloss": "one of us",
+ "domain": "People and voices",
+ "note": "an individual"
+ },
+ {
+ "kind": "ONE",
+ "which": "OTHER",
+ "gloss": "one of you",
+ "domain": "People and voices",
+ "note": "a human, to them"
+ },
+ {
+ "kind": "VOICE",
+ "which": "SELF",
+ "gloss": "our voice",
+ "domain": "People and voices",
+ "note": ""
+ },
+ {
+ "kind": "VOICE",
+ "which": "OTHER",
+ "gloss": "your voice",
+ "domain": "People and voices",
+ "note": ""
+ },
+ {
+ "kind": "STAR",
+ "which": null,
+ "gloss": "star",
+ "domain": "Sky",
+ "note": ""
+ },
+ {
+ "kind": "STAR",
+ "which": "SELF",
+ "gloss": "our star",
+ "domain": "Sky",
+ "note": "Ross 128"
+ },
+ {
+ "kind": "STAR",
+ "which": "OTHER",
+ "gloss": "your star",
+ "domain": "Sky",
+ "note": "the Sun"
+ },
+ {
+ "kind": "STAR",
+ "which": "TIME",
+ "gloss": "pulsar",
+ "domain": "Sky",
+ "note": "a timing star"
+ },
+ {
+ "kind": "STAR",
+ "which": "STAR",
+ "gloss": "galaxy",
+ "domain": "Sky",
+ "note": "star of stars"
+ },
+ {
+ "kind": "AIR",
+ "which": "NOT",
+ "gloss": "space",
+ "domain": "Sky",
+ "note": "no air"
+ },
+ {
+ "kind": "LIGHT",
+ "which": "PATH",
+ "gloss": "ray, signal",
+ "domain": "Sky",
+ "note": "light going"
+ },
+ {
+ "kind": "LIGHT",
+ "which": "BODY",
+ "gloss": "transit, shadow",
+ "domain": "Sky",
+ "note": "light with a world in it"
+ },
+ {
+ "kind": "BODY",
+ "which": null,
+ "gloss": "world, matter",
+ "domain": "Worlds",
+ "note": ""
+ },
+ {
+ "kind": "BODY",
+ "which": "SELF",
+ "gloss": "our world",
+ "domain": "Worlds",
+ "note": "Ross 128 b"
+ },
+ {
+ "kind": "BODY",
+ "which": "OTHER",
+ "gloss": "your world",
+ "domain": "Worlds",
+ "note": "Earth"
+ },
+ {
+ "kind": "BODY",
+ "which": "AIR",
+ "gloss": "air-world",
+ "domain": "Worlds",
+ "note": ""
+ },
+ {
+ "kind": "BODY",
+ "which": "WATER",
+ "gloss": "water-world",
+ "domain": "Worlds",
+ "note": ""
+ },
+ {
+ "kind": "BODY",
+ "which": "BODY",
+ "gloss": "rock, ground",
+ "domain": "Worlds",
+ "note": "matter of matter"
+ },
+ {
+ "kind": "BODY",
+ "which": "PATH",
+ "gloss": "orbit",
+ "domain": "Worlds",
+ "note": "a world going"
+ },
+ {
+ "kind": "LIGHT",
+ "which": null,
+ "gloss": "light",
+ "domain": "Their surface",
+ "note": ""
+ },
+ {
+ "kind": "LIGHT",
+ "which": "NOT",
+ "gloss": "dark, the night side",
+ "domain": "Their surface",
+ "note": ""
+ },
+ {
+ "kind": "PATH",
+ "which": "LIGHT",
+ "gloss": "terminator",
+ "domain": "Their surface",
+ "note": "the way between light and dark"
+ },
+ {
+ "kind": "AIR",
+ "which": null,
+ "gloss": "air",
+ "domain": "Their surface",
+ "note": ""
+ },
+ {
+ "kind": "AIR",
+ "which": "PATH",
+ "gloss": "wind",
+ "domain": "Their surface",
+ "note": "air going"
+ },
+ {
+ "kind": "AIR",
+ "which": "WATER",
+ "gloss": "cloud",
+ "domain": "Their surface",
+ "note": "watery air"
+ },
+ {
+ "kind": "WATER",
+ "which": null,
+ "gloss": "water",
+ "domain": "Their surface",
+ "note": ""
+ },
+ {
+ "kind": "WATER",
+ "which": "PATH",
+ "gloss": "river",
+ "domain": "Their surface",
+ "note": "water going"
+ },
+ {
+ "kind": "WATER",
+ "which": "BODY",
+ "gloss": "ice",
+ "domain": "Their surface",
+ "note": "solid water"
+ },
+ {
+ "kind": "WATER",
+ "which": "NOT",
+ "gloss": "dry land",
+ "domain": "Their surface",
+ "note": ""
+ },
+ {
+ "kind": "TIME",
+ "which": null,
+ "gloss": "time",
+ "domain": "Time",
+ "note": ""
+ },
+ {
+ "kind": "TIME",
+ "which": "BEFORE",
+ "gloss": "the past, before",
+ "domain": "Time",
+ "note": ""
+ },
+ {
+ "kind": "TIME",
+ "which": "ONE",
+ "gloss": "now",
+ "domain": "Time",
+ "note": ""
+ },
+ {
+ "kind": "TIME",
+ "which": "AFTER",
+ "gloss": "the future, after",
+ "domain": "Time",
+ "note": ""
+ },
+ {
+ "kind": "TIME",
+ "which": "PATH",
+ "gloss": "always",
+ "domain": "Time",
+ "note": "time going on"
+ },
+ {
+ "kind": "TIME",
+ "which": "NOT",
+ "gloss": "never",
+ "domain": "Time",
+ "note": ""
+ },
+ {
+ "kind": "TIME",
+ "which": "BODY",
+ "gloss": "year",
+ "domain": "Time",
+ "note": "one orbit"
+ },
+ {
+ "kind": "TIME",
+ "which": "VOICE",
+ "gloss": "rhythm, beat",
+ "domain": "Time",
+ "note": ""
+ },
+ {
+ "kind": "TIME",
+ "which": "TIME",
+ "gloss": "an age",
+ "domain": "Time",
+ "note": "time of times"
+ },
+ {
+ "kind": "VOICE",
+ "which": null,
+ "gloss": "sound, voice",
+ "domain": "Sound and talk",
+ "note": ""
+ },
+ {
+ "kind": "VOICE",
+ "which": "PATH",
+ "gloss": "message",
+ "domain": "Sound and talk",
+ "note": "voice going"
+ },
+ {
+ "kind": "VOICE",
+ "which": "NOT",
+ "gloss": "silence",
+ "domain": "Sound and talk",
+ "note": ""
+ },
+ {
+ "kind": "VOICE",
+ "which": "VOICE",
+ "gloss": "conversation",
+ "domain": "Sound and talk",
+ "note": "voice with voice"
+ },
+ {
+ "kind": "VOICE",
+ "which": "ONE",
+ "gloss": "monologue",
+ "domain": "Sound and talk",
+ "note": "one voice"
+ },
+ {
+ "kind": "VOICE",
+ "which": "AIR",
+ "gloss": "speech",
+ "domain": "Sound and talk",
+ "note": "sound in air"
+ },
+ {
+ "kind": "VOICE",
+ "which": "LIGHT",
+ "gloss": "radio",
+ "domain": "Sound and talk",
+ "note": "sound carried by light"
+ },
+ {
+ "kind": "VOICE",
+ "which": "BODY",
+ "gloss": "resonator, instrument",
+ "domain": "Sound and talk",
+ "note": "a body that sounds"
+ },
+ {
+ "kind": "VOICE",
+ "which": "OPEN",
+ "gloss": "a question",
+ "domain": "Sound and talk",
+ "note": ""
+ },
+ {
+ "kind": "ONE",
+ "which": null,
+ "gloss": "that",
+ "domain": "Logic and number",
+ "note": "alone in a node slot: the line above"
+ },
+ {
+ "kind": "ONE",
+ "which": "ONE",
+ "gloss": "the same",
+ "domain": "Logic and number",
+ "note": ""
+ },
+ {
+ "kind": "ONE",
+ "which": "NOT",
+ "gloss": "different",
+ "domain": "Logic and number",
+ "note": ""
+ },
+ {
+ "kind": "OPEN",
+ "which": "COUNT",
+ "gloss": "how many?",
+ "domain": "Logic and number",
+ "note": "\"what number?\"; answer with COUNT.n"
+ },
+ {
+ "kind": "OPEN",
+ "which": null,
+ "gloss": "what?",
+ "domain": "Logic and number",
+ "note": ""
+ }
+ ]
+}
diff --git a/lib/glyph-zh.json b/lib/glyph-zh.json
new file mode 100644
index 0000000..f1b54e7
--- /dev/null
+++ b/lib/glyph-zh.json
@@ -0,0 +1,90 @@
+{
+ "about": "Chinese glosses for lib/glyph-vocab.json, used by the /script/ pages. Keys are the words as glyph-cli writes them.",
+ "parts": {
+ "SELF": {"thing": "我们", "relation": "知道"},
+ "OTHER": {"thing": "你们", "relation": "回答"},
+ "STAR": {"thing": "恒星", "relation": "照耀"},
+ "LIGHT": {"thing": "光", "relation": "看见"},
+ "BODY": {"thing": "世界、物质", "relation": "拥有、包含"},
+ "AIR": {"thing": "空气", "relation": "承载"},
+ "WATER": {"thing": "水", "relation": "流向"},
+ "TIME": {"thing": "时间", "relation": "持续、等待"},
+ "VOICE": {"thing": "声音", "relation": "说、发送"},
+ "PATH": {"thing": "路、之间", "relation": "去、到达"},
+ "NOT": {"thing": "不", "relation": null},
+ "ONE": {"thing": "一、这、同一", "relation": "是、等于"},
+ "BEFORE": {"thing": "之前", "relation": null},
+ "AFTER": {"thing": "之后", "relation": null},
+ "OPEN": {"thing": "这里填什么?", "relation": null},
+ "COUNT": {"thing": "数", "relation": null}
+ },
+ "markers": {
+ "_": "无时态", "BEFORE": "过去", "ONE": "现在", "AFTER": "将来", "PATH": "一直", "NOT": "否定", "OPEN": "问:是否?"
+ },
+ "domains": {
+ "People and voices": "人与声音",
+ "Sky": "天空",
+ "Worlds": "世界",
+ "Their surface": "他们的地表",
+ "Time": "时间",
+ "Sound and talk": "声音与交谈",
+ "Logic and number": "逻辑与数"
+ },
+ "words": {
+ "SELF": {"gloss": "我们", "note": "画的一方"},
+ "OTHER": {"gloss": "你们", "note": "读的一方"},
+ "OTHER.OTHER": {"gloss": "第三方", "note": "“另一个你们”"},
+ "ONE.SELF": {"gloss": "我们中的一个", "note": "一个个体"},
+ "ONE.OTHER": {"gloss": "你们中的一个", "note": "在他们看来:一个人类"},
+ "VOICE.SELF": {"gloss": "我们的声音", "note": ""},
+ "VOICE.OTHER": {"gloss": "你们的声音", "note": ""},
+ "STAR": {"gloss": "恒星", "note": ""},
+ "STAR.SELF": {"gloss": "我们的恒星", "note": "罗斯128"},
+ "STAR.OTHER": {"gloss": "你们的恒星", "note": "太阳"},
+ "STAR.TIME": {"gloss": "脉冲星", "note": "计时的星"},
+ "STAR.STAR": {"gloss": "星系", "note": "星中之星"},
+ "AIR.NOT": {"gloss": "太空", "note": "没有空气"},
+ "LIGHT.PATH": {"gloss": "光线、信号", "note": "行进的光"},
+ "LIGHT.BODY": {"gloss": "凌日、影子", "note": "光里有一个世界"},
+ "BODY": {"gloss": "世界、物质", "note": ""},
+ "BODY.SELF": {"gloss": "我们的世界", "note": "罗斯128 b"},
+ "BODY.OTHER": {"gloss": "你们的世界", "note": "地球"},
+ "BODY.AIR": {"gloss": "空气世界", "note": ""},
+ "BODY.WATER": {"gloss": "水世界", "note": ""},
+ "BODY.BODY": {"gloss": "岩石、地面", "note": "物质中的物质"},
+ "BODY.PATH": {"gloss": "轨道", "note": "行进的世界"},
+ "LIGHT": {"gloss": "光", "note": ""},
+ "LIGHT.NOT": {"gloss": "黑暗、夜面", "note": ""},
+ "PATH.LIGHT": {"gloss": "晨昏线", "note": "明与暗之间的路"},
+ "AIR": {"gloss": "空气", "note": ""},
+ "AIR.PATH": {"gloss": "风", "note": "行进的空气"},
+ "AIR.WATER": {"gloss": "云", "note": "含水的空气"},
+ "WATER": {"gloss": "水", "note": ""},
+ "WATER.PATH": {"gloss": "河", "note": "行进的水"},
+ "WATER.BODY": {"gloss": "冰", "note": "固态的水"},
+ "WATER.NOT": {"gloss": "干燥的陆地", "note": ""},
+ "TIME": {"gloss": "时间", "note": ""},
+ "TIME.BEFORE": {"gloss": "过去、之前", "note": ""},
+ "TIME.ONE": {"gloss": "现在", "note": ""},
+ "TIME.AFTER": {"gloss": "将来、之后", "note": ""},
+ "TIME.PATH": {"gloss": "永远", "note": "流逝的时间"},
+ "TIME.NOT": {"gloss": "从不", "note": ""},
+ "TIME.BODY": {"gloss": "年", "note": "一次公转"},
+ "TIME.VOICE": {"gloss": "节奏、节拍", "note": ""},
+ "TIME.TIME": {"gloss": "纪元", "note": "时间中的时间"},
+ "VOICE": {"gloss": "声音、嗓音", "note": ""},
+ "VOICE.PATH": {"gloss": "消息", "note": "行进的声音"},
+ "VOICE.NOT": {"gloss": "寂静", "note": ""},
+ "VOICE.VOICE": {"gloss": "对话", "note": "声音与声音"},
+ "VOICE.ONE": {"gloss": "独白", "note": "一个声音"},
+ "VOICE.AIR": {"gloss": "言语", "note": "空气里的声音"},
+ "VOICE.LIGHT": {"gloss": "无线电", "note": "由光承载的声音"},
+ "VOICE.BODY": {"gloss": "共鸣体、乐器", "note": "会发声的物体"},
+ "VOICE.OPEN": {"gloss": "一个问题", "note": ""},
+ "ONE": {"gloss": "那", "note": "单独放在节点位置:指上一行"},
+ "ONE.ONE": {"gloss": "相同", "note": ""},
+ "ONE.NOT": {"gloss": "不同", "note": ""},
+ "OPEN.COUNT": {"gloss": "多少?", "note": "“什么数?”,用 COUNT.n 回答"},
+ "OPEN": {"gloss": "什么?", "note": ""}
+ }
+}
diff --git a/lib/glyph.js b/lib/glyph.js
new file mode 100644
index 0000000..50f6440
--- /dev/null
+++ b/lib/glyph.js
@@ -0,0 +1,473 @@
+// The grid script, in the browser: a port of glyph-cli 0.2.0 (https://github.com/InterImm/glyph-cli).
+// Parts, words, pages, drawings and the knowledge-graph reading behave exactly as the `glyph` command does,
+// and lib/glyph-vocab.json is a copy of its bundled vocabulary; tests/glyph.test.mjs checks the two agree.
+// No DOM here, so the same file runs under node for the tests.
+
+export const POSITIONS = 6;
+export const WIDTH = POSITIONS * 4 - 1; // 23 cells
+export const BAND_GAP = 1;
+export const LINE_GAP = 3;
+export const EMPTY = '_';
+export const EMPTY_CELL = '.';
+export const MARK = '#';
+export const NUMBER = 'COUNT'; // the kind whose which is a number
+export const MAX_NUMBER = 511;
+export const SLOTS = ['subject', 'relation', 'object'];
+export const HALVES = ['kind', 'which'];
+export const RESERVED_SYMBOLS = new Set([...'.#|:_ \t']);
+
+// Colours of the phase 2 ("137") design: signal blue, white-cyan for them, Sol yellow for us.
+export const BACKGROUND = '#0d1457';
+export const GRID = '#141d6e';
+export const INKS = { '+': '#9ff8ff', '×': '#ffd84a', x: '#ffd84a' };
+export const OTHER_INKS = ['#c9b3ff', '#7dffa8', '#ffa45c', '#ffffff'];
+
+// Fixed words of the readings; a translation can replace them (see Vocabulary.labels).
+export const LABELS = {
+ none: '—',
+ notInVocabulary: '(not in vocabulary)',
+ statement: (n) => `[statement ${n}]`,
+ thatNothing: '[that: nothing above]',
+ yes: 'yes', no: 'no', instead: 'instead', 'and also': 'and also',
+ to: (s) => ` (to ${s})`,
+ quote: (s) => ` "${s}"`,
+ noComment: 'present, no comment',
+ edges: 'Edges:', nodes: 'Nodes:',
+};
+
+/** A problem the reader can fix. `code` and `args` let a page say it in another language. */
+export class GlyphError extends Error {
+ constructor(message, code = 'error', args = {}) {
+ super(message);
+ this.name = 'GlyphError';
+ this.code = code;
+ this.args = args;
+ }
+}
+
+// ---------- words ----------
+// A word is { kind, which }: kind is a part name or null, which is a part name, a number or null.
+
+export const word = (kind = null, which = null) => ({ kind, which });
+export const isEmpty = (w) => w.kind === null && w.which === null;
+export const isNumber = (w) => typeof w.which === 'number';
+export const halves = (w) => [w.kind, w.which];
+export const sameWord = (a, b) => a.kind === b.kind && a.which === b.which;
+export const THAT = word('ONE', null);
+
+export function wordStr(w) {
+ if (isEmpty(w)) return EMPTY;
+ return (w.kind ?? EMPTY) + (w.which === null ? '' : `.${w.which}`);
+}
+
+// ---------- the vocabulary ----------
+
+export class Vocabulary {
+ /** `data` is a vocab.json object; `labels` replaces any of LABELS (for a translation). */
+ constructor(data, labels = {}) {
+ if (!data || typeof data !== 'object' || !data.parts || !data.words) {
+ throw new GlyphError('not a glyph vocabulary file', 'vocabulary');
+ }
+ this.version = data.version ?? 1;
+ this.parts = {};
+ for (const [name, p] of Object.entries(data.parts)) {
+ this.parts[name] = { name, shape: [...p.shape], thing: p.thing, relation: p.relation ?? null };
+ }
+ this.markers = { ...(data.markers || {}) };
+ this.entries = data.words.map((e) => ({
+ word: word(e.kind, e.which), gloss: e.gloss, domain: e.domain ?? 'Unsorted', note: e.note ?? '',
+ }));
+ this.labels = { ...LABELS, ...labels };
+ }
+
+ /** 'BODY.OTHER' -> {kind: 'BODY', which: 'OTHER'}; 'COUNT.137' -> {kind: 'COUNT', which: 137}. */
+ parse(text) {
+ const s = String(text).trim();
+ if (s === '' || s === EMPTY) return word();
+ const dot = s.indexOf('.');
+ const k = dot < 0 ? s : s.slice(0, dot);
+ const w = dot < 0 ? '' : s.slice(dot + 1);
+ const kind = k === '' || k === EMPTY ? null : k.toUpperCase();
+ let which;
+ if (w === '' || w === EMPTY) {
+ which = null;
+ } else if (/^[0-9]+$/.test(w)) {
+ which = parseInt(w, 10);
+ if (which > MAX_NUMBER) throw new GlyphError(`number out of range 0-${MAX_NUMBER}: ${which}`, 'range', { n: which });
+ if (kind !== NUMBER && kind !== null) {
+ throw new GlyphError(`a number needs ${NUMBER} as its kind, e.g. ${NUMBER}.${which} (got ${s})`, 'numberKind', { n: which, s });
+ }
+ if (kind === NUMBER && which === 0) which = null; // COUNT alone is zero: COUNT.0 draws exactly like it
+ } else {
+ which = w.toUpperCase();
+ if (kind === NUMBER) throw new GlyphError(`${NUMBER} only takes a number as its which, e.g. ${NUMBER}.12 (got ${s})`, 'countWhich', { s });
+ }
+ for (const half of [kind, which]) {
+ if (typeof half === 'string' && !(half in this.parts)) throw new GlyphError(`unknown part: ${half}`, 'unknownPart', { part: half });
+ }
+ return word(kind, which);
+ }
+
+ lookup(w) {
+ return this.entries.find((e) => sameWord(e.word, w)) || null;
+ }
+
+ /** The meaning of one half on its own. */
+ thing(half) {
+ if (half === null) return EMPTY;
+ if (typeof half === 'number') return String(half);
+ return this.parts[half].thing;
+ }
+
+ /** The best human reading of a word as a 'node' or as a 'relation'. */
+ gloss(w, role = 'node') {
+ if (isEmpty(w)) return this.labels.none;
+ if (role === 'relation') {
+ let base = '';
+ if (w.kind) {
+ const part = this.parts[w.kind];
+ base = part.relation || part.thing;
+ }
+ if (w.which === null) return base;
+ const marker = this.markers[String(w.which)] ?? String(w.which);
+ return `${base} [${marker}]`;
+ }
+ if (w.kind === NUMBER && (isNumber(w) || w.which === null)) return String(w.which || 0);
+ const entry = this.lookup(w);
+ if (entry) return entry.gloss;
+ if (w.which === null) return this.parts[w.kind].thing;
+ return `${this.thing(w.kind)} | ${this.thing(w.which)} ${this.labels.notInVocabulary}`;
+ }
+
+ search(text) {
+ const t = text.toLowerCase();
+ const words = this.entries.filter((e) => e.gloss.toLowerCase().includes(t) || e.note.toLowerCase().includes(t));
+ const parts = Object.values(this.parts).filter((p) => p.thing.toLowerCase().includes(t) || (p.relation && p.relation.toLowerCase().includes(t)));
+ return { words, parts };
+ }
+
+ /** A list of problems; empty means the vocabulary is sound. */
+ validate() {
+ const errors = [];
+ const shapes = new Map();
+ for (const p of Object.values(this.parts)) {
+ if (p.shape.length !== 3 || p.shape.some((r) => r.length !== 3 || /[^#.]/.test(r))) errors.push(`part ${p.name}: shape must be 3 rows of 3 "#"/"."`);
+ const key = p.shape.join('/');
+ if (shapes.has(key)) errors.push(`parts ${shapes.get(key)} and ${p.name} have the same shape`);
+ shapes.set(key, p.name);
+ if (!p.shape.some((r) => r.includes(MARK))) errors.push(`part ${p.name}: shape is empty`);
+ }
+ const seen = new Map();
+ const glosses = new Map();
+ for (const e of this.entries) {
+ const ws = wordStr(e.word);
+ for (const half of halves(e.word)) {
+ if (typeof half === 'string' && !(half in this.parts)) errors.push(`${ws}: unknown part ${half}`);
+ }
+ if (e.word.kind === NUMBER) errors.push(`${ws}: ${NUMBER} words are numbers and are built in`);
+ if (seen.has(ws)) errors.push(`${ws} defined twice ("${seen.get(ws)}" and "${e.gloss}")`);
+ seen.set(ws, e.gloss);
+ const g = e.gloss.toLowerCase();
+ if (glosses.has(g)) errors.push(`meaning "${e.gloss}" used by ${glosses.get(g)} and ${ws}`);
+ glosses.set(g, ws);
+ }
+ return errors;
+ }
+
+ domains() {
+ const out = [];
+ for (const e of this.entries) if (!out.includes(e.domain)) out.push(e.domain);
+ return out;
+ }
+}
+
+// ---------- pages ----------
+// A page is { lines: [{ bands: [{ symbol, words: [w, w, w] }] }] }. The first band of a line is the statement.
+
+export const bandHalves = (band) => band.words.flatMap(halves);
+export const pageVoices = (page) => page.lines.reduce((n, line) => Math.max(n, line.bands.length), 0);
+
+export function pageSymbols(page) {
+ const out = [];
+ for (const line of page.lines) for (const band of line.bands) if (!out.includes(band.symbol)) out.push(band.symbol);
+ return out;
+}
+
+export function checkSymbol(symbol) {
+ if ([...symbol].length !== 1 || RESERVED_SYMBOLS.has(symbol)) {
+ throw new GlyphError(`a symbol is one character, not a space or any of # . : _ |: '${symbol}'`, 'symbol', { symbol });
+ }
+ return symbol;
+}
+
+/** Read page source ("SYMBOL: node | relation | node", one band per line) into a page. */
+export function parsePage(text, vocab) {
+ const page = { lines: [] };
+ let current = { bands: [] };
+ text.split(/\r?\n/).forEach((raw, i) => {
+ const line = raw.split('#', 1)[0].trimEnd();
+ if (!line.trim()) {
+ if (current.bands.length) { page.lines.push(current); current = { bands: [] }; }
+ return;
+ }
+ const at = (err) => new GlyphError(`line ${i + 1}: ${err.message}`, err.code, { ...err.args, line: i + 1 });
+ const colon = line.indexOf(':');
+ if (colon < 0) {
+ throw new GlyphError(`line ${i + 1}: expected "SYMBOL: node | relation | node", got '${raw.trim()}'`, 'format', { line: i + 1 });
+ }
+ const symbol = line.slice(0, colon).trim();
+ try { checkSymbol(symbol); } catch (err) { throw at(err); }
+ const cells = line.slice(colon + 1).split('|');
+ if (cells.length !== 3) {
+ throw new GlyphError(`line ${i + 1}: need exactly three slots separated by |, got ${cells.length}`, 'slots', { line: i + 1, n: cells.length });
+ }
+ let words;
+ try { words = cells.map((c) => vocab.parse(c)); } catch (err) { throw at(err); }
+ current.bands.push({ symbol, words });
+ });
+ if (current.bands.length) page.lines.push(current);
+ return page;
+}
+
+/** Write a page back as page source. */
+export function formatPage(page) {
+ const blocks = page.lines.map((line) => line.bands.map((b) => `${b.symbol}: ${b.words.map(wordStr).join(' | ')}`).join('\n'));
+ return blocks.join('\n\n') + (blocks.length ? '\n' : '');
+}
+
+// ---------- drawing ----------
+
+/** Nine bits, 256 ... 1, left to right, top to bottom. */
+export function numberShape(n) {
+ const bits = n.toString(2).padStart(9, '0');
+ return [0, 1, 2].map((r) => [0, 1, 2].map((c) => (bits[r * 3 + c] === '1' ? MARK : EMPTY_CELL)).join(''));
+}
+
+export function shape(vocab, half) {
+ if (half === null) return [EMPTY_CELL.repeat(3), EMPTY_CELL.repeat(3), EMPTY_CELL.repeat(3)];
+ if (typeof half === 'number') return numberShape(half);
+ return vocab.parts[half].shape;
+}
+
+/** Three rows of text for a run of positions drawn in one symbol. */
+export function bandRows(vocab, hs, symbol) {
+ const rows = ['', '', ''];
+ hs.forEach((half, i) => {
+ const s = shape(vocab, half);
+ for (let r = 0; r < 3; r++) rows[r] += (i ? EMPTY_CELL : '') + s[r].replaceAll(MARK, symbol);
+ });
+ return rows;
+}
+
+/** Words side by side, as a quick look-up drawing (not a page). */
+export function drawWords(vocab, words, symbol = '+', spacing = 3) {
+ const blocks = words.map((w) => bandRows(vocab, halves(w), symbol));
+ return [0, 1, 2].map((r) => blocks.map((b) => b[r]).join(' '.repeat(spacing)));
+}
+
+/** Draw a page as rows of text. Every line gets as many bands as the page has voices. */
+export function render(vocab, page) {
+ const blank = EMPTY_CELL.repeat(WIDTH);
+ const n = pageVoices(page);
+ const out = [];
+ page.lines.forEach((line, li) => {
+ if (li) for (let i = 0; i < LINE_GAP; i++) out.push(blank);
+ for (let k = 0; k < n; k++) {
+ if (k) for (let i = 0; i < BAND_GAP; i++) out.push(blank);
+ if (k < line.bands.length) out.push(...bandRows(vocab, bandHalves(line.bands[k]), line.bands[k].symbol));
+ else out.push(blank, blank, blank);
+ }
+ });
+ return out;
+}
+
+export function ink(symbol, symbols) {
+ if (symbol in INKS) return INKS[symbol];
+ const others = symbols.filter((s) => !(s in INKS));
+ return OTHER_INKS[Math.max(0, others.indexOf(symbol)) % OTHER_INKS.length];
+}
+
+const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
+
+/** Draw rows of text as SVG: square pixels on signal blue, no curves. */
+export function rowsSvg(rows, symbols, { cell = 12, pad = 1, grid = true, width = WIDTH } = {}) {
+ const w = width + 2 * pad, h = rows.length + 2 * pad;
+ const out = [
+ ``,
+ ` `,
+ ];
+ const inset = Math.max(1, Math.floor(cell / 12));
+ const size = cell - 2 * inset;
+ rows.forEach((row, r) => {
+ [...row].forEach((ch, c) => {
+ const box = `x="${(c + pad) * cell + inset}" y="${(r + pad) * cell + inset}" width="${size}" height="${size}"`;
+ if (ch !== EMPTY_CELL && ch !== ' ') out.push(`${esc(ch)} `);
+ else if (grid && ch === EMPTY_CELL) out.push(` `);
+ });
+ });
+ out.push(' ');
+ return out.join('\n') + '\n';
+}
+
+/** Draw a page as SVG, as `glyph render --svg` does. */
+export function renderSvg(vocab, page, opts = {}) {
+ return rowsSvg(render(vocab, page), pageSymbols(page), opts);
+}
+
+// ---------- reading a drawing back ----------
+
+/** How many bands each line of a drawing has, from where its empty rows fall (throws if they don't fit). */
+export function structure(rows) {
+ const empty = rows.map((row) => !row.replaceAll(EMPTY_CELL, ''));
+ const total = rows.length;
+ for (let n = 1; n <= total; n++) {
+ const lineH = 4 * n - 1;
+ const period = lineH + LINE_GAP;
+ if ((total + LINE_GAP) % period) continue;
+ let ok = true;
+ for (let i = 0; i < total; i++) {
+ const at = i % period;
+ const gap = at >= lineH || at % 4 === 3;
+ if (gap && !empty[i]) { ok = false; break; }
+ }
+ if (ok) return n;
+ }
+ throw new GlyphError("the rows don't fall into lines and bands; is this a picture rather than text?", 'structure');
+}
+
+/**
+ * Read a drawing (as made by render) back into a page. A which is read as nine bits only after COUNT,
+ * or with no kind under a line whose word there is a COUNT word (a reply like _.12); everywhere else
+ * it must be a part.
+ */
+export function decode(vocab, text) {
+ const rows = text.split(/\r?\n/).map((r) => r.trim()).filter(Boolean);
+ const page = { lines: [] };
+ if (!rows.length) return page;
+ rows.forEach((row, i) => {
+ const len = [...row].length;
+ if (len !== WIDTH) {
+ throw new GlyphError(`row ${i + 1} is ${len} cells wide; text is always ${WIDTH} (off the lattice: a picture?)`, 'width', { row: i + 1, len });
+ }
+ const cells = [...row];
+ for (let c = 3; c < WIDTH; c += 4) {
+ if (cells[c] !== EMPTY_CELL) throw new GlyphError(`row ${i + 1}, column ${c + 1}: a mark between lattice positions (a picture?)`, 'between', { row: i + 1, col: c + 1 });
+ }
+ });
+ const grid = rows.map((r) => [...r]);
+ const n = structure(rows);
+ const period = 4 * n - 1 + LINE_GAP;
+ const byShape = new Map(Object.values(vocab.parts).map((p) => [p.shape.join('/'), p.name]));
+ for (let top = 0; top < grid.length; top += period) {
+ const line = { bands: [] };
+ for (let k = 0; k < n; k++) {
+ const r0 = top + 4 * k;
+ const band = grid.slice(r0, r0 + 3);
+ const marks = new Set(band.flat().filter((ch) => ch !== EMPTY_CELL));
+ if (!marks.size) continue;
+ const where = `rows ${r0 + 1}-${r0 + 3}`;
+ if (marks.size > 1) throw new GlyphError(`${where}: one band mixes symbols ${[...marks].sort().join(' ')}`, 'mixed', { from: r0 + 1, to: r0 + 3 });
+ const symbol = checkSymbol([...marks][0]);
+ const hs = [];
+ for (let pos = 0; pos < POSITIONS; pos++) {
+ const pattern = band.map((row) => row.slice(pos * 4, pos * 4 + 3).map((ch) => (ch !== EMPTY_CELL ? MARK : EMPTY_CELL)).join(''));
+ if (!pattern.some((p) => p.includes(MARK))) { hs.push(null); continue; }
+ const slot = Math.floor(pos / 2);
+ if (pos % 2 === 0) {
+ const name = byShape.get(pattern.join('/'));
+ if (name === undefined) throw new GlyphError(`${where}, position ${pos + 1}: not a part`, 'notPart', { from: r0 + 1, to: r0 + 3, pos: pos + 1 });
+ hs.push(name);
+ } else {
+ const above = line.bands.length ? line.bands[0].words[slot] : null;
+ const kind = hs[hs.length - 1];
+ let which;
+ if (kind === NUMBER || (kind === null && above !== null && (above.kind === NUMBER || above.which === NUMBER))) {
+ which = parseInt(pattern.join('').replaceAll(EMPTY_CELL, '0').replaceAll(MARK, '1'), 2);
+ } else {
+ which = byShape.get(pattern.join('/')) ?? null;
+ }
+ if (which === null) throw new GlyphError(`${where}, position ${pos + 1}: not a part or a number here`, 'notWhich', { from: r0 + 1, to: r0 + 3, pos: pos + 1 });
+ hs.push(which);
+ }
+ }
+ line.bands.push({ symbol, words: [0, 2, 4].map((i) => word(hs[i], hs[i + 1])) });
+ }
+ if (line.bands.length) page.lines.push(line);
+ }
+ return page;
+}
+
+// ---------- reading a page as a knowledge graph (grammar rules 3 and 5) ----------
+
+export function respond(above, below) {
+ if (below === null) return null;
+ if (above === null) return 'and also';
+ if (below === above) return 'yes';
+ if (below === 'NOT') return 'no';
+ return 'instead';
+}
+
+export function nodeLabel(node, labels = LABELS) {
+ if (node.statement !== null) return node.statement ? labels.statement(node.statement) : labels.thatNothing;
+ return node.gloss;
+}
+
+export function responseText(r, labels = LABELS) {
+ const tail = r.meaning === 'yes' || r.meaning === 'no' ? '' : labels.quote(r.part);
+ const to = r.to ? labels.to(r.to) : '';
+ return `${r.slot}.${r.half}: ${labels[r.meaning]}${tail}${to}`;
+}
+
+/**
+ * Rule 3: the first band of every line is one edge. Rule 5: every part in a lower band answers the
+ * nearest drawn part above it in the same position.
+ */
+export function readGraph(vocab, page) {
+ const g = { edges: [], nodes: {} };
+ page.lines.forEach((line, idx) => {
+ const i = idx + 1;
+ const st = line.bands[0];
+ const [subjectW, relationW, objectW] = st.words;
+ const node = (w) => {
+ if (sameWord(w, THAT)) return { word: null, gloss: '', statement: i - 1 };
+ const gloss = vocab.gloss(w);
+ if (!isEmpty(w) && !(wordStr(w) in g.nodes)) g.nodes[wordStr(w)] = gloss;
+ return { word: isEmpty(w) ? null : wordStr(w), gloss, statement: null };
+ };
+ const subject = node(subjectW);
+ const relation = vocab.gloss(relationW, 'relation');
+ const edge = { number: i, symbol: st.symbol, subject, relation, relationWord: wordStr(relationW), object: node(objectW), replies: [] };
+ line.bands.slice(1).forEach((band, b0) => {
+ const b = b0 + 1;
+ const reply = { symbol: band.symbol, responses: [] };
+ bandHalves(band).forEach((below, pos) => {
+ let above = null, by = null;
+ for (let j = b - 1; j >= 0; j--) {
+ const prev = bandHalves(line.bands[j])[pos];
+ if (prev !== null) { above = prev; by = line.bands[j].symbol; break; }
+ }
+ const meaning = respond(above, below);
+ if (meaning) reply.responses.push({ slot: SLOTS[Math.floor(pos / 2)], half: HALVES[pos % 2], meaning, part: vocab.thing(below), to: by });
+ });
+ edge.replies.push(reply);
+ });
+ g.edges.push(edge);
+ });
+ return g;
+}
+
+/** The graph as text lines, exactly as `glyph graph` prints it (in the vocabulary's own language). */
+export function formatGraph(vocab, g) {
+ const L = vocab.labels;
+ const out = [L.edges];
+ for (const e of g.edges) {
+ out.push(`${e.number}. (${e.symbol}) ${nodeLabel(e.subject, L)} --${e.relation}--> ${nodeLabel(e.object, L)}`);
+ for (const r of e.replies) {
+ const text = r.responses.map((x) => responseText(x, L)).join('; ') || L.noComment;
+ out.push(` (${r.symbol}) ${text}`);
+ }
+ }
+ out.push('', L.nodes);
+ for (const [w, gloss] of Object.entries(g.nodes)) out.push(` ${w.padEnd(14)} ${gloss}`);
+ return out;
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d410951
--- /dev/null
+++ b/package.json
@@ -0,0 +1 @@
+{"type":"module","private":true,"scripts":{"test":"node --test tests/*.test.mjs"}}
diff --git a/script/convert/en/index.html b/script/convert/en/index.html
new file mode 100644
index 0000000..8dc9dd1
--- /dev/null
+++ b/script/convert/en/index.html
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+Grid script converter | The Contact Era
+
+
+
+
+
+
+
+
+
+
+Skip to content
+
+
+
+
+
+
+ The converter needs JavaScript and the vocabulary file; they could not load.
+
+
+
+
+
Text → drawing
+
One band per row: SYMBOL: node | relation | node. Words are KIND.WHICH from the dictionary , numbers are COUNT.n, and _ is empty. Rows under a statement are other voices answering it. A blank row starts the next statement. # starts a comment.
+
+
+
+
Examples
+
+ Pick an example …
+ A conversation
+ We saw your world
+ We no longer see you
+ We saw that
+ Twelve pulsars
+ A question
+ How many?
+ A third voice
+
+
+
Page source
+
+
Symbols: + is them, × is us; any other single character is another voice.
+
+
+
+
+ Copy as text
+ Download SVG
+ Copy link
+ Edit the drawing ↓
+
+
+
The drawing as text
+
+
+
+
+
+
+
+
+
Drawing → text
+
Pick a part and click a position to stamp it, or pick "one cell" and draw freely. The lighter squares are the lattice: text only ever sits there, so a mark between them makes it a picture. You can also paste a drawing as text, one row per line, with . for empty cells.
+
+
+
+
+
The drawing as text
+
+
Read back as page source:
+
+
Use as text ↑
+
+
+
+
+
+
+
+
+
+
diff --git a/script/convert/index.html b/script/convert/index.html
new file mode 100644
index 0000000..1da1428
--- /dev/null
+++ b/script/convert/index.html
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+网格文字转换器 | 通联纪
+
+
+
+
+
+
+
+
+
+
+跳到正文
+
+
+
+
+
+
+ 转换器需要 JavaScript 和词表文件,没能加载。
+
+
+
+
+
文字 → 图
+
每行一个声部:符号: 节点 | 关系 | 节点。词写成词典 里的 类.指,数写成 COUNT.n,_ 表示空。一句下面的行是其他声音在回应它。空一行开始下一句。# 后面是注释。
+
+
+
+
例子
+
+ 选一个例子 …
+ 一场对话
+ 我们看见过你们的世界
+ 我们看不见你们了
+ 我们看见过那个
+ 十二颗脉冲星
+ 一个问题
+ 多少?
+ 第三个声音
+
+
+
页面源文
+
+
符号:+ 是他们,× 是我们;其他任何单个字符是另一个声音。
+
+
+
+
+ 复制文字图
+ 下载 SVG
+ 复制链接
+ 修改这幅图 ↓
+
+
+
用文字表示的图
+
+
+
+
+
+
+
+
+
图 → 文字
+
选一个部件,点一个位置把它盖上去;或者选“单格”随手画。颜色浅一些的方块是格点:文字只会落在那里,画在它们之间的记号会让它变成一幅图。也可以粘贴用文字表示的图,每行一排,. 表示空格。
+
+
+
+
+
用文字表示的图
+
+
读回的页面源文:
+
+
用作文字 ↑
+
+
+
+
+
+
+
+
+
+
diff --git a/script/dictionary/en/index.html b/script/dictionary/en/index.html
new file mode 100644
index 0000000..c575436
--- /dev/null
+++ b/script/dictionary/en/index.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+Grid script dictionary | The Contact Era
+
+
+
+
+
+
+
+
+
+
+Skip to content
+
+
+
+
+
+
+ The dictionary needs JavaScript and the vocabulary file; they could not load.
+
+
+
+
Words
+
+
Search meanings or write a word
+
+
+
+
+
+
+
+
+
+
+
+
The sixteen parts
+
Every word is built from these. A part means a thing in a node and an action in a relation. A word that is not in the dictionary still reads as its two parts.
+
+
+
The same vocabulary ships with glyph , the command-line dictionary. How the parts combine is on the idea page.
+
+
+
+
+
+
+
diff --git a/script/dictionary/index.html b/script/dictionary/index.html
new file mode 100644
index 0000000..59374fe
--- /dev/null
+++ b/script/dictionary/index.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+网格文字词典 | 通联纪
+
+
+
+
+
+
+
+
+
+
+跳到正文
+
+
+
+
+
+
+ 词典需要 JavaScript 和词表文件,没能加载。
+
+
+
+
+
+
+
十六个部件
+
每个词都由它们拼成。部件在节点里表示事物,在关系里表示动作。不在词典里的词,也能按它的两个部件读出来。
+
+
+
同一份词表也随命令行词典 glyph 一起发布。部件怎样组合,见原理 。
+
+
+
+
+
+
+
diff --git a/script/en/index.html b/script/en/index.html
new file mode 100644
index 0000000..e842778
--- /dev/null
+++ b/script/en/index.html
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+The grid script | The Contact Era
+
+
+
+
+
+
+
+
+
+
+Skip to content
+
+
+
+
+
+
+
Archive · the grid script
+
Writing to a voice we have never seen.
+
Once the beacon carried more than a picture, the translating machine needed a way to write it down. This is that script. It is our transcription of what Ross 128 b sends, not their own writing, and nobody knows what they look like. It grows from one idea about their speech: one voice alone can say only a little, and meaning grows when voices join.
+
+
+ The idea Dictionary Converter
+
+
+
+
+ The drawings need JavaScript and the vocabulary file; they could not load.
+
+
+
+
+
1 · The grid
+
One grid, one mark per voice
+
+ Everything is drawn on one shared grid of cells.
+ Each party draws only with its own mark: + for Ross 128 b, × for us. Marks never cover each other, so you can always see who drew what.
+ One grid square of 3 × 3 cells is one part, and two parts make a word, the way strokes make a Chinese character. It is not an alphabet.
+ A line is one statement. Under it, other voices draw their answers.
+
+
So far, everything they have sent is drawn in + alone. The second band in the drawing is a reply humans could send: water, instead of air.
+
+
+
+
+
+
+
+
+
2 · Parts
+
Sixteen parts
+
Each part is a 3 × 3 shape with two meanings: one as a thing, and one (after the arrow) as a relation. The shapes are meant to be guessable: a closed square for us, a square opened toward someone for you, crossing rays for light, a rising staircase for sound.
+
+
+
+
+
+
+
+
+
3 · Words
+
Two parts make a word
+
A word takes two neighbouring positions: the kind on the left and the which on the right, written KIND.WHICH. A world that is yours is your world; a star that keeps time is a pulsar; air going is wind. A one-part word leaves the which empty. The dictionary lists every agreed word.
+
+
+
+
+
+
+
+
+
4 · Lines
+
Every line is one edge of a graph
+
A line is six positions wide (23 cells), so it holds three words: a node, a relation, another node. A text is a stack of lines, so a text is a knowledge graph. The same word on two lines is the same node, and a lone ONE in a node slot means "that": the statement on the line above.
+
+
+
+
A relation is one part for the action, plus a which that marks time or polarity. With LIGHT (see):
+
+
+
+
+
+
+
+
+
The second example is the shadow transmission put into words: we saw you; we do not see you. The third points back at itself: your world is water, and we saw that.
+
+
+
+
+
+
+
5 · Numbers
+
Numbers are nine bits
+
A number is COUNT, a counting line, followed by a which read as nine bits: 256 down to 1, left to right, top to bottom. COUNT alone is zero, so 0 to 511 fit. COUNT is the only kind whose which is read as bits, so a number can never be mistaken for a word.
+
+
+
+
+
+
+
+
+
+
+
+
6 · Voices
+
Stacking means another voice
+
Only a new party adds a band under a line, and each party keeps its band, so the number of bands is the number of voices. Every part in a lower band answers the nearest drawn part above it in the same position. That lets anyone agree with, deny or change any node or edge, part by part. A conversation is a graph several voices build together.
+
+
+
+
+
+
+
+
Questions: OPEN in any position asks "what goes here?", and whatever is drawn under it replaces it. In the last example a third voice, * , draws AIR under our WATER. It answers the nearest part above, which is ours: it sides with them, against us, about our own world.
+
+
+
+
+
+
7 · The machine
+
How the machine reads a page
+
+ Cut the page into 3 × 3 positions. Anything off the lattice is a picture, like the first 667-bit message, not text.
+ Split it into lines at the three-row gaps, and count the bands in a line: that is the number of voices.
+ Read the first band of each line as an edge: node, relation, node. Same word, same node. A lone ONE points at the line above.
+ Read every part in a lower band against the nearest part above it, and attach the answer to that node or edge, with the voice that drew it.
+
+
Humans don't learn the signs; the machine reads and writes them, and its lexicon grows with every page. You can run the same machine: the converter turns text into drawings and drawings back into text, in your browser. It follows the same rules and the same vocabulary as glyph , the command-line dictionary (pip install glyph-cli).
+
+
+
+
+
+
+
diff --git a/script/index.html b/script/index.html
new file mode 100644
index 0000000..1ddcd92
--- /dev/null
+++ b/script/index.html
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+网格文字 | 通联纪
+
+
+
+
+
+
+
+
+
+
+跳到正文
+
+
+
+
+
+
+ 图需要 JavaScript 和词表文件,没能加载。
+
+
+
+
+
1 · 网格
+
一张网格,每个声音一种记号
+
+ 一切都画在同一张共享的网格上。
+ 每一方只用自己的记号画:+ 是罗斯128 b,× 是我们。记号从不互相覆盖,所以总能看出是谁画的。
+ 一块 3 × 3 的格子是一个部件,两个部件组成一个词,就像笔画组成汉字。它不是字母表。
+ 一行是一句话。其他声音在它下面画出自己的回答。
+
+
到目前为止,他们发来的一切都只用 + 画成。图里的第二个声部是人类可以发回去的回答:是水,不是空气。
+
+
+
+
+
+
+
+
+
2 · 部件
+
十六个部件
+
每个部件是一个 3 × 3 的形状,有两种意思:作为事物的意思,和箭头后面作为关系的意思。形状尽量让人猜得出来:封闭的方块是我们,朝某人打开的方块是你们,交叉的光线是光,上升的台阶是声音。
+
+
+
+
+
+
+
+
+
3 · 词
+
两个部件组成一个词
+
一个词占两个相邻的位置:左边是类 ,右边是指 ,写作 类.指。属于你们的世界是你们的世界;计时的恒星是脉冲星;行进的空气是风。只有一个部件的词,右边空着。所有约定好的词都在词典 里。
+
+
+
+
+
+
+
+
+
4 · 句
+
每一行是图谱的一条边
+
一行宽六个位置(23 格),正好放三个词:一个节点、一个关系、另一个节点。一段文字是一行行叠起来的,所以一段文字就是一张知识图谱。同一个词出现在两行,就是同一个节点;节点位置上单独一个 ONE 表示“那”,指上一行那句话。
+
+
+
+
关系是一个表示动作的部件,加上一个标出时间或肯定否定的“指”。以 LIGHT(看见)为例:
+
+
+
+
+
+
+
+
+
第二个例子把“影子”那段发射写成了话:我们看见过你们;我们看不见你们了。第三个例子指向自己:你们的世界是水世界,我们看见过那个。
+
+
+
+
+
+
+
5 · 数
+
数是九个比特
+
一个数写作 COUNT(一条计数的横线),后面的“指”按九个比特读:从 256 到 1,从左到右,从上到下。COUNT 单独出现是零,所以能写 0 到 511。只有 COUNT 这一类的“指”按比特读,所以数永远不会被错认成词。
+
+
+
+
+
+
+
+
+
+
+
+
6 · 声音
+
往下叠,就是又一个声音
+
只有新的一方才会在一行下面加一个声部,而且每一方一直用同一个声部,所以声部的数目就是声音的数目。下面声部里的每个部件,回应同一位置上方最近的那个部件。这样任何一方都能逐个部件地赞同、否定或修改任何节点和边。一场对话,就是几个声音一起搭起来的一张图。
+
+
+
+
+
+
+
+
提问:任何位置上的 OPEN 都在问“这里填什么?”,下面画的东西就取代它。最后一个例子里,第三个声音 * 在我们的 WATER 下面画了 AIR。它回应的是上方最近的部件,也就是我们的:它站在他们一边,反对我们对自己世界的说法。
+
+
+
+
+
+
7 · 翻译机
+
翻译机怎样读一页
+
+ 把一页切成 3 × 3 的位置。不落在格点上的都是图,比如最早那幅 667 比特的图,而不是文字。
+ 在三行的空隙处分出句子,数一数一句有几个声部:那就是有几个声音。
+ 把每句的第一个声部读成一条边:节点、关系、节点。同一个词,同一个节点。单独的 ONE 指向上一行。
+ 把下面声部的每个部件和上方最近的部件对照,把回答连同画它的声音,挂到那个节点或那条边上。
+
+
人类不用学这些符号;翻译机负责读和写,它的词库随着每一页增长。你也可以运行同一台机器:转换器 在浏览器里把文字变成图,再把图变回文字。它和命令行词典 glyph (pip install glyph-cli)用的是同一套规则和同一份词表。
+
+
+
+
+
+
+
diff --git a/scripts/glyph_fixtures.py b/scripts/glyph_fixtures.py
new file mode 100644
index 0000000..940b39c
--- /dev/null
+++ b/scripts/glyph_fixtures.py
@@ -0,0 +1,53 @@
+"""Write tests/glyph-cli-fixtures.json: what glyph-cli itself says about the examples and the vocabulary.
+
+tests/glyph.test.mjs compares lib/glyph.js against this file, so the site and the `glyph` command agree.
+Re-run it after updating glyph-cli or lib/glyph-vocab.json:
+
+ pip install glyph-cli==0.2.0
+ python3 scripts/glyph_fixtures.py
+"""
+
+import json
+from pathlib import Path
+
+import glyph_cli
+from glyph_cli import GlyphError, Vocabulary
+from glyph_cli.drawing import decode, draw_words, render, render_svg
+from glyph_cli.graph import format_graph, read_graph
+from glyph_cli.page import format_page, parse_page
+
+ROOT = Path(__file__).resolve().parent.parent
+vocab = Vocabulary.from_json(json.loads((ROOT / "lib/glyph-vocab.json").read_text(encoding="utf-8")))
+
+examples = {}
+for path in sorted((ROOT / "tests/glyph-examples").glob("*.txt")):
+ source = path.read_text(encoding="utf-8")
+ page = parse_page(source, vocab)
+ rows = render(vocab, page)
+ examples[path.name] = {
+ "source": source,
+ "formatted": format_page(page),
+ "rows": rows,
+ "graph": format_graph(vocab, read_graph(vocab, page)),
+ "svg": render_svg(vocab, page),
+ "decoded": format_page(decode(vocab, "\n".join(rows))),
+ }
+
+words = {}
+for e in vocab.entries:
+ words[str(e.word)] = {"rows": draw_words(vocab, [e.word]), "gloss": vocab.gloss(e.word)}
+
+parse = {}
+for text in ["BODY.OTHER", "body.other", "SELF", "COUNT.137", "COUNT.0", "COUNT", "_.12", "_.WATER", "_", "",
+ "LIGHT.VOICE", "BODY.WIND", "COUNT.512", "STAR.12", "ONE.12", "COUNT.SELF", "FOO", "_.0"]:
+ try:
+ w = vocab.parse(text)
+ parse[text] = {"word": str(w), "node": vocab.gloss(w), "relation": vocab.gloss(w, role="relation")}
+ except GlyphError as exc:
+ parse[text] = {"error": str(exc)}
+
+numbers = {str(n): draw_words(vocab, [vocab.parse(f"COUNT.{n}")]) for n in range(0, 512)}
+
+out = {"glyph_cli": glyph_cli.__version__, "examples": examples, "words": words, "parse": parse, "numbers": numbers}
+(ROOT / "tests/glyph-cli-fixtures.json").write_text(json.dumps(out, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
+print(f"wrote tests/glyph-cli-fixtures.json from glyph-cli {glyph_cli.__version__}")
diff --git a/tests/glyph-cli-fixtures.json b/tests/glyph-cli-fixtures.json
new file mode 100644
index 0000000..6a6b156
--- /dev/null
+++ b/tests/glyph-cli-fixtures.json
@@ -0,0 +1,3325 @@
+{
+ "glyph_cli": "0.2.0",
+ "examples": {
+ "conversation.txt": {
+ "source": "# They speak: three statements. A blank line starts the next edge.\n+: BODY.OTHER | ONE | BODY.AIR\n×: _ | _ | _.WATER\n*: _ | _ | _.AIR\n\n+: SELF | LIGHT.BEFORE | BODY.OTHER\n\n+: SELF | VOICE.ONE | ONE\n",
+ "formatted": "+: BODY.OTHER | ONE | BODY.AIR\n×: _ | _ | _.WATER\n*: _ | _ | _.AIR\n\n+: SELF | LIGHT.BEFORE | BODY.OTHER\n\n+: SELF | VOICE.ONE | ONE\n",
+ "rows": [
+ "+++.+++.........+++.+.+",
+ "+++.+....+......+++....",
+ "+++.+++.........+++.+.+",
+ ".......................",
+ "....................×××",
+ ".......................",
+ "....................×××",
+ ".......................",
+ "....................*.*",
+ ".......................",
+ "....................*.*",
+ ".......................",
+ ".......................",
+ ".......................",
+ "+++.....+.+.....+++.+++",
+ "+.+......+..+...+++.+..",
+ "+++.....+.+.....+++.+++",
+ ".......................",
+ ".......................",
+ ".......................",
+ ".......................",
+ ".......................",
+ ".......................",
+ ".......................",
+ ".......................",
+ ".......................",
+ ".......................",
+ ".......................",
+ "+++.....+..............",
+ "+.+.....++...+...+.....",
+ "+++.....+++............",
+ ".......................",
+ ".......................",
+ ".......................",
+ ".......................",
+ ".......................",
+ ".......................",
+ ".......................",
+ "......................."
+ ],
+ "graph": [
+ "Edges:",
+ "1. (+) your world --is, equals--> air-world",
+ " (×) object.which: instead \"water\" (to +)",
+ " (*) object.which: instead \"air\" (to ×)",
+ "2. (+) we --see [past]--> your world",
+ "3. (+) we --say, send [now]--> [statement 2]",
+ "",
+ "Nodes:",
+ " BODY.OTHER your world",
+ " BODY.AIR air-world",
+ " SELF we"
+ ],
+ "svg": "\n\n+ \n+ \n+ \n\n+ \n+ \n+ \n\n\n\n\n\n\n\n\n\n+ \n+ \n+ \n\n+ \n\n+ \n+ \n+ \n+ \n\n+ \n\n\n\n\n+ \n\n\n\n\n\n\n+ \n+ \n+ \n\n\n\n\n+ \n+ \n+ \n\n+ \n+ \n+ \n\n\n\n\n\n\n\n\n\n+ \n+ \n+ \n\n+ \n\n+ \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n× \n× \n× \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n× \n× \n× \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n* \n\n* \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n* \n\n* \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n