/
deka
/
buffer
Обзор
Документация
Войти
/
deka
/
buffer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
dev
src/converters/board-layout.js
734 строки
34 KB
Maksim Ratnikov
fix: доска отвергала вставку из-за дробной координаты
час назад
час назад
51ca8ed
Код
Авторство
О чём код?
import { markdownToBlocks } from "./board-text.js"; // Палитра доски: имена, которыми оперирует board-layout-v1, и их hex. // Значения взяты из реальных выгрузок. Новый цвет — одна строка здесь. // Палитры доски разные у разных объектов — это проверено по выгрузкам образцов, // а не выведено из общих соображений. Имя цвета разрешается в палитре своего // объекта, поэтому одно и то же имя может означать разный hex: violet у стикера, // у заливки фигуры и у её границы — три разных цвета, и так на доске и есть. // Одна палитра на элементы доски: ею красятся и заливка фигуры, и её граница, // и ячейки таблиц. Проверено по выгрузкам: все девять цветов, встреченных в // ячейках, входят в объединение заливок и границ фигур. export const ELEMENT_COLORS = { white: "#FFFFFF", black: "#000000", "grey-light": "#DCDCDC", grey: "#939393", green: "#9DE97A", teal: "#21CA9A", yellow: "#FCEE73", orange: "#FFB45C", red: "#EE6880", pink: "#EE5EC6", violet: "#8275F3", lilac: "#AC75F3", blue: "#3F81FD", navy: "#235AC2", none: "#FFFFFF00" }; // У стикера своя палитра и нет границы вовсе — в его нативных атрибутах // strokeColor отсутствует. 29 образцов, выложенных на доску 2026-08-05. export const STICKER_COLORS = { red: "#F66C6C", rose: "#F1A0AE", "dusty-rose": "#D8B0B0", clay: "#C09595", magenta: "#F17BED", "pink-light": "#F7B6E3", mauve: "#E4BFDA", orange: "#FC9251", peach: "#FFC786", cream: "#FBE1CB", gold: "#E3C528", yellow: "#FBEA8F", sand: "#E1E4BA", green: "#77DE73", lime: "#C0E55F", "lime-light": "#C8EE92", sage: "#BBDCB1", mint: "#84E4C9", sky: "#70C5E6", "sky-light": "#BCECF6", "blue-bright": "#8A91F5", blue: "#BDD4FF", periwinkle: "#B1B2DC", violet: "#B29AFF", "violet-light": "#DFCBFB", lilac: "#C9B1DC", white: "#FFFFFF", "grey-light": "#D7D7D7", grey: "#A2A1A1" }; export const CELL_COLORS = ELEMENT_COLORS; export const FIGURE_FILL_COLORS = ELEMENT_COLORS; export const FIGURE_STROKE_COLORS = ELEMENT_COLORS; export const TEXT_COLORS = { transparent: "#FFFFFF00", white: "#FFFFFF" }; export const PALETTES = { cell: ELEMENT_COLORS, sticker: STICKER_COLORS, figureFill: ELEMENT_COLORS, figureStroke: ELEMENT_COLORS, text: TEXT_COLORS }; // Девять базовых фигур — отдельный набор доски, они пронумерованы в выгрузке. export const FIGURE_TYPES = [ "rectangle", "roundedRect", "ellipse", "diamond", "triangle", "star", "dialog", "rightArrow", "parallelogram" ]; // Блок-схемы — второй набор, 30 символов. На доске это отдельная панель, // хотя нативно оба набора приходят как type: "figure". export const FLOWCHART_TYPES = [ "terminator", "preparation", "manualInput", "display", "delay", "document", "multiDoc", "card", "sort", "sort2", "cycle", "loopLimit", "circleOr", "circleJunction", "database", "cylinder", "cylinderHalf", "tableRectangle", "tableSquare", "bracket", "bracketLeft", "bracketRight", "houseUp", "houseDown", "trapezeUp", "trapezeDown", "triangleDown", "threeRhombs", "flag", "sandGlass" ]; export const ALL_FIGURE_TYPES = [...FIGURE_TYPES, ...FLOWCHART_TYPES]; // Типы линий, наблюдаемые на доске. export const LINE_TYPES = { bezier: "bezierCurve", curve: "curve", straight: "straight" }; export const CARD_SIZES = { sticker: { s: { w: 240, h: 240 }, m: { w: 360, h: 360 }, l: { w: 480, h: 480 } }, // textArea — текст без плашки: подпись, заголовок раздела, пояснение на полотне. text: { s: { w: 360, h: 80 }, m: { w: 640, h: 110 }, l: { w: 960, h: 150 } } }; // Естественные размеры фигур, снятые с доски. Фигура не растягивается: её // пропорции — часть смысла (ромб решения, цилиндр базы), а размеры друг // относительно друга задают масштаб набора. size только умножает обе стороны. export const FIGURE_NATURAL = { bracket: { w: 60, h: 80 }, bracketLeft: { w: 62, h: 80 }, bracketRight: { w: 62, h: 80 }, card: { w: 120, h: 80 }, circleJunction: { w: 120, h: 120 }, circleOr: { w: 120, h: 120 }, cycle: { w: 202, h: 202 }, cylinder: { w: 102, h: 80 }, cylinderHalf: { w: 257, h: 202 }, database: { w: 95, h: 122 }, delay: { w: 102, h: 68 }, dialog: { w: 173, h: 173 }, diamond: { w: 150, h: 150 }, display: { w: 126, h: 80 }, document: { w: 126, h: 86 }, ellipse: { w: 150, h: 150 }, flag: { w: 202, h: 152 }, houseDown: { w: 120, h: 114 }, houseUp: { w: 120, h: 114 }, loopLimit: { w: 126, h: 84 }, manualInput: { w: 120, h: 80 }, multiDoc: { w: 120, h: 73 }, parallelogram: { w: 221, h: 175 }, preparation: { w: 202, h: 140 }, rectangle: { w: 150, h: 150 }, rightArrow: { w: 221, h: 173 }, roundedRect: { w: 173, h: 173 }, sandGlass: { w: 120, h: 122 }, sort: { w: 202, h: 202 }, sort2: { w: 202, h: 202 }, star: { w: 173, h: 173 }, tableRectangle: { w: 202, h: 121 }, tableSquare: { w: 102, h: 102 }, terminator: { w: 257, h: 158 }, threeRhombs: { w: 202, h: 120 }, trapezeDown: { w: 120, h: 80 }, trapezeUp: { w: 120, h: 80 }, triangle: { w: 173, h: 150 }, triangleDown: { w: 120, h: 80 } }; export const FIGURE_SCALE = { s: 0.75, m: 1, l: 1.5 }; export const TABLE_HEADER_COLOR = ELEMENT_COLORS["grey-light"]; export const COLUMN_WIDTHS = { s: 360, m: 460, l: 700 }; // Пол, который держит сама доска. Замерено на реальной выгрузке: 89 строк из 97 // имеют высоту ровно 90–92, минимальная ширина ячейки — 347. Если отдать меньше, // доска поднимет строки до своего минимума, а table.h останется нашим — рамка // окажется короче суммы строк, и таблица отрисуется разъехавшейся. export const TABLE_ROW_MIN_HEIGHT = 90; export const TABLE_COLUMN_MIN_WIDTH = 347; export const GAPS = { s: 24, m: 40, l: 80 }; export const FONT_SIZES = { lane: 32, table: 24, card: 24 }; const LANE_FONT_SIZE_MIN = 8; const LANE_FONT_SIZE_MAX = 200; // Половина нечётного остатка — дробь, а доска принимает координаты только // целыми: её бэкенд отвечает «cannot unmarshal number 191.5 … of type int» и // отвергает вставку целиком. Округляем там, где дробь рождается, чтобы вся // геометрия ниже по течению осталась целой. const center = (leftover) => Math.round(leftover / 2); const LINE_HEIGHT = 1.35; const TEXT_PAD_X = 24; const TEXT_PAD_Y = 16; const CELL_PAD_X = 96; const CELL_PAD_Y = 48; const MAX_DEPTH = 6; const MAX_NODES = 500; const MAX_TEXT_LENGTH = 4000; const MAX_GRID_COLUMNS = 6; // Предел табличных данных, а не полотна: за объём отвечает MAX_NODES. // Экспортируются, чтобы prompt-smoke сверял число в промпте с числом в коде. export const MAX_TABLE_ROWS = 30; export const MAX_TABLE_COLUMNS = 30; const CONTAINER_TYPES = new Set(["column", "row", "grid", "table", "swimlanes"]); const CONNECTABLE_TYPES = new Set(["card"]); // Средняя ширина глифа как доля кегля. Заниженная оценка режет текст, завышенная // раздувает полотно; 0.55 подобрано по кириллице основного текста досок. const GLYPH_RATIO = 0.55; export function heuristicMeasure(text, width, fontSize) { const glyph = fontSize * GLYPH_RATIO; const usable = Math.max(glyph * 4, width - 2 * TEXT_PAD_X); const perLine = Math.max(1, Math.floor(usable / glyph)); let lines = 0; for (const block of text?.blocks ?? []) { const length = (block.spans ?? []).reduce((sum, span) => sum + span.text.length, 0); lines += Math.max(1, Math.ceil(length / perLine)); } return Math.ceil(Math.max(1, lines) * fontSize * LINE_HEIGHT + 2 * TEXT_PAD_Y); } export function validateBoardLayout(layout) { const errors = []; const warnings = []; const ids = new Map(); const connectable = new Set(); const fail = (code, path, detail) => errors.push({ code, path, detail, severity: "error" }); // Обход бросили на полпути (слишком большая таблица), значит часть id так и не // собрана и судить о висячих связях больше нельзя. let skippedSubtree = false; if (!layout || typeof layout !== "object") { fail("not-an-object", "", "ожидался объект board-layout-v1"); return { errors, warnings }; } if (layout.$schema && layout.$schema !== "board-layout-v1") { warnings.push({ code: "unexpected-schema", path: "$schema", detail: String(layout.$schema), severity: "warn" }); } if (!layout.content) fail("no-content", "content", "нужен ровно один корневой контейнер"); let nodeCount = 0; const walk = (node, path, depth) => { if (!node || typeof node !== "object") { fail("not-a-node", path, "ожидался узел"); return; } nodeCount += 1; if (nodeCount > MAX_NODES) { fail("too-many-nodes", path, `больше ${MAX_NODES} узлов`); return; } if (depth > MAX_DEPTH) { fail("too-deep", path, `вложенность больше ${MAX_DEPTH}`); return; } const type = node.type; if (!CONTAINER_TYPES.has(type) && type !== "card") { fail("unknown-type", path, String(type)); return; } if (node.id !== undefined) { if (typeof node.id !== "string" || !node.id.trim()) fail("bad-id", path, String(node.id)); else if (ids.has(node.id)) fail("duplicate-id", path, `id "${node.id}" уже занят (${ids.get(node.id)})`); else ids.set(node.id, path); if (CONNECTABLE_TYPES.has(type)) connectable.add(node.id); } if (node.size !== undefined && !(node.size in CARD_SIZES.sticker)) { fail("bad-size", path, `${node.size}: допустимы s, m, l`); } if (node.gap !== undefined && !(node.gap in GAPS)) { fail("bad-gap", path, `${node.gap}: допустимы s, m, l`); } if (node.color !== undefined) { const scope = type !== "card" ? "cell" : node.variant === "figure" ? "figureFill" : node.variant === "text" ? "text" : "sticker"; if (!(node.color in PALETTES[scope])) { fail("unknown-color", path, `${node.color}: у этого объекта своя палитра — ${Object.keys(PALETTES[scope]).join(", ")}`); } } for (const field of ["title", "text"]) { const value = node[field]; if (value === undefined) continue; if (typeof value !== "string") { fail("bad-text", `${path}.${field}`, "текст должен быть строкой"); continue; } if (value.length > MAX_TEXT_LENGTH) fail("text-too-long", `${path}.${field}`, `${value.length} символов`); if (/<[a-zA-Z/]/.test(value)) fail("html-in-text", `${path}.${field}`, "HTML запрещён, используйте markdown"); } if (type === "card") { if (node.variant !== undefined && !["sticker", "figure", "text"].includes(node.variant)) { fail("bad-variant", path, `${node.variant}: допустимы sticker, figure, text`); } if (node.variant === "figure") { if (node.figure === undefined) { fail("figure-without-type", path, "у фигуры нужно указать figure — иначе непонятно, что рисовать"); } else if (!ALL_FIGURE_TYPES.includes(node.figure)) { fail("unknown-figure", path, `${node.figure}: нет такой фигуры на доске`); } } if (node.stroke !== undefined) { if (node.variant !== "figure") fail("stroke-on-non-figure", path, "граница есть только у фигур"); else if (!(node.stroke in ELEMENT_COLORS)) { fail("unknown-stroke", path, `${node.stroke}: допустимы ${Object.keys(ELEMENT_COLORS).join(", ")}`); } } if (!node.id) fail("card-without-id", path, "карточке нужен id, иначе на неё нельзя сослаться"); return; } for (const [field, expected] of [["items", ["row", "column", "grid"]], ["rows", ["table"]], ["columns", ["table"]], ["lanes", ["swimlanes"]]]) { if (expected.includes(type) && node[field] !== undefined && !Array.isArray(node[field])) { fail("not-an-array", `${path}.${field}`, "ожидался массив"); return; } } if (type === "grid") { const columns = node.columns; if (!Number.isInteger(columns) || columns < 1 || columns > MAX_GRID_COLUMNS) { fail("bad-grid-columns", path, `columns должен быть целым 1..${MAX_GRID_COLUMNS}`); } } if (type === "table") { const columns = Array.isArray(node.columns) ? node.columns : []; if (!columns.length) fail("table-without-columns", path, "нужна хотя бы одна колонка"); // Обходить заведомо негодную таблицу незачем, а на гигантском массиве // валидатор ещё и буксует — поэтому размер проверяется до строк. const rows = Array.isArray(node.rows) ? node.rows : []; let oversized = false; if (columns.length > MAX_TABLE_COLUMNS) { fail("too-many-columns", `${path}.columns`, `${columns.length} колонок при пределе ${MAX_TABLE_COLUMNS} — разбейте данные на несколько таблиц`); oversized = true; } if (rows.length > MAX_TABLE_ROWS) { fail("too-many-rows", `${path}.rows`, `${rows.length} строк при пределе ${MAX_TABLE_ROWS} — разбейте данные на несколько таблиц`); oversized = true; } if (oversized) { // Карточки внутри так и не попадут в ids, поэтому связи к ним ниже // выглядели бы висячими: погасим ложную диагностику, а не пошлём // человека чинить не тот дефект. skippedSubtree = true; return; } const columnIds = new Set(); columns.forEach((column, index) => { if (column?.width !== undefined && !(column.width in COLUMN_WIDTHS)) { fail("bad-width", `${path}.columns[${index}]`, `${column.width}: допустимы s, m, l`); } if (!column?.id) fail("column-without-id", `${path}.columns[${index}]`, "у колонки должен быть id"); else if (columnIds.has(column.id)) fail("duplicate-column-id", `${path}.columns[${index}]`, column.id); else columnIds.add(column.id); }); const rowIds = new Set(); rows.forEach((row, index) => { const rowPath = `${path}.rows[${index}]`; if (row?.id) { if (rowIds.has(row.id)) fail("duplicate-row-id", rowPath, row.id); else rowIds.add(row.id); } if (row?.color !== undefined && !(row.color in ELEMENT_COLORS)) { fail("unknown-color", rowPath, `${row.color}: у ячеек своя палитра — ${Object.keys(ELEMENT_COLORS).join(", ")}`); } for (const key of Object.keys(row?.cells ?? {})) { if (!columnIds.has(key)) fail("unknown-column", `${rowPath}.cells.${key}`, "нет такой колонки"); const value = row.cells[key]; if (typeof value !== "string") { fail("cell-not-a-string", `${rowPath}.cells.${key}`, "значение ячейки — строка markdown; цвет задаётся у строки через row.color"); } else { if (/<[a-zA-Z/]/.test(value)) fail("html-in-text", `${rowPath}.cells.${key}`, "HTML запрещён, используйте markdown"); if (value.length > MAX_TEXT_LENGTH) fail("text-too-long", `${rowPath}.cells.${key}`, `${value.length} символов`); } } for (const [key, child] of Object.entries(row?.content ?? {})) { if (!columnIds.has(key)) fail("unknown-column", `${rowPath}.content.${key}`, "нет такой колонки"); // Текст ячейки рисуется поверх вложенного контейнера — получается каша. // Ячейка либо подписана, либо содержит вложение. if (String(row?.cells?.[key] ?? "").trim()) { fail("cell-text-and-content", `${rowPath}.${key}`, "в ячейке одновременно текст и вложенный контейнер — они наложатся друг на друга"); } walk(child, `${rowPath}.content.${key}`, depth + 1); } }); return; } if (type === "swimlanes") { const lanes = Array.isArray(node.lanes) ? node.lanes : []; if (!lanes.length) fail("swimlanes-without-lanes", path, "нужна хотя бы одна дорожка"); lanes.forEach((lane, index) => { const lanePath = `${path}.lanes[${index}]`; if (!lane?.id) fail("lane-without-id", lanePath, "у дорожки должен быть id"); else if (ids.has(lane.id)) fail("duplicate-id", lanePath, lane.id); else ids.set(lane.id, lanePath); if (lane?.color !== undefined && !(lane.color in ELEMENT_COLORS)) { fail("unknown-color", lanePath, `${lane.color}: у дорожек палитра ячеек — ${Object.keys(ELEMENT_COLORS).join(", ")}`); } if (lane?.fontSize !== undefined && (!Number.isFinite(lane.fontSize) || lane.fontSize < LANE_FONT_SIZE_MIN || lane.fontSize > LANE_FONT_SIZE_MAX)) { fail("bad-lane-font-size", `${lanePath}.fontSize`, `ожидалось число ${LANE_FONT_SIZE_MIN}..${LANE_FONT_SIZE_MAX}`); } if (lane?.content) walk(lane.content, `${lanePath}.content`, depth + 1); }); return; } const items = Array.isArray(node.items) ? node.items : []; if (!items.length) warnings.push({ code: "empty-container", path, detail: type, severity: "warn" }); items.forEach((item, index) => walk(item, `${path}.items[${index}]`, depth + 1)); }; if (layout.content) walk(layout.content, "content", 1); if (layout.edges !== undefined && !Array.isArray(layout.edges)) { fail("not-an-array", "edges", "ожидался массив"); return { errors, warnings }; } const edges = layout.edges ?? []; const edgeIds = new Set(); edges.forEach((edge, index) => { const path = `edges[${index}]`; if (edge?.id) { if (edgeIds.has(edge.id) || ids.has(edge.id)) fail("duplicate-id", path, `id "${edge.id}" уже занят`); else edgeIds.add(edge.id); } for (const end of ["from", "to"]) { const ref = edge?.[end]; if (!ref?.id) { fail("edge-without-target", `${path}.${end}`, "нужен id элемента"); continue; } if (!ids.has(ref.id)) { if (!skippedSubtree) fail("dangling-edge", `${path}.${end}`, `нет элемента с id "${ref.id}"`); continue; } if (!connectable.has(ref.id)) { fail("edge-to-container", `${path}.${end}`, `связь можно тянуть только к card, а "${ref.id}" — контейнер`); } if (ref.side !== undefined && !["top", "right", "bottom", "left", "auto"].includes(ref.side)) { fail("bad-side", `${path}.${end}.side`, String(ref.side)); } } if (edge?.arrow !== undefined && !["none", "end", "both"].includes(edge.arrow)) { fail("bad-arrow", path, String(edge.arrow)); } if (edge?.line !== undefined && !(edge.line in LINE_TYPES)) { fail("bad-line-type", path, `${edge.line}: допустимы ${Object.keys(LINE_TYPES).join(", ")}`); } }); return { errors, warnings }; } export function boardLayoutToScene(layout, opts = {}) { const measure = opts.measure ?? heuristicMeasure; const { errors, warnings } = validateBoardLayout(layout); if (errors.length) { const first = errors[0]; const error = new Error(`board-layout-v1: ${first.code} в ${first.path || "корне"} — ${first.detail}`); error.errors = errors; throw error; } const nodes = []; const idToKey = new Map(); let nodeCounter = 0; let cellCounter = 0; const emit = (node) => { const key = `n${++nodeCounter}`; const scene = { key, paintOrder: nodes.length, ...node }; nodes.push(scene); return scene; }; const register = (id, key) => { if (id) idToKey.set(id, key); }; const color = (scope, name, fallback) => PALETTES[scope][name] ?? PALETTES[scope][fallback]; const ctx = { emit, register, measure, warnings }; const root = buildNode(layout.content, ctx); root.place(0, 0, null); // Стороны считаются после раскладки: "auto" и пропущенное значение выбираются // по взаимному положению карточек, иначе стрелка втыкается в бок фигуры. const rectOf = new Map(nodes.map((node) => [node.key, node.rect])); const autoSides = (fromRect, toRect) => { const dx = (toRect.x + toRect.w / 2) - (fromRect.x + fromRect.w / 2); const dy = (toRect.y + toRect.h / 2) - (fromRect.y + fromRect.h / 2); if (Math.abs(dx) >= Math.abs(dy)) { return dx >= 0 ? ["right", "left"] : ["left", "right"]; } return dy >= 0 ? ["bottom", "top"] : ["top", "bottom"]; }; const edges = []; let edgeCounter = 0; for (const edge of layout.edges ?? []) { const fromKey = idToKey.get(edge.from.id); const toKey = idToKey.get(edge.to.id); if (!fromKey || !toKey) continue; const [autoFrom, autoTo] = autoSides(rectOf.get(fromKey), rectOf.get(toKey)); const pick = (side, fallback) => (side && side !== "auto" ? side : fallback); edges.push({ key: `e${++edgeCounter}`, from: { nodeKey: fromKey, side: pick(edge.from.side, autoFrom) }, to: { nodeKey: toKey, side: pick(edge.to.side, autoTo) }, arrow: edge.arrow ?? "end", ...(edge.line && LINE_TYPES[edge.line] ? { lineType: LINE_TYPES[edge.line] } : {}) }); } return { scene: { nodes, edges }, warnings }; // --- построение --------------------------------------------------------- // Каждый билдер возвращает собственный размер и функцию размещения. Размер // считается снизу вверх (сначала дети), координаты назначаются сверху вниз — // иначе вложенная таблица вылезает за свою дорожку. function buildNode(node, context) { switch (node.type) { case "card": return buildCard(node, context); case "row": return buildFlow(node, context, "row"); case "column": return buildFlow(node, context, "column"); case "grid": return buildGrid(node, context); case "table": return buildTable(node, context); case "swimlanes": return buildSwimlanes(node, context); default: return { w: 0, h: 0, place() {} }; } } function cardText(node) { const parts = []; if (node.title) parts.push(`**${node.title}**`); if (node.text) parts.push(node.text); return markdownToBlocks(parts.join("\n")); } function buildCard(node, context) { const variant = ["figure", "text"].includes(node.variant) ? node.variant : "sticker"; const text = cardText(node); let base; if (variant === "figure") { // Размер фигуры фиксирован: и пропорции, и масштаб относительно других // фигур — часть смысла схемы. Под длинную подпись доска сама уменьшает // кегль (fontSizeAuto), поэтому раздувать фигуру не нужно и вредно: // ряд из фигур разного размера перестаёт читаться как схема. const natural = FIGURE_NATURAL[node.figure] ?? FIGURE_NATURAL.rectangle; const scale = FIGURE_SCALE[node.size] ?? FIGURE_SCALE.m; base = { w: Math.round(natural.w * scale), h: Math.round(natural.h * scale) }; } else { const preset = CARD_SIZES[variant][node.size] ?? CARD_SIZES[variant].m; base = { w: preset.w, h: Math.max(preset.h, context.measure(text, preset.w, FONT_SIZES.card)) }; } return { w: base.w, h: base.h, place(x, y, parent) { const scene = context.emit({ kind: variant === "text" ? "textArea" : variant, parent, rect: { x, y, w: base.w, h: base.h }, style: { color: variant === "text" ? color("text", node.color, "transparent") : variant === "figure" ? color("figureFill", node.color, "white") : color("sticker", node.color, "blue"), fontSize: FONT_SIZES.card, ...(variant === "figure" ? { figureType: node.figure ?? "rectangle", opacity: 1, strokeColor: ELEMENT_COLORS[node.stroke] ?? ELEMENT_COLORS.blue } : {}), ...(variant === "text" ? { opacity: 1 } : {}) }, text, connectable: true }); context.register(node.id, scene.key); } }; } function buildFlow(node, context, direction) { const gap = GAPS[node.gap] ?? GAPS.m; const children = (node.items ?? []).map((item) => buildNode(item, context)); const w = direction === "row" ? children.reduce((sum, child) => sum + child.w, 0) + gap * Math.max(0, children.length - 1) : children.reduce((max, child) => Math.max(max, child.w), 0); const h = direction === "row" ? children.reduce((max, child) => Math.max(max, child.h), 0) : children.reduce((sum, child) => sum + child.h, 0) + gap * Math.max(0, children.length - 1); return { w, h, place(x, y, parent) { // Поперёк потока элементы центрируются: ряд фигур разной высоты, // прижатый к верхнему краю, читается как случайная россыпь. let cursor = direction === "row" ? x : y; for (const child of children) { if (direction === "row") { child.place(cursor, y + center(h - child.h), parent); cursor += child.w + gap; } else { child.place(x + center(w - child.w), cursor, parent); cursor += child.h + gap; } } } }; } function buildGrid(node, context) { const gap = GAPS[node.gap] ?? GAPS.m; const columns = node.columns; const children = (node.items ?? []).map((item) => buildNode(item, context)); const columnWidths = new Array(columns).fill(0); const rowHeights = []; children.forEach((child, index) => { const column = index % columns; const row = Math.floor(index / columns); columnWidths[column] = Math.max(columnWidths[column], child.w); rowHeights[row] = Math.max(rowHeights[row] ?? 0, child.h); }); const w = columnWidths.reduce((sum, value) => sum + value, 0) + gap * Math.max(0, columns - 1); const h = rowHeights.reduce((sum, value) => sum + value, 0) + gap * Math.max(0, rowHeights.length - 1); return { w, h, place(x, y, parent) { children.forEach((child, index) => { const column = index % columns; const row = Math.floor(index / columns); const offsetX = x + columnWidths.slice(0, column).reduce((sum, value) => sum + value + gap, 0); const offsetY = y + rowHeights.slice(0, row).reduce((sum, value) => sum + value + gap, 0); // Элемент центрируется в своей клетке сетки по обеим осям. child.place( offsetX + center(columnWidths[column] - child.w), offsetY + center(rowHeights[row] - child.h), parent ); }); } }; } // Общая сборка таблицы: и для table, и для swimlanes. Ширина живёт на колонке, // высота на строке — инвариант доски (w таблицы = сумма ширин в каждой строке) // нарушить структурно нельзя. function buildTableLike(spec, context) { const { columns, rows, fontSize, tableColor, title } = spec; for (const column of columns) { column.w = Math.max(column.w, TABLE_COLUMN_MIN_WIDTH); } for (const row of rows) { for (const cell of row.cells) { if (!cell.child) continue; const column = columns[cell.columnIndex]; column.w = Math.max(column.w, cell.child.w + 2 * CELL_PAD_X); } } for (const row of rows) { let height = TABLE_ROW_MIN_HEIGHT; for (const cell of row.cells) { const column = columns[cell.columnIndex]; height = Math.max(height, context.measure(cell.text, column.w, cell.fontSize ?? fontSize)); if (cell.child) height = Math.max(height, cell.child.h + 2 * CELL_PAD_Y); } row.h = height; } const w = columns.reduce((sum, column) => sum + column.w, 0); const h = rows.reduce((sum, row) => sum + row.h, 0); return { w, h, place(x, y, parent) { const cellKeys = rows.map((row) => row.cells.map(() => `c${++cellCounter}`)); const scene = context.emit({ kind: "table", parent, rect: { x, y, w, h }, style: { color: tableColor ?? ELEMENT_COLORS.white, fontSize }, ...(title ? { title: markdownToBlocks(title) } : {}), table: { columns: columns.map((column, index) => ({ key: `col${index + 1}`, w: column.w })), rows: rows.map((row, rowIndex) => ({ key: `row${rowIndex + 1}`, h: row.h, cells: row.cells.map((cell, cellIndex) => ({ key: cellKeys[rowIndex][cellIndex], columnKey: `col${cell.columnIndex + 1}`, text: cell.text, color: cell.color ?? ELEMENT_COLORS.white, opacity: cell.opacity ?? 1, fontSize: cell.fontSize ?? fontSize })) })) }, connectable: false }); context.register(spec.id, scene.key); let offsetY = y; rows.forEach((row, rowIndex) => { let offsetX = x; row.cells.forEach((cell, cellIndex) => { if (cell.child) { cell.child.place(offsetX + CELL_PAD_X, offsetY + CELL_PAD_Y, { nodeKey: scene.key, cellKey: cellKeys[rowIndex][cellIndex] }); } offsetX += columns[cell.columnIndex].w; }); offsetY += row.h; }); } }; } function buildTable(node, context) { const columnSpecs = node.columns.map((column) => ({ id: column.id, title: column.title, w: COLUMN_WIDTHS[column.width] ?? COLUMN_WIDTHS.m })); const indexOf = new Map(columnSpecs.map((column, index) => [column.id, index])); const rows = []; if (columnSpecs.some((column) => column.title)) { rows.push({ cells: columnSpecs.map((column, index) => ({ columnIndex: index, text: markdownToBlocks(`**${column.title ?? ""}**`), color: TABLE_HEADER_COLOR })) }); } for (const row of node.rows ?? []) { rows.push({ id: row.id, cells: columnSpecs.map((column, index) => ({ columnIndex: index, text: markdownToBlocks(row.cells?.[column.id] ?? ""), color: row.color ? ELEMENT_COLORS[row.color] : ELEMENT_COLORS.white, opacity: row.color ? 0.4 : 1, child: row.content?.[column.id] ? buildNode(row.content[column.id], context) : null })) }); } void indexOf; return buildTableLike({ id: node.id, title: node.title, columns: columnSpecs, rows, fontSize: FONT_SIZES.table }, context); } function buildSwimlanes(node, context) { const columns = [ { id: "lane", w: COLUMN_WIDTHS.m }, { id: "content", w: COLUMN_WIDTHS.l } ]; const rows = (node.lanes ?? []).map((lane) => { const laneColor = lane.color ? ELEMENT_COLORS[lane.color] : ELEMENT_COLORS.white; const laneFontSize = lane.fontSize ?? FONT_SIZES.lane; return { id: lane.id, cells: [ { columnIndex: 0, text: markdownToBlocks(lane.title ?? ""), color: laneColor, opacity: lane.color ? 0.4 : 1, fontSize: laneFontSize }, { columnIndex: 1, text: markdownToBlocks(""), color: laneColor, opacity: lane.color ? 0.4 : 1, fontSize: laneFontSize, child: lane.content ? buildNode(lane.content, context) : null } ] }; }); return buildTableLike({ id: node.id, title: node.title, columns, rows, fontSize: FONT_SIZES.lane }, context); } }