/**
* Клиент редактора диалогов (D8): список графов, SVG-канва с карточками и
* рёбрами, инспектор узла. Без фреймворков и CDN; авторитет валидации —
* сервер (probe.ts), клиент лишь мгновенно подсвечивает сирот и неизвестные id.
*/
const $ = (id) => document.getElementById(id);
const SVGNS = 'http://www.w3.org/2000/svg';
const CARD_W = 190;
const CARD_H = 74;
const GAP_X = 90;
const GAP_Y = 24;
let graphIds = [];
let currentId = null;
let mtime = null;
let graph = null;
let selected = null;
let dirty = false;
/** Позиции узлов последнего рендера (для рёбер). */
let posMap = new Map();
async function jget(url) {
const r = await fetch(url);
const body = await r.json();
if (!r.ok) throw new Error(body.message ?? `${r.status}`);
return body;
}
async function jsend(url, method, body) {
const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return { ok: r.ok, status: r.status, body: await r.json() };
}
function setStatus(text, cls = '') {
$('status').textContent = text;
$('status').className = `muted ${cls}`;
}
function markDirty() {
dirty = true;
setStatus('не сохранено');
}
/** Все исходящие рёбра узла: [{to, kind}] — next и next выборов. */
function edgesOf(id) {
const n = graph.nodes[id];
if (!n) return [];
const out = [];
if (n.next) out.push({ to: n.next, kind: 'next' });
(n.choices ?? []).forEach((c) => {
if (c.next) out.push({ to: c.next, kind: 'choice' });
});
return out;
}
/** Слоёная раскладка: BFS от start (уровень → колонка), сироты — в последнюю. */
function computeLayout() {
const pos = new Map();
const depth = new Map();
if (!graph.nodes[graph.start]) return pos;
depth.set(graph.start, 0);
const queue = [graph.start];
while (queue.length > 0) {
const id = queue.shift();
for (const e of edgesOf(id)) {
if (graph.nodes[e.to] && !depth.has(e.to)) {
depth.set(e.to, depth.get(id) + 1);
queue.push(e.to);
}
}
}
const rows = new Map();
const lastCol = Math.max(-1, ...depth.values()) + 1;
for (const id of Object.keys(graph.nodes)) {
const d = depth.get(id) ?? lastCol;
const row = rows.get(d) ?? 0;
rows.set(d, row + 1);
pos.set(id, { x: d * (CARD_W + GAP_X) + 16, y: row * (CARD_H + GAP_Y) + 16, orphan: !depth.has(id) });
}
return pos;
}
/** Эллипсис для карточки. */
function preview(s, n) {
const s2 = (s ?? '').replace(/\s+/g, ' ').trim();
return s2.length > n ? `${s2.slice(0, n - 1)}…` : s2;
}
function svgText(x, y, str, cls) {
const t = document.createElementNS(SVGNS, 'text');
t.setAttribute('x', x);
t.setAttribute('y', y);
if (cls) t.setAttribute('class', cls);
t.textContent = str;
return t;
}
/** Реплика ли узел: есть текст (inline или ключ). */
function isReplica(n) {
return n.text !== undefined || n.textKey !== undefined;
}
/** Краткие бейджи условий и эффектов (внизу карточки). */
function badgesOf(n) {
const parts = [];
if (n.mood) parts.push(`mood:${n.mood}`);
if (n.when?.length) parts.push(`when:${n.when.join(',')}`);
if (n.whenNot?.length) parts.push(`!${n.whenNot.join(',')}`);
if (n.whenVar) parts.push(`${n.whenVar.key}${n.whenVar.op}${n.whenVar.value}`);
if (n.whenVars?.length) parts.push(`${n.whenVars.length}×var`);
if (n.hasItem?.length) parts.push(`item:${n.hasItem.join(',')}`);
if (n.setFlags?.length) parts.push(`+${n.setFlags.join(',')}`);
if (n.setVars && Object.keys(n.setVars).length) parts.push(`v=${Object.keys(n.setVars).join(',')}`);
(n.do ?? []).forEach((o) => parts.push(`${o.kind}${o.id ? ':' + o.id : ''}`));
return parts.join(' ');
}
/** Карточка узла (rect + 4 строки текста). */
function drawNodeCard(id) {
const n = graph.nodes[id];
const p = posMap.get(id);
const g = document.createElementNS(SVGNS, 'g');
g.setAttribute('class', `node-card${selected === id ? ' selected' : ''}${p.orphan ? ' ghost' : ''}`);
g.setAttribute('transform', `translate(${p.x},${p.y})`);
const rect = document.createElementNS(SVGNS, 'rect');
rect.setAttribute('width', CARD_W);
rect.setAttribute('height', CARD_H);
rect.setAttribute('rx', 6);
g.appendChild(rect);
g.appendChild(svgText(10, 16, n.speaker ?? (isReplica(n) ? '—' : '(действие)'), 'speaker'));
g.appendChild(svgText(10, 30, id, ''));
g.appendChild(svgText(10, 46, preview(n.text ?? n.textKey ?? '', 27), 'body'));
const badges = badgesOf(n);
if (badges) g.appendChild(svgText(10, 62, preview(badges, 30), 'badge'));
g.addEventListener('click', () => select(id));
$('nodes').appendChild(g);
}
/** Ребро между карточками: кубическая кривая от правой грани к левой. */
function drawEdge(from, to, kind) {
const a = posMap.get(from);
const b = posMap.get(to);
if (!a || !b) return;
const x1 = a.x + CARD_W;
const y1 = a.y + CARD_H / 2;
const x2 = b.x;
const y2 = b.y + CARD_H / 2;
const mx = (x1 + x2) / 2;
const path = document.createElementNS(SVGNS, 'path');
path.setAttribute('d', `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', kind === 'choice' ? 'var(--edge-choice)' : 'var(--edge)');
if (kind === 'choice') path.setAttribute('stroke-dasharray', '5 4');
path.setAttribute('stroke-width', '1.5');
$('edges').appendChild(path);
}
function renderCanvas() {
posMap = computeLayout();
$('edges').replaceChildren();
$('nodes').replaceChildren();
for (const id of Object.keys(graph.nodes)) {
for (const e of edgesOf(id)) {
if (graph.nodes[e.to]) drawEdge(id, e.to, e.kind);
}
}
for (const id of Object.keys(graph.nodes)) {
drawNodeCard(id);
}
const xs = [...posMap.values()].map((p) => p.x + CARD_W + GAP_X);
const ys = [...posMap.values()].map((p) => p.y + CARD_H + GAP_Y);
$('canvas').setAttribute('viewBox', `0 0 ${Math.max(480, ...xs)} ${Math.max(400, ...ys)}`);
}
function select(id) {
selected = id;
renderCanvas();
renderInspector();
}
/* ---------- инспектор ---------- */
function setField(el, value) {
el.value = value ?? '';
}
/** Собрать whenVar из трёх полей (пустой ключ — условия нет). */
function whenVarFromInputs() {
const key = $('f-whenvar-key').value.trim();
if (!key) return undefined;
return { key, op: $('f-whenvar-op').value, value: Number($('f-whenvar-value').value || 0) };
}
/** do[] из строки «kind:id:count, kind:id». */
function doFromString(s) {
const ops = [];
for (const part of s.split(',')) {
const [kind, id, count] = part.trim().split(':').map((x) => x.trim());
if (!kind) continue;
const op = { kind };
if (id) op.id = id;
if (count) op.count = Number(count);
ops.push(op);
}
return ops.length > 0 ? ops : undefined;
}
function doToString(ops) {
return (ops ?? []).map((o) => [o.kind, o.id, o.count].filter((x) => x !== undefined).join(':')).join(', ');
}
/** setVars из строки «ключ=число, ...». */
function setVarsFromString(s) {
const out = {};
for (const part of s.split(',')) {
const [k, v] = part.split('=').map((x) => x.trim());
if (k && v !== undefined && v !== '') out[k] = Number(v);
}
return Object.keys(out).length > 0 ? out : undefined;
}
function setVarsToString(vars) {
return Object.entries(vars ?? {}).map(([k, v]) => `${k}=${v}`).join(', ');
}
/** Заполнить поля инспектора из узла. */
function fillInspector(id) {
const n = graph.nodes[id];
setField($('f-id'), id);
setField($('f-speaker'), n.speaker);
setField($('f-mood'), n.mood);
setField($('f-text'), n.text);
setField($('f-textkey'), n.textKey);
setField($('f-tags'), (n.tags ?? []).join(', '));
setField($('f-next'), n.next ?? '');
setField($('f-when'), (n.when ?? []).join(', '));
setField($('f-whennot'), (n.whenNot ?? []).join(', '));
const wv = n.whenVar ?? n.whenVars?.[0];
setField($('f-whenvar-key'), wv?.key);
setField($('f-whenvar-op'), wv?.op ?? 'ge');
setField($('f-whenvar-value'), wv?.value);
setField($('f-setflags'), (n.setFlags ?? []).join(', '));
setField($('f-setvars'), setVarsToString(n.setVars));
setField($('f-do'), doToString(n.do));
fillNextOptions(n.next ?? '');
renderChoicesEditor(id);
}
/** Варианты next: все узлы + пустой конец. */
function fillNextOptions(current) {
const sel = $('f-next');
sel.replaceChildren(new Option('— конец —', ''));
for (const id of Object.keys(graph.nodes)) sel.appendChild(new Option(id, id));
sel.value = current;
}
/** Обёртка label>control (строки инспектора). */
function wrapLabel(text, control, grow) {
const label = document.createElement('label');
if (grow) label.className = 'grow';
label.append(text, control);
return label;
}
/** Редактор одного выбора: text / next / when / удалить. */
function choiceRow(id, index, ch) {
const div = document.createElement('div');
div.className = 'choice-edit';
const text = document.createElement('input');
text.value = ch.text ?? '';
text.placeholder = 'текст варианта';
text.addEventListener('input', () => {
ch.text = text.value;
markDirty();
renderCanvas();
});
const next = document.createElement('select');
next.appendChild(new Option('— конец —', ''));
for (const nid of Object.keys(graph.nodes)) next.appendChild(new Option(nid, nid));
next.value = ch.next ?? '';
next.addEventListener('change', () => {
if (next.value) ch.next = next.value;
else delete ch.next;
markDirty();
renderCanvas();
});
const when = document.createElement('input');
when.value = (ch.when ?? []).join(', ');
when.placeholder = 'when (флаги, через запятую)';
when.addEventListener('input', () => {
const flags = listOf(when.value);
if (flags.length > 0) ch.when = flags;
else delete ch.when;
markDirty();
});
const del = document.createElement('button');
del.textContent = '× удалить';
del.className = 'danger';
del.addEventListener('click', () => {
graph.nodes[id].choices.splice(index, 1);
if (graph.nodes[id].choices.length === 0) delete graph.nodes[id].choices;
markDirty();
renderAll();
});
const row1 = document.createElement('div');
row1.className = 'row';
row1.appendChild(wrapLabel('text', text, true));
const row2 = document.createElement('div');
row2.className = 'row';
row2.appendChild(wrapLabel('next', next, true));
const row3 = document.createElement('div');
row3.className = 'row';
row3.appendChild(wrapLabel('when', when, true));
div.append(row1, row2, row3, del);
return div;
}
function renderChoicesEditor(id) {
const n = graph.nodes[id];
const wrap = $('choices');
wrap.replaceChildren();
(n.choices ?? []).forEach((ch, i) => {
wrap.appendChild(choiceRow(id, i, ch));
});
}
/* ---------- запись полей обратно в узел ---------- */
/** Поля с comma-списками: пустое поле → пустой список. */
function listOf(s) {
return s.split(',').map((x) => x.trim()).filter(Boolean);
}
function bindField(elId, apply) {
$(elId).addEventListener('input', () => {
if (!selected) return;
apply(graph.nodes[selected]);
markDirty();
renderCanvas();
});
}
/** whenVar-поле: единая запись (редактируем одно условие; whenVars[1+] не трогаем). */
function bindWhenVar() {
const apply = (n) => {
const wv = whenVarFromInputs();
if (wv) {
n.whenVar = wv;
delete n.whenVars;
} else {
delete n.whenVar;
delete n.whenVars;
}
};
for (const id of ['f-whenvar-key', 'f-whenvar-op', 'f-whenvar-value']) bindField(id, apply);
}
function bindInspector() {
bindField('f-speaker', (n) => {
if ($('f-speaker').value) n.speaker = $('f-speaker').value;
else delete n.speaker;
});
bindField('f-mood', (n) => {
if ($('f-mood').value) n.mood = $('f-mood').value;
else delete n.mood;
});
bindField('f-text', (n) => {
if ($('f-text').value) n.text = $('f-text').value;
else delete n.text;
});
bindField('f-textkey', (n) => {
if ($('f-textkey').value) n.textKey = $('f-textkey').value;
else delete n.textKey;
});
bindField('f-tags', (n) => {
const tags = listOf($('f-tags').value);
if (tags.length > 0) n.tags = tags;
else delete n.tags;
});
bindField('f-when', (n) => {
const flags = listOf($('f-when').value);
if (flags.length > 0) n.when = flags;
else delete n.when;
});
bindField('f-whennot', (n) => {
const flags = listOf($('f-whennot').value);
if (flags.length > 0) n.whenNot = flags;
else delete n.whenNot;
});
bindWhenVar();
bindField('f-setflags', (n) => {
const flags = listOf($('f-setflags').value);
if (flags.length > 0) n.setFlags = flags;
else delete n.setFlags;
});
bindField('f-setvars', (n) => {
const vars = setVarsFromString($('f-setvars').value);
if (vars) n.setVars = vars;
else delete n.setVars;
});
bindField('f-do', (n) => {
const ops = doFromString($('f-do').value);
if (ops) n.do = ops;
else delete n.do;
});
$('f-next').addEventListener('change', () => {
if (!selected) return;
if ($('f-next').value) graph.nodes[selected].next = $('f-next').value;
else delete graph.nodes[selected].next;
markDirty();
renderCanvas();
});
$('btn-add-choice').addEventListener('click', () => {
if (!selected) return;
(graph.nodes[selected].choices ??= []).push({ text: 'Новый вариант' });
markDirty();
renderAll();
});
$('btn-add-node').addEventListener('click', addNode);
$('btn-del-node').addEventListener('click', deleteNode);
}
/** Свободный id узла: node_N. */
function freeNodeId() {
let i = 1;
while (graph.nodes[`node_${i}`]) i++;
return `node_${i}`;
}
function addNode() {
const id = freeNodeId();
graph.nodes[id] = { text: '' };
markDirty();
select(id);
}
function deleteNode() {
if (!selected) return;
if (selected === graph.start) {
setStatus('start удалять нельзя — выбери другой узел', 'err');
return;
}
delete graph.nodes[selected];
selected = null;
markDirty();
renderAll();
}
/* ---------- список графов, открытие, сохранение, dry-run ---------- */
async function loadGraphList() {
graphIds = await jget('/api/graphs');
const nav = $('graph-list');
nav.replaceChildren();
for (const g of graphIds) {
const b = document.createElement('button');
b.textContent = g.id;
b.className = g.id === currentId ? 'active' : '';
b.addEventListener('click', () => switchGraph(g.id));
nav.appendChild(b);
}
}
async function switchGraph(id) {
if (dirty && !confirm('Есть несохранённые правки. Перейти и потерять их?')) return;
await openGraph(id);
}
async function openGraph(id) {
const data = await jget(`/api/graph/${id}`);
currentId = id;
mtime = data.mtime;
graph = data.graph;
selected = null;
dirty = false;
$('graph-title').textContent = `— ${id}`;
$('run-output').hidden = true;
$('problems').hidden = true;
setStatus('загружено с диска');
await loadGraphList();
renderAll();
}
function renderInspector() {
if (selected && graph.nodes[selected]) {
$('inspector-empty').hidden = true;
$('inspector-form').hidden = false;
fillInspector(selected);
} else {
$('inspector-empty').hidden = false;
$('inspector-form').hidden = true;
}
}
function renderAll() {
renderCanvas();
renderInspector();
}
async function save() {
if (!graph) return;
setStatus('валидация…');
const r = await jsend(`/api/graph/${currentId}`, 'PUT', { mtime, graph });
if (r.status === 409) {
if (confirm(`${r.body.message}. Перечитать с диска и потерять локальные правки?`)) {
await openGraph(currentId);
}
return;
}
if (r.status === 422) {
showProblems(r.body);
setStatus('ошибки валидации — не сохранено', 'err');
return;
}
if (!r.ok) {
setStatus(`ошибка: ${r.body.message ?? r.status}`, 'err');
return;
}
mtime = r.body.mtime;
dirty = false;
$('problems').hidden = true;
setStatus('сохранено', 'ok');
}
/** Проблемы (валидация/анализ) в панель над канвой. */
function showProblems(out) {
const div = $('problems');
div.replaceChildren();
const line = (cls, text) => {
const p = document.createElement('div');
p.className = cls;
p.textContent = text;
div.appendChild(p);
};
for (const inv of out.errors ?? []) line('err', `[ошибка] ${inv.message}`);
for (const inv of out.warnings ?? []) line('warn', `[предупреждение] ${inv.message}`);
for (const o of out.orphans ?? []) line('warn', `[сирота] узел «${o}» недостижим`);
for (const cy of out.cycles ?? []) line('err', `[цикл без текста] ${cy.join(' → ')}`);
for (const e of out.silentEnds ?? []) line('warn', `[тихий конец] «${e}»`);
div.hidden = div.childElementCount === 0;
}
async function dryRun() {
if (!graph) return;
setStatus('прогон…');
const r = await jsend('/api/dry-run', 'POST', { graph, preset: $('preset').value });
if (!r.ok) {
setStatus(`ошибка: ${r.body.message ?? r.status}`, 'err');
return;
}
const div = $('run-output');
div.textContent = r.body.run ?? '';
div.hidden = false;
showProblems(r.body);
setStatus('прогон готов', 'ok');
}
async function init() {
const list = await jget('/api/graphs');
if (list.length === 0) {
setStatus('в data/dialogues нет графов', 'err');
return;
}
bindInspector();
$('btn-save').addEventListener('click', save);
$('btn-reload').addEventListener('click', async () => {
if (!dirty || confirm('Потерять локальные правки?')) await openGraph(currentId);
});
$('btn-dry').addEventListener('click', dryRun);
window.addEventListener('beforeunload', (e) => {
if (dirty) e.preventDefault();
});
await openGraph(list[0].id);
}
init().catch((e) => setStatus(String(e), 'err'));