/
deka
/
buffer
Обзор
Документация
Войти
/
deka
/
buffer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
dev
src/converters/board-to-layout.js
243 строки
10 KB
Maksim Ratnikov
feat: таблица доски ограничена 30×30
11 авг 2026, 10:48
11 авг 2026, 10:48
953e2ed
Код
Авторство
О чём код?
import { blocksToMarkdown } from "./board-text.js"; import { ELEMENT_COLORS, STICKER_COLORS, LINE_TYPES, TABLE_HEADER_COLOR, FONT_SIZES } from "./board-layout.js"; const LINE_NAMES = Object.fromEntries(Object.entries(LINE_TYPES).map(([name, native]) => [native, name])); // Имя цвета ищется в палитре своего объекта: у стикера и у фигуры один и тот же // hex может называться по-разному, а одно имя — означать разные цвета. const nameMap = (palette) => new Map(Object.entries(palette).map(([n, hex]) => [hex.toUpperCase(), n])); const ELEMENT_NAMES = nameMap(ELEMENT_COLORS); const STICKER_NAMES = nameMap(STICKER_COLORS); // Допуски распознавания. Ряд считается рядом, пока элементы перекрываются по // вертикали не хуже, чем на ROW_TOLERANCE; сетка признаётся сеткой, пока левые // края колонок совпадают в пределах GRID_TOLERANCE. Значения подобраны так, // чтобы ручной сдвиг карточки на доске не менял распознанную структуру. export const ROW_TOLERANCE = 60; export const GRID_TOLERANCE = 40; export function sceneToBoardLayout(scene) { const warnings = []; const nodes = scene?.nodes ?? []; const childrenOfCell = new Map(); const roots = []; for (const node of nodes) { if (!node.parent) { roots.push(node); continue; } const key = node.parent.cellKey; if (!childrenOfCell.has(key)) childrenOfCell.set(key, []); childrenOfCell.get(key).push(node); } const usedIds = new Map(); const keyToId = new Map(); const ctx = { childrenOfCell, warnings, usedIds, keyToId }; const content = groupNodes(roots, ctx); const edges = []; for (const edge of scene?.edges ?? []) { const from = ctx.keyToId.get(edge.from?.nodeKey); const to = ctx.keyToId.get(edge.to?.nodeKey); if (!from || !to) { warnings.push({ code: "edge-endpoint-lost", path: edge.key ?? "", severity: "warn" }); continue; } edges.push({ id: makeId("edge", ctx), from: { id: from, side: edge.from.side ?? "auto" }, to: { id: to, side: edge.to.side ?? "auto" }, ...(edge.arrow && edge.arrow !== "end" ? { arrow: edge.arrow === "start" ? "end" : edge.arrow } : {}), ...(edge.lineType ? { line: LINE_NAMES[edge.lineType] ?? "bezier" } : {}) }); if (edge.arrow === "start") { warnings.push({ code: "arrow-direction-normalized", path: edge.key ?? "", severity: "info" }); } } const layout = { $schema: "board-layout-v1", content: content ?? { type: "column", items: [] } }; if (edges.length) layout.edges = edges; return { layout, warnings }; } // --- распознаватели. Список закрыт: четыре случая, больше не добавлять ------- function groupNodes(list, ctx) { const nodes = [...list].sort((a, b) => (a.rect.y - b.rect.y) || (a.rect.x - b.rect.x)); if (!nodes.length) return null; if (nodes.length === 1) return convertNode(nodes[0], ctx); const rows = []; for (const node of nodes) { const row = rows.find((candidate) => Math.abs(candidate.y - node.rect.y) <= ROW_TOLERANCE); if (row) { row.items.push(node); row.y = Math.min(row.y, node.rect.y); } else rows.push({ y: node.rect.y, items: [node] }); } for (const row of rows) row.items.sort((a, b) => a.rect.x - b.rect.x); const widths = new Set(rows.map((row) => row.items.length)); const onlyCards = nodes.every((node) => node.kind !== "table"); const aligned = rows.length > 1 && widths.size === 1 && rows[0].items.length > 1 && rows.every((row) => row.items.every((item, index) => Math.abs(item.rect.x - rows[0].items[index].rect.x) <= GRID_TOLERANCE)); if (onlyCards && aligned) { return { type: "grid", columns: rows[0].items.length, items: rows.flatMap((row) => row.items.map((node) => convertNode(node, ctx))) }; } if (rows.length === 1) { return { type: "row", items: rows[0].items.map((node) => convertNode(node, ctx)) }; } return { type: "column", items: rows.map((row) => (row.items.length === 1 ? convertNode(row.items[0], ctx) : { type: "row", items: row.items.map((node) => convertNode(node, ctx)) })) }; } function convertNode(node, ctx) { if (node.kind === "table") return convertTable(node, ctx); return convertCard(node, ctx); } function convertCard(node, ctx) { const markdown = blocksToMarkdown(node.text).trim(); const blocks = node.text?.blocks ?? []; // Заголовком становится только первый абзац. Если карточка начинается со // списка, разрывать его на title и text нельзя — потеряется нумерация. const titleIsSeparate = blocks.length > 0 && blocks[0].type === "p"; const [firstLine, ...restLines] = markdown.split("\n"); const title = titleIsSeparate ? stripEmphasis(firstLine ?? "") : ""; const rest = (titleIsSeparate ? restLines.join("\n") : markdown).trim(); const id = makeId(title || firstLine || node.kind, ctx); ctx.keyToId.set(node.key, id); const VARIANTS = { figure: "figure", textArea: "text" }; const card = { type: "card", id, variant: VARIANTS[node.kind] ?? "sticker" }; if (node.kind === "figure" && node.style?.figureType) card.figure = node.style.figureType; const scope = node.kind === "sticker" ? "sticker" : "element"; const color = colorName(node.style?.color, scope); const defaults = node.kind === "sticker" ? ["blue"] : ["white", "none"]; if (color && !defaults.includes(color)) card.color = color; if (node.kind === "figure") { const stroke = colorName(node.style?.strokeColor, "element"); if (stroke && stroke !== "blue") card.stroke = stroke; } if (title) card.title = title; if (rest) card.text = rest; return card; } function convertTable(node, ctx) { const table = node.table ?? { columns: [], rows: [] }; const cellChildren = (cell) => ctx.childrenOfCell.get(cell.key) ?? []; // Свимлейн: две колонки, справа лежит вложенное содержимое. const isSwimlane = table.columns.length === 2 && table.rows.some((row) => cellChildren(row.cells[1] ?? {}).length > 0); if (isSwimlane) { const id = makeId("lanes", ctx); ctx.keyToId.set(node.key, id); const swimlaneTitle = blocksToMarkdown(node.title).trim(); return { type: "swimlanes", id, ...(swimlaneTitle ? { title: swimlaneTitle } : {}), lanes: table.rows.map((row) => { const titleCell = row.cells[0] ?? {}; const bodyCell = row.cells[1] ?? {}; const title = blocksToMarkdown(titleCell.text).trim(); const lane = { id: makeId(stripEmphasis(title) || "lane", ctx), title: stripEmphasis(title) }; const color = colorName(titleCell.color); if (color && color !== "white") lane.color = color; if (Number.isFinite(titleCell.fontSize) && titleCell.fontSize !== FONT_SIZES.lane) { lane.fontSize = titleCell.fontSize; } const content = groupNodes(cellChildren(bodyCell), ctx); if (content) lane.content = content; return lane; }) }; } const id = makeId("table", ctx); ctx.keyToId.set(node.key, id); // Заголовочная строка узнаётся по серому фону — так её рисует движок. const headerRow = table.rows[0]; const hasHeader = Boolean(headerRow) && headerRow.cells.length > 0 && headerRow.cells.every((cell) => sameColor(cell.color, TABLE_HEADER_COLOR)); const columns = table.columns.map((column, index) => { const title = hasHeader ? stripEmphasis(blocksToMarkdown(headerRow.cells[index]?.text).trim()) : ""; return title ? { id: `col${index + 1}`, title } : { id: `col${index + 1}` }; }); const bodyRows = hasHeader ? table.rows.slice(1) : table.rows; const rows = bodyRows.map((row, rowIndex) => { const cells = {}; const content = {}; row.cells.forEach((cell, index) => { const columnId = columns[index]?.id; if (!columnId) return; cells[columnId] = blocksToMarkdown(cell.text).trim(); const nested = groupNodes(cellChildren(cell), ctx); if (nested) content[columnId] = nested; }); const result = { id: makeId(`row-${rowIndex + 1}`, ctx), cells }; const color = colorName(row.cells[0]?.color); if (color && color !== "white") result.color = color; if (Object.keys(content).length) result.content = content; return result; }); const tableTitle = blocksToMarkdown(node.title).trim(); return { type: "table", id, ...(tableTitle ? { title: tableTitle } : {}), columns, rows }; } // --- вспомогательное --------------------------------------------------------- function sameColor(a, b) { return typeof a === "string" && typeof b === "string" && a.toUpperCase() === b.toUpperCase(); } function colorName(hex, scope = "element") { if (typeof hex !== "string") return null; const names = scope === "sticker" ? STICKER_NAMES : ELEMENT_NAMES; return names.get(hex.toUpperCase()) ?? null; } function stripEmphasis(value) { return value.replace(/\*\*/g, "").replace(/~~/g, "").replace(/(^|\s)\*(\S)/g, "$1$2").replace(/(\S)\*(\s|$)/g, "$1$2").trim(); } const TRANSLIT = { а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "e", ж: "zh", з: "z", и: "i", й: "i", к: "k", л: "l", м: "m", н: "n", о: "o", п: "p", р: "r", с: "s", т: "t", у: "u", ф: "f", х: "h", ц: "c", ч: "ch", ш: "sh", щ: "sch", ъ: "", ы: "y", ь: "", э: "e", ю: "yu", я: "ya" }; // Идентификаторы выводятся из содержимого, а не из UUID: LLM должна уметь // сказать «убери sber-billing», а не «убери n-4e9ccec0». function makeId(source, ctx) { const base = String(source ?? "") .toLowerCase() .split("") .map((char) => TRANSLIT[char] ?? char) .join("") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .split("-") .slice(0, 4) .join("-") .slice(0, 40) || "item"; const seen = ctx.usedIds.get(base) ?? 0; ctx.usedIds.set(base, seen + 1); return seen ? `${base}-${seen + 1}` : base; }