Files
airflow-greenplum/docs/design/architecture-map/template.html
T

481 lines
27 KiB
HTML

<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Карта DWH авиаперевозок</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; flex-wrap: wrap; 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: 8px 20px; color: var(--muted); }
.legend-item { display: inline-flex; align-items: center; gap: 7px; }
.legend-icon { display: block; width: 50px; height: 20px; flex: none; }
main {
display: grid;
grid-template-columns: minmax(0, 1fr) 370px;
gap: 16px;
padding: 0 18px 24px;
align-items: start;
}
.map-scroll {
overflow-x: auto;
border: 1px solid #d7d9da;
border-radius: 10px;
background: var(--panel);
}
#map { display: block; width: 100%; min-width: 1040px; 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, .legend-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: 14px ui-monospace, SFMono-Regular, Consolas, monospace;
text-anchor: middle;
pointer-events: none;
}
.node .stream-mark { fill: var(--muted); font: 11px 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; max-height: calc(100vh - 32px); overflow-y: auto; position: sticky; top: 16px;
padding: 20px;
border: 1px solid #d7d9da;
border-radius: 10px;
background: var(--panel);
}
@media (max-width: 900px) { main { grid-template-columns: minmax(0, 1fr); } aside { min-height: 0; max-height: none; position: static; } .map-scroll { max-height: 50vh; overflow: auto; } }
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; }
.edge[hidden] { display: none; }
.node.student rect, .legend-node.student rect { stroke-dasharray: 5 3; }
.node.student .stream-mark { fill: #875a14; }
.edge.expected .edge-line, .legend-arrow.expected .edge-line { stroke-dasharray: 5 4; }
.node.is-upstream rect, .legend-node.is-upstream rect { fill: #e3eff5; stroke: #307c9a; }
.node.is-downstream rect, .legend-node.is-downstream rect { fill: #e7f1e5; stroke: #527a43; }
.node.is-selected rect, .legend-node.is-selected rect { fill: var(--accent-soft); stroke: var(--accent); }
.relation-button { border-radius: 5px; padding: 5px 7px; text-align: left; width: 100%; margin-top: 5px; font-size: 13px; }
.badge { font-size: 13px; padding: 3px 8px; border-radius: 4px; background: #eef1ed; display: inline-block; }
.badge.student { background: #fff1cf; color: #775018; }
.navigation { margin: 8px 0; }
code { overflow-wrap: anywhere; }
summary { cursor: pointer; font-weight: 600; }
#view-hint { margin: 0; max-width: 1050px; }
noscript { display: block; padding: 20px; }
</style>
</head>
<body>
<header>
<h1 id="page-title"></h1>
<p class="lead" id="lead"></p>
<p class="navigation"><a href="db_schema.md">Руководство: от бронирования до витрины</a> · Учебная ветка main</p>
</header>
<div class="controls">
<div class="switcher" id="switcher" aria-label="Вид связей"></div>
<p id="view-hint" class="hint"></p>
<div class="legend" aria-label="Обозначения стрелок">
<span class="legend-item">
<svg class="legend-icon legend-arrow" viewBox="0 0 50 20" aria-hidden="true"><path class="edge-line" d="M 2 10 H 43"/></svg>
<span id="ready-edge-legend">Готовая загрузка</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-arrow expected" viewBox="0 0 50 20" aria-hidden="true"><path class="edge-line" d="M 2 10 H 43"/></svg>
<span id="expected-edge-legend">Загрузка по ТЗ</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-arrow is-path" viewBox="0 0 50 20" aria-hidden="true"><path class="edge-line" d="M 2 10 H 43"/></svg>
<span>Выделенные связи</span>
</span>
</div>
<div class="legend" aria-label="Обозначения таблиц">
<span class="legend-item">
<svg class="legend-icon legend-node" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span>Готовая таблица</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node student" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span>Учебная заглушка</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node is-upstream" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span id="upstream-legend">Источники</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node is-downstream" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span id="downstream-legend">Потребители</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node is-selected" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span>Выбранная таблица</span>
</span>
</div>
</div>
<main>
<div class="map-scroll">
<svg id="map" viewBox="0 0 1250 725" role="group" aria-label="Путь данных через слои хранилища"></svg>
</div>
<aside id="details" aria-live="polite" aria-label="Карточка выбранного объекта"></aside>
</main>
<noscript>Для интерактивной карты включите JavaScript. Объяснение модели доступно в <a href="db_schema.md">руководстве</a>.</noscript>
<script>
const MAP = /*__MAP_DATA__*/;
const NS = "http://www.w3.org/2000/svg";
const state = { view: "flow", selected: null };
const nodeById = new Map(MAP.nodes.map(node => [node.id, node]));
const edgeById = new Map(MAP.edges.map(edge => [edge.id, edge]));
const positions = new Map();
const svg = document.getElementById("map");
const details = document.getElementById("details");
const statusLabel = {ready: "Готовая загрузка", student: "Задание · загрузка и DQ — заглушки", source: "Источник · демо-БД"};
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 ?? ""; }
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(message = "") {
details.replaceChildren();
const heading = document.createElement("h2");
heading.textContent = message || "Начните с билета";
details.append(heading);
addSection("Маршрут чтения", "Один билет может включать несколько перелетов. Выберите bookings.tickets, затем проследите сегменты до факта и витрины sales_report.");
addSection("Как исследовать", "Выбор таблицы подсвечивает ее окружение: источники и потребителей в потоке данных, родителей и потомков в связях по ключам. Их положение остается прежним. Соседние связи доступны кнопками в карточке. Повторный выбор, Esc или «Общий вид» снимает выделение.");
addSection("Что показывает карта", "Стрелки описывают данные. Порядок задач Airflow смотрите отдельно по ссылке на DAG в карточке. Пунктирная стрелка означает ожидаемую загрузку из задания.");
addSection("Вход через PXF", "Внешние stg.*_ext показаны внутри карточек STG: они читают Postgres, но сами не хранят копию данных.");
addSection("Ключи", "Все ключи и связи здесь логические. DDL таблиц DWH не объявляет PRIMARY KEY, UNIQUE и FOREIGN KEY. DISTRIBUTED BY задает размещение, а не уникальность.");
addLinks([{label: "Открыть руководство о модели", url: "db_schema.md"}]);
}
function relationButtons(node) {
const section = document.createElement("section");
section.className = "card-section";
const h = document.createElement("h3"); h.textContent = "Связи рядом с таблицей";
section.append(h);
const edges = MAP.edges.filter(e => e.mode === state.view && (e.from === node.id || e.to === node.id));
edges.forEach(edge => {
const b = document.createElement("button"); b.className = "relation-button";
const neighbor = edge.from === node.id ? edge.to : edge.from;
b.textContent = `${edge.from === node.id ? "→" : "←"} ${neighbor}: ${edge.label}`;
b.dataset.edge = edge.id;
b.addEventListener("click", () => selectItem("edge", edge.id));
section.append(b);
});
if (!edges.length) {
const p = document.createElement("p"); p.textContent = "В этом виде рядом нет связей. Переключите вид, чтобы увидеть путь загрузки."; section.append(p);
}
details.append(section);
}
function renderNodeCard(node) {
details.replaceChildren();
const heading = document.createElement("h2"); heading.textContent = node.name;
const meta = document.createElement("p"); meta.className = `badge ${node.status}`; meta.textContent = statusLabel[node.status];
details.append(heading, meta);
addSection("Назначение", node.what);
addSection("Одна строка", node.grain);
addSection("Логические ключи", node.keys);
addSection(node.status === "student" ? "Ожидаемый результат по ТЗ" : "Как наполняется", node.fill);
if (node.parts.length) addSection("Вход через PXF", node.parts.map(id => `\`${id}\` — внешняя таблица для чтения Postgres, без локального хранения. Ее определение находится в том же DDL.`));
const fields = document.createElement("details"); fields.className = "card-section";
const summary = document.createElement("summary"); summary.textContent = `Важные поля (${node.fields.length})`;
const list = document.createElement("ul");
node.fields.forEach(field => {
const li = document.createElement("li"); appendRichText(li, "`" + field.name + "` — " + field.meaning); list.append(li);
});
fields.append(summary,list); details.append(fields);
relationButtons(node);
const dag = {stg:"stage",ods:"ods",dds:"dds",dm:"dm"}[node.layer];
addLinks([...node.links, ...(dag ? [{label:"DAG · порядок задач",url:`../../airflow/dags/bookings_to_gp_${dag}.py`}] : []), {label:"Ссылка на эту таблицу",url:`#node=${node.id}&view=${state.view}`}]);
}
function renderEdgeCard(edge) {
details.replaceChildren();
const heading = document.createElement("h2"); heading.textContent = edge.label; details.append(heading);
addSection("Таблицы", "`" + edge.from + "` → `" + edge.to + "`");
addSection("Вид связи", edge.mode === "flow" ? (edge.status === "student" ? "Ожидаемый поток по ТЗ; загрузка пока не реализована." : "Чтение и преобразование данных в готовом SQL.") : "Логическое сопоставление строк по ключам. Это не зависимость задач Airflow и не объявленное ограничение БД.");
addSection("Что связывает", edge.join);
addSection("Участвующие поля", edge.refs.map(r => `\`${r.node}\`: ${r.columns.map(c => "`"+c+"`").join(", ")}`));
[edge.from, edge.to].forEach(id => {
const b = document.createElement("button"); b.className = "relation-button"; b.textContent = `Открыть ${id}`;
b.addEventListener("click", () => selectItem("node", id)); details.append(b);
});
addLinks(edge.links);
}
function layoutMap() {
MAP.layers.forEach((layer, index) => {
MAP.nodes.filter(node => node.layer === layer.id).forEach(node => {
positions.set(node.id, {x: 20 + index * 250, y: 72 + node.order * 70, width: 195, height: 48});
});
});
}
function edgePath(edge, offset) {
const a = positions.get(edge.from), b = positions.get(edge.to);
const y1 = a.y + a.height / 2 + offset, y2 = b.y + b.height / 2 + offset;
if (a.x === b.x) {
const x = a.x + a.width, bend = x + 20 + Math.abs(a.y - b.y) * .08 + offset;
return `M ${x} ${y1} C ${bend} ${y1}, ${bend} ${y2}, ${x} ${y2}`;
}
const x1 = a.x + a.width, x2 = b.x, 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 = 117.5 + index * 250;
const title = svgElement("text", {x:center, y:27, 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);
});
MAP.edges.forEach(edge => {
const siblings = MAP.edges.filter(e => e.from === edge.from && e.to === edge.to && e.mode === edge.mode);
const offset = (siblings.indexOf(edge) - (siblings.length - 1) / 2) * 13;
const group = svgElement("g", {class:`edge ${edge.status === "student" ? "expected" : ""}`, "data-id":edge.id, tabindex:"0", role:"button", "aria-label":`${edge.label}: ${edge.from} → ${edge.to}`});
const d = edgePath(edge,offset);
group.append(svgElement("path", {d,class:"edge-line"}),svgElement("path", {d,class:"edge-hit"}));
bindSelection(group,"edge",edge.id); svg.append(group);
});
MAP.nodes.forEach(node => {
const p = positions.get(node.id);
const g = svgElement("g", {class:`node ${node.status}`, "data-id":node.id, tabindex:"0", role:"button", "aria-label":`${node.name}: ${statusLabel[node.status]}`});
g.append(svgElement("rect",p));
const label = svgElement("text", {x:p.x+p.width/2,y:p.y+21}); label.textContent = node.name;
const mark = svgElement("text", {x:p.x+p.width/2,y:p.y+38,class:"stream-mark"}); mark.textContent = {ready:"готово",student:"задание",source:"источник"}[node.status];
g.append(label,mark); bindSelection(g,"node",node.id); svg.append(g);
});
}
function bindSelection(element,type,id) {
element.addEventListener("click", () => selectItem(type,id));
element.addEventListener("keydown", event => {
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectItem(type,id); }
});
}
function walk(nodeId,direction) {
const nodes = new Set([nodeId]), edges = new Set(), queue = [nodeId];
while (queue.length) {
const current = queue.shift();
MAP.edges.filter(e => e.mode === state.view).forEach(edge => {
if ((direction === "in" ? edge.to : edge.from) !== current) return;
edges.add(edge.id);
const next = direction === "in" ? edge.from : edge.to;
if (!nodes.has(next)) { nodes.add(next); queue.push(next); }
});
}
return {nodes,edges};
}
function updateHighlights() {
let upstream = new Set(), downstream = new Set(), activeEdges = new Set(), activeNodes = new Set();
if (state.selected?.type === "node") {
const a = walk(state.selected.id,"in"), b = walk(state.selected.id,"out");
upstream = a.nodes; downstream = b.nodes;
activeNodes = new Set([...upstream,...downstream]); activeEdges = new Set([...a.edges,...b.edges]);
} else if (state.selected?.type === "edge") {
const edge = edgeById.get(state.selected.id);
activeNodes = new Set([edge.from,edge.to]); activeEdges.add(edge.id);
}
svg.querySelectorAll(".node").forEach(el => {
const id = el.dataset.id, selected = state.selected?.type === "node" && state.selected.id === id;
el.classList.toggle("is-context-dim", Boolean(state.selected) && !activeNodes.has(id));
el.classList.toggle("is-upstream", upstream.has(id)); el.classList.toggle("is-downstream", downstream.has(id));
el.classList.toggle("is-selected", selected); el.setAttribute("aria-pressed",String(selected));
});
svg.querySelectorAll(".edge").forEach(el => {
const edge = edgeById.get(el.dataset.id), visible = edge.mode === state.view;
el.toggleAttribute("hidden", !visible); el.setAttribute("tabindex",visible ? "0" : "-1");
el.setAttribute("aria-hidden",String(!visible));
el.classList.toggle("is-context-dim",Boolean(state.selected) && !activeEdges.has(edge.id));
el.classList.toggle("is-path",activeEdges.has(edge.id));
const selected = state.selected?.type === "edge" && state.selected.id === edge.id;
el.classList.toggle("is-selected", selected); el.setAttribute("aria-pressed",String(selected));
});
}
function renderState(message = "") {
document.querySelectorAll("[data-view]").forEach(b => b.setAttribute("aria-pressed",String(b.dataset.view === state.view)));
document.getElementById("upstream-legend").textContent = state.view === "flow" ? "Источники" : "Связанные родители";
document.getElementById("downstream-legend").textContent = state.view === "flow" ? "Потребители" : "Связанные потомки";
document.getElementById("ready-edge-legend").textContent = state.view === "flow" ? "Готовая загрузка" : "Связь по ключам";
document.getElementById("expected-edge-legend").textContent = state.view === "flow" ? "Загрузка по ТЗ" : "Связь с учебной заглушкой";
document.getElementById("view-hint").textContent = state.view === "flow" ? "Стрелка: откуда загрузчик читает данные и куда записывает результат. Календарь генерируется отдельно." : "Стрелка: от родительской строки к связанным строкам. Например, от бронирования к билетам. Это не порядок загрузки и не физические FK.";
if (state.selected?.type === "node") renderNodeCard(nodeById.get(state.selected.id));
else if (state.selected?.type === "edge") renderEdgeCard(edgeById.get(state.selected.id));
else renderIntro(message);
details.scrollTop = 0; updateHighlights();
}
function saveAddress() {
const p = new URLSearchParams();
if (state.selected) p.set(state.selected.type,state.selected.id);
if (state.view !== "flow") p.set("view",state.view);
const hash = p.toString();
if (location.hash.slice(1) !== hash) location.hash = hash;
renderState();
}
function selectItem(type,id) {
const repeated = state.selected?.type === type && state.selected.id === id;
state.selected = repeated ? null : {type,id}; saveAddress();
}
function readAddress() {
const p = new URLSearchParams(location.hash.slice(1));
state.view = p.get("view") === "keys" ? "keys" : "flow";
state.selected = null;
if (nodeById.has(p.get("node"))) state.selected = {type:"node",id:p.get("node")};
else if (edgeById.has(p.get("edge"))) {
const edge = edgeById.get(p.get("edge")); state.view = edge.mode; state.selected = {type:"edge",id:edge.id};
}
renderState(!state.selected && (p.has("node") || p.has("edge")) ? "Объект по ссылке не найден. Выберите таблицу на карте." : "");
}
function renderControls() {
const switcher = document.getElementById("switcher");
[["flow","Поток данных"],["keys","Связи по ключам"]].forEach(([id,label]) => {
const b = document.createElement("button"); b.textContent = label; b.dataset.view = id;
b.addEventListener("click", () => {
state.view = id;
if (state.selected?.type === "edge") state.selected = {type:"node",id:edgeById.get(state.selected.id).to};
saveAddress();
}); switcher.append(b);
});
const clear = document.createElement("button"); clear.textContent = "Общий вид"; clear.id = "clear-selection";
clear.addEventListener("click", () => {state.selected = null; saveAddress();}); switcher.append(clear);
}
document.getElementById("page-title").textContent = MAP.title;
document.getElementById("lead").textContent = MAP.lead;
layoutMap(); drawMap(); renderControls(); readAddress();
window.addEventListener("hashchange",readAddress);
document.addEventListener("keydown",event => {
if (event.key === "Escape" && state.selected) {state.selected = null; saveAddress();}
});
</script>
</body>
</html>