Зачем: путеводитель рассказывает путь данных через слои словами, а карта дает его увидеть и потрогать: у одного смысла в каждом слое свое имя и своя поверхность, а переносят данные три разных механизма — матвью непрерывно, даг пачкой, представление без копии. Постановка — тикет #222, форма — резолюция #208 (один самодостаточный HTML, исходники рядом). Что: docs/handbook/architecture-map.html — готовая карта; исходники в docs/handbook/architecture-map/ (map.yaml — слои, узлы, стрелки, карточки; template.html — разметка, стили, скрипт; render.py — сборка и --check; README.md). Щелчок по узлу открывает карточку и подсвечивает путь, щелчок по стрелке — кто и каким SQL переносит данные; переключатель потока приглушает чужую часть карты. Ссылки карточек ведут в справочники и DDL на Gitea. Путеводитель и глава о хранилище ведут на карту; make lint проверяет ruff для сборщика и свежесть рендера; testing.md и AGENTS.md обновлены. Проверка: uv run docs/handbook/architecture-map/render.py --check и make lint зеленые; внешних запросов в HTML нет; ссылки и якоря карточек сверены с Gitea; карта прощелкана в headless Chromium на ширинах 1280–1920: подсветка пути, фильтр, Esc, карточки узлов и стрелок. Ревью: постановка — Opus; реализация — Codex (Sol), два слепых ревью Opus (уместность и код), вычитка прозы Sol.
454 lines
19 KiB
HTML
454 lines
19 KiB
HTML
<!doctype html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Карта слоев хранилища</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: light;
|
|
--paper: #f7f7f5;
|
|
--panel: #ffffff;
|
|
--ink: #272a2d;
|
|
--muted: #6b7177;
|
|
--line: #8c9298;
|
|
--soft: #e6e8e9;
|
|
--accent: #b54a2a;
|
|
--accent-soft: #f5ded7;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
background: var(--paper);
|
|
color: var(--ink);
|
|
font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
}
|
|
header { padding: 22px 26px 12px; }
|
|
h1 { margin: 0 0 5px; font-size: 25px; }
|
|
.lead { margin: 0; color: var(--muted); }
|
|
.controls {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
padding: 8px 26px 16px;
|
|
}
|
|
.switcher { display: flex; gap: 6px; }
|
|
button {
|
|
border: 1px solid #aeb3b7;
|
|
border-radius: 18px;
|
|
background: var(--panel);
|
|
color: var(--ink);
|
|
padding: 6px 13px;
|
|
font: inherit;
|
|
cursor: pointer;
|
|
}
|
|
button[aria-pressed="true"] {
|
|
border-color: var(--accent);
|
|
background: var(--accent-soft);
|
|
color: #71301e;
|
|
}
|
|
button:focus-visible, [tabindex="0"]:focus-visible {
|
|
outline: 3px solid #d79078;
|
|
outline-offset: 2px;
|
|
}
|
|
.legend { display: flex; flex-wrap: wrap; gap: 15px; color: var(--muted); }
|
|
.legend-item { display: inline-flex; align-items: center; gap: 7px; }
|
|
.legend-line { width: 34px; border-top: 2px solid var(--line); }
|
|
.legend-line.dashed { border-top-style: dashed; }
|
|
.legend-line.dotted { border-top-style: dotted; }
|
|
main {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) 350px;
|
|
gap: 16px;
|
|
padding: 0 18px 24px;
|
|
align-items: start;
|
|
}
|
|
.map-scroll {
|
|
overflow-x: auto;
|
|
border: 1px solid #d7d9da;
|
|
border-radius: 10px;
|
|
background: var(--panel);
|
|
}
|
|
svg { display: block; width: 100%; min-width: 980px; height: auto; }
|
|
.layer-title { font-size: 15px; font-weight: 700; text-anchor: middle; }
|
|
.layer-caption { font-size: 11px; fill: var(--muted); text-anchor: middle; }
|
|
.zone-caption { font-size: 12px; fill: var(--muted); }
|
|
.column-guide { stroke: #eef0f1; stroke-width: 1; }
|
|
.edge-line {
|
|
fill: none;
|
|
stroke: var(--line);
|
|
stroke-width: 1.8;
|
|
marker-end: url(#arrowhead);
|
|
transition: opacity .15s, stroke .15s, stroke-width .15s;
|
|
}
|
|
.edge-line.dashed { stroke-dasharray: 8 6; }
|
|
.edge-line.dotted { stroke-dasharray: 2 6; stroke-linecap: round; }
|
|
.edge-hit { fill: none; stroke: transparent; stroke-width: 14; cursor: pointer; }
|
|
.node { cursor: pointer; transition: opacity .15s; }
|
|
.node rect {
|
|
fill: #f1f2f2;
|
|
stroke: #858b90;
|
|
stroke-width: 1.2;
|
|
rx: 7;
|
|
transition: fill .15s, stroke .15s, stroke-width .15s;
|
|
}
|
|
.node text {
|
|
fill: var(--ink);
|
|
font: 12px ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
text-anchor: middle;
|
|
pointer-events: none;
|
|
}
|
|
.node .stream-mark { fill: var(--muted); font: 10px system-ui, sans-serif; }
|
|
.is-filtered, .is-context-dim { opacity: .16; }
|
|
.is-path:not(.is-filtered), .is-hover:not(.is-filtered) { opacity: 1; }
|
|
.is-path .edge-line, .edge.is-path .edge-line {
|
|
stroke: var(--accent); stroke-width: 2.8; marker-end: url(#arrowhead-active);
|
|
}
|
|
.node.is-path rect { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; }
|
|
.node.is-hover rect { fill: #faefeb; stroke: #d79078; }
|
|
.edge.is-hover .edge-line { stroke: #d79078; stroke-width: 2.2; }
|
|
.node.is-selected rect { stroke-width: 3; }
|
|
.edge.is-selected .edge-line { stroke-width: 4; }
|
|
aside {
|
|
min-height: 470px;
|
|
padding: 20px;
|
|
border: 1px solid #d7d9da;
|
|
border-radius: 10px;
|
|
background: var(--panel);
|
|
}
|
|
@media (max-width: 1530px) { main { grid-template-columns: minmax(0, 1fr); } aside { min-height: 0; } }
|
|
aside h2 { margin: 0 0 3px; font-size: 20px; overflow-wrap: anywhere; }
|
|
.meta { margin: 0 0 18px; color: var(--muted); }
|
|
.card-section { margin-top: 17px; }
|
|
.card-section h3 { margin: 0 0 5px; font-size: 13px; }
|
|
.card-section p { margin: 0; }
|
|
.card-section ul { margin: 5px 0 0; padding-left: 20px; }
|
|
.card-section li + li { margin-top: 5px; }
|
|
code { font: .92em ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
a { color: #8a351e; text-underline-offset: 2px; }
|
|
.hint { color: var(--muted); font-size: 13px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1 id="page-title"></h1>
|
|
<p class="lead" id="lead"></p>
|
|
</header>
|
|
<div class="controls">
|
|
<div class="switcher" id="switcher" aria-label="Выбор потока"></div>
|
|
<div class="legend" id="legend" aria-label="Механизмы переноса"></div>
|
|
</div>
|
|
<main>
|
|
<div class="map-scroll">
|
|
<svg id="map" viewBox="0 0 1230 470" role="group" aria-label="Путь данных через слои хранилища"></svg>
|
|
</div>
|
|
<aside id="details" aria-live="polite"></aside>
|
|
</main>
|
|
<script>
|
|
const MAP = /*__MAP_DATA__*/;
|
|
const NS = "http://www.w3.org/2000/svg";
|
|
const state = { stream: "all", selected: null, hovered: null };
|
|
const nodeById = new Map(MAP.nodes.map((node) => [node.id, node]));
|
|
const mechanismById = new Map(MAP.mechanisms.map((item) => [item.id, item]));
|
|
const positions = new Map();
|
|
const svg = document.getElementById("map");
|
|
const details = document.getElementById("details");
|
|
function svgElement(name, attributes = {}) {
|
|
const element = document.createElementNS(NS, name);
|
|
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value));
|
|
return element;
|
|
}
|
|
function layerTitle(layerId) { return MAP.layers.find((layer) => layer.id === layerId)?.title ?? MAP.dictionary.title; }
|
|
|
|
function appendRichText(parent, value) {
|
|
value.split("`").forEach((part, index) => {
|
|
const element = index % 2 ? document.createElement("code") : document.createTextNode(part);
|
|
if (index % 2) element.textContent = part;
|
|
parent.append(element);
|
|
});
|
|
}
|
|
function addSection(title, value) {
|
|
if (!value || (Array.isArray(value) && !value.length)) return;
|
|
const section = document.createElement("section");
|
|
section.className = "card-section";
|
|
const heading = document.createElement("h3");
|
|
heading.textContent = title;
|
|
section.append(heading);
|
|
if (Array.isArray(value)) {
|
|
const list = document.createElement("ul");
|
|
value.forEach((item) => {
|
|
const row = document.createElement("li");
|
|
appendRichText(row, item);
|
|
list.append(row);
|
|
});
|
|
section.append(list);
|
|
} else {
|
|
const paragraph = document.createElement("p");
|
|
appendRichText(paragraph, value);
|
|
section.append(paragraph);
|
|
}
|
|
details.append(section);
|
|
}
|
|
function addLinks(links) {
|
|
if (!links?.length) return;
|
|
const section = document.createElement("section");
|
|
section.className = "card-section";
|
|
const heading = document.createElement("h3");
|
|
heading.textContent = "Ссылки";
|
|
const list = document.createElement("ul");
|
|
links.forEach((item) => {
|
|
const row = document.createElement("li");
|
|
const link = document.createElement("a");
|
|
link.href = item.url;
|
|
link.target = "_blank";
|
|
link.rel = "noopener noreferrer";
|
|
link.textContent = item.label;
|
|
row.append(link);
|
|
list.append(row);
|
|
});
|
|
section.append(heading, list);
|
|
details.append(section);
|
|
}
|
|
function renderIntro() {
|
|
details.replaceChildren();
|
|
const heading = document.createElement("h2");
|
|
heading.textContent = "Как пользоваться картой";
|
|
details.append(heading);
|
|
MAP.intro.forEach((line) => {
|
|
const paragraph = document.createElement("p");
|
|
paragraph.textContent = line;
|
|
details.append(paragraph);
|
|
});
|
|
const paragraph = document.createElement("p");
|
|
const link = document.createElement("a");
|
|
link.href = MAP.chapter_url;
|
|
link.target = "_blank";
|
|
link.rel = "noopener noreferrer";
|
|
link.textContent = "Открыть главу «Хранилище и его слои»";
|
|
paragraph.append(link);
|
|
details.append(paragraph);
|
|
const hint = document.createElement("p");
|
|
hint.className = "hint";
|
|
hint.textContent = "Повторный щелчок или Esc снимает выделение.";
|
|
details.append(hint);
|
|
}
|
|
function renderNodeCard(node) {
|
|
details.replaceChildren();
|
|
const heading = document.createElement("h2");
|
|
heading.textContent = node.name;
|
|
const meta = document.createElement("p");
|
|
meta.className = "meta";
|
|
meta.textContent = `${layerTitle(node.layer)} — ${node.streams.map((id) => MAP.streams[id]).join(", ")}`;
|
|
details.append(heading, meta);
|
|
addSection("Что это", node.what);
|
|
addSection("Как читать", node.read);
|
|
addSection("Из чего состоит", node.parts);
|
|
addSection("Ключи", node.keys);
|
|
addSection("Кто наполняет и как часто", node.fill);
|
|
addLinks(node.links);
|
|
}
|
|
function renderEdgeCard(edge) {
|
|
details.replaceChildren();
|
|
const heading = document.createElement("h2");
|
|
heading.textContent = `${nodeById.get(edge.from).name} → ${nodeById.get(edge.to).name}`;
|
|
details.append(heading);
|
|
addSection("Механизм", mechanismById.get(edge.mechanism).label);
|
|
addSection("Ритм", edge.rhythm);
|
|
addSection("Кто именно", edge.actor);
|
|
addLinks(edge.links);
|
|
}
|
|
function layoutMap() {
|
|
const nodeWidth = 180;
|
|
const nodeHeight = 48;
|
|
const columnStep = 250;
|
|
MAP.layers.forEach((layer, index) => {
|
|
const x = 20 + index * columnStep;
|
|
MAP.nodes.filter((node) => node.layer === layer.id).forEach((node) => {
|
|
positions.set(node.id, { x, y: 88 + node.order * 70, width: nodeWidth, height: nodeHeight });
|
|
});
|
|
});
|
|
const dicX = 20 + ((MAP.layers.findIndex((layer) => layer.id === "dds") + MAP.layers.findIndex((layer) => layer.id === "dm")) * columnStep) / 2 + nodeWidth / 2 - 130;
|
|
MAP.nodes.filter((node) => node.layer === "dic").forEach((node) => positions.set(node.id, { x: dicX, y: 402 + node.order * 70, width: 260, height: nodeHeight }));
|
|
}
|
|
function edgePath(edge, sameColumnIndex) {
|
|
const source = positions.get(edge.from);
|
|
const target = positions.get(edge.to);
|
|
if (nodeById.get(edge.from).layer === "dic") {
|
|
const x1 = source.x + source.width / 2;
|
|
const y1 = source.y;
|
|
const x2 = target.x;
|
|
const y2 = target.y + target.height / 2;
|
|
return `M ${x1} ${y1} C ${x1} ${y1 - 38}, ${x1} ${y2 + 48}, ${x2} ${y2}`;
|
|
}
|
|
const x1 = source.x + source.width;
|
|
const y1 = source.y + source.height / 2;
|
|
if (source.x === target.x) {
|
|
const route = source.x + source.width + 16 + sameColumnIndex * 18;
|
|
const y2 = target.y + target.height / 2;
|
|
return `M ${x1} ${y1} C ${route} ${y1}, ${route} ${y2}, ${x1} ${y2}`;
|
|
}
|
|
const x2 = target.x;
|
|
const y2 = target.y + target.height / 2;
|
|
const bend = Math.max(18, (x2 - x1) / 2);
|
|
return `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`;
|
|
}
|
|
|
|
function drawMap() {
|
|
const defs = svgElement("defs");
|
|
[["arrowhead", "#8c9298"], ["arrowhead-active", "#b54a2a"]].forEach(([id, fill]) => {
|
|
const marker = svgElement("marker", { id, viewBox: "0 0 8 8", refX: "7", refY: "4", markerWidth: "6", markerHeight: "6", orient: "auto" });
|
|
marker.append(svgElement("path", { d: "M 0 0 L 8 4 L 0 8 z", fill }));
|
|
defs.append(marker);
|
|
});
|
|
svg.append(defs);
|
|
MAP.layers.forEach((layer, index) => {
|
|
const center = 110 + index * 250;
|
|
svg.append(svgElement("line", { x1: center, y1: 65, x2: center, y2: 377, class: "column-guide" }));
|
|
const title = svgElement("text", { x: center, y: 28, class: "layer-title" });
|
|
title.textContent = layer.title;
|
|
const caption = svgElement("text", { x: center, y: 47, class: "layer-caption" });
|
|
caption.textContent = layer.caption;
|
|
svg.append(title, caption);
|
|
});
|
|
const dictionaryPosition = positions.get(MAP.nodes.find((node) => node.layer === "dic").id);
|
|
const zone = svgElement("text", { x: dictionaryPosition.x, y: dictionaryPosition.y - 13, class: "zone-caption" });
|
|
zone.textContent = MAP.dictionary.caption;
|
|
svg.append(zone);
|
|
let sameIndex = 0;
|
|
MAP.edges.forEach((edge) => {
|
|
const same = positions.get(edge.from).x === positions.get(edge.to).x;
|
|
const index = same ? sameIndex++ : 0;
|
|
const group = svgElement("g", { class: "edge", "data-id": edge.id, tabindex: "0", role: "button", "aria-label": `${nodeById.get(edge.from).name} — ${nodeById.get(edge.to).name}` });
|
|
const path = edgePath(edge, index);
|
|
const lineClass = `edge-line ${mechanismById.get(edge.mechanism).line}`;
|
|
group.append(svgElement("path", { d: path, class: lineClass }), svgElement("path", { d: path, class: "edge-hit" }));
|
|
bindSelection(group, "edge", edge.id);
|
|
svg.append(group);
|
|
});
|
|
MAP.nodes.forEach((node) => {
|
|
const position = positions.get(node.id);
|
|
const group = svgElement("g", { class: "node", "data-id": node.id, tabindex: "0", role: "button", "aria-label": node.name });
|
|
group.append(svgElement("rect", position));
|
|
const name = svgElement("text", { x: position.x + position.width / 2, y: position.y + 21 });
|
|
name.textContent = node.name;
|
|
const mark = svgElement("text", { x: position.x + position.width / 2, y: position.y + 38, class: "stream-mark" });
|
|
mark.textContent = node.streams.map((id) => MAP.streams[id]).join(" + ");
|
|
group.append(name, mark);
|
|
group.addEventListener("mouseenter", () => { state.hovered = node.id; updateHighlights(); });
|
|
group.addEventListener("mouseleave", () => { state.hovered = null; updateHighlights(); });
|
|
bindSelection(group, "node", node.id);
|
|
svg.append(group);
|
|
});
|
|
}
|
|
function bindSelection(element, type, id) {
|
|
const choose = () => selectItem(type, id);
|
|
element.addEventListener("click", choose);
|
|
element.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); choose(); }
|
|
});
|
|
}
|
|
function findReachability(nodeId) {
|
|
const nodes = new Set([nodeId]);
|
|
const edges = new Set();
|
|
const walk = (start, direction) => {
|
|
const queue = [start];
|
|
while (queue.length) {
|
|
const current = queue.shift();
|
|
MAP.edges.forEach((edge) => {
|
|
const matches = direction === "out" ? edge.from === current : edge.to === current;
|
|
if (!matches) return;
|
|
edges.add(edge.id);
|
|
const next = direction === "out" ? edge.to : edge.from;
|
|
if (!nodes.has(next)) { nodes.add(next); queue.push(next); }
|
|
});
|
|
}
|
|
};
|
|
walk(nodeId, "in");
|
|
walk(nodeId, "out");
|
|
return { nodes, edges };
|
|
}
|
|
|
|
function selectItem(type, id) {
|
|
const repeated = state.selected?.type === type && state.selected.id === id;
|
|
state.selected = repeated ? null : { type, id };
|
|
if (!state.selected) renderIntro();
|
|
else if (type === "node") renderNodeCard(nodeById.get(id));
|
|
else renderEdgeCard(MAP.edges.find((edge) => edge.id === id));
|
|
updateHighlights();
|
|
}
|
|
|
|
function updateHighlights() {
|
|
let activeNodes = new Set();
|
|
let activeEdges = new Set();
|
|
if (state.selected?.type === "node") ({ nodes: activeNodes, edges: activeEdges } = findReachability(state.selected.id));
|
|
if (state.selected?.type === "edge") {
|
|
const edge = MAP.edges.find((item) => item.id === state.selected.id);
|
|
activeNodes = new Set([edge.from, edge.to]);
|
|
activeEdges = new Set([edge.id]);
|
|
}
|
|
const hoverNodes = new Set(state.hovered ? [state.hovered] : []);
|
|
const hoverEdges = new Set();
|
|
if (state.hovered) MAP.edges.forEach((edge) => {
|
|
if (edge.from === state.hovered || edge.to === state.hovered) {
|
|
hoverEdges.add(edge.id); hoverNodes.add(edge.from); hoverNodes.add(edge.to);
|
|
}
|
|
});
|
|
document.querySelectorAll(".node").forEach((element) => {
|
|
const node = nodeById.get(element.dataset.id);
|
|
element.classList.toggle("is-filtered", state.stream !== "all" && !node.streams.includes(state.stream));
|
|
element.classList.toggle("is-context-dim", Boolean(state.selected) && !activeNodes.has(node.id));
|
|
element.classList.toggle("is-path", activeNodes.has(node.id));
|
|
element.classList.toggle("is-hover", hoverNodes.has(node.id));
|
|
element.classList.toggle("is-selected", state.selected?.type === "node" && state.selected.id === node.id);
|
|
});
|
|
document.querySelectorAll(".edge").forEach((element) => {
|
|
const edge = MAP.edges.find((item) => item.id === element.dataset.id);
|
|
element.classList.toggle("is-filtered", state.stream !== "all" && !edge.streams.includes(state.stream));
|
|
element.classList.toggle("is-context-dim", Boolean(state.selected) && !activeEdges.has(edge.id));
|
|
element.classList.toggle("is-path", activeEdges.has(edge.id));
|
|
element.classList.toggle("is-hover", hoverEdges.has(edge.id));
|
|
element.classList.toggle("is-selected", state.selected?.type === "edge" && state.selected.id === edge.id);
|
|
});
|
|
}
|
|
|
|
function renderControls() {
|
|
const switcher = document.getElementById("switcher");
|
|
Object.entries(MAP.streams).forEach(([id, label]) => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.textContent = label;
|
|
button.setAttribute("aria-pressed", String(id === state.stream));
|
|
button.addEventListener("click", () => {
|
|
state.stream = id;
|
|
switcher.querySelectorAll("button").forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
|
|
updateHighlights();
|
|
});
|
|
switcher.append(button);
|
|
});
|
|
const legend = document.getElementById("legend");
|
|
MAP.mechanisms.forEach((item) => {
|
|
const row = document.createElement("span");
|
|
row.className = "legend-item";
|
|
const line = document.createElement("span");
|
|
line.className = `legend-line ${item.line}`;
|
|
const label = document.createElement("span");
|
|
appendRichText(label, `${item.label} — ${item.rhythm}`);
|
|
row.append(line, label);
|
|
legend.append(row);
|
|
});
|
|
}
|
|
|
|
document.getElementById("page-title").textContent = MAP.title;
|
|
document.getElementById("lead").textContent = MAP.lead;
|
|
layoutMap();
|
|
drawMap();
|
|
renderControls();
|
|
renderIntro();
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape" && state.selected) selectItem(state.selected.type, state.selected.id);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|