docs(map): добавлены карта и руководство по структуре DWH
Объяснены зерно таблиц, связи и путь бронирования до витрины. Добавлена автономная карта с двумя видами связей и адресами объектов. Состояние заглушек сверено с DDL и ТЗ; SQL и DAG не изменялись. Проверки: сборка и ссылки, make lint, pytest (4 passed, 14 skipped), Chromium file:// offline: выбор, связи, сброс, история и узкий экран. Refs #2
This commit is contained in:
1 parent
77d72b08c6
commit
b9bcfe7230
9 files changed
+10806
-373
No files matched your search
@@ -0,0 +1,60 @@
|
||||
# Исходники карты DWH
|
||||
|
||||
Готовый файл - [../architecture-map.html](../architecture-map.html).
|
||||
Откройте его локально в браузере. Сервер и интернет не требуются;
|
||||
SQL и Markdown открываются из соседних каталогов репозитория.
|
||||
Учебный маршрут находится в [руководстве о структуре](../db_schema.md).
|
||||
|
||||
## Редактирование и сборка
|
||||
|
||||
- `map.json` - единственный каталог объектов, полей, связей и текстов карточек.
|
||||
- `template.html` - интерфейс. SVG, карточки, постоянная раскладка и выделение
|
||||
адаптированы из `clickstream-data-platform/docs/handbook/architecture-map/template.html`.
|
||||
- `render.py` - сборка автономного HTML и статические проверки. Нужны uv и Python
|
||||
3.11 или новее; пакеты Python и JavaScript не требуются. JSON заменяет YAML
|
||||
образца, чтобы сборка обходилась стандартной библиотекой.
|
||||
|
||||
Из корня репозитория:
|
||||
|
||||
```bash
|
||||
uv run docs/design/architecture-map/render.py
|
||||
uv run docs/design/architecture-map/render.py --check
|
||||
```
|
||||
|
||||
Правьте исходники, затем пересобирайте HTML. `--check` сверяет:
|
||||
имена объектов с DDL/PXF, важные поля и поля связей, статусы загрузок и DQ,
|
||||
входы готовых SQL со стрелками потока, файлы и разделы по ссылкам,
|
||||
уникальность идентификаторов и позиций, свежесть HTML.
|
||||
Проверка входов учитывает именованные таблицы в FROM/JOIN;
|
||||
смысл соединений нужно сверять при изменении SQL вручную.
|
||||
|
||||
Внешние `stg.*_ext` представлены в карточках STG, поскольку это входы чтения,
|
||||
а не еще одна копия данных. Поля источника сверяются с контрактом PXF.
|
||||
Для заглушек используются DDL и `docs/assignment/analyst_spec.md`.
|
||||
Новые предположения о поведении загрузок в карточки не добавляйте.
|
||||
|
||||
## Адреса объектов
|
||||
|
||||
`../architecture-map.html#node=ods.airplanes` выбирает таблицу.
|
||||
Дополнение `&view=keys` включает связи по ключам; без него показан поток данных.
|
||||
Идентификатор равен полному имени таблицы и не зависит от позиции в карте.
|
||||
Неизвестный адрес возвращает обзор с сообщением. Назад/вперед в браузере
|
||||
восстанавливают предыдущий выбор и вид связей.
|
||||
|
||||
## Проверка в браузере
|
||||
|
||||
Статические проверки не заменяют этот проход:
|
||||
|
||||
1. Откройте готовый HTML с диска. Убедитесь, что видны пять слоев,
|
||||
имена таблиц и пометки заданий; ошибок JavaScript нет.
|
||||
2. Выберите `bookings.tickets`, затем `ods.segments` и `dds.fact_flight_sales`.
|
||||
Проверьте источники, потребителей и карточки связей. Снимите выделение
|
||||
повторным выбором, кнопкой "Общий вид" и клавишей Esc.
|
||||
3. Для выбранных билета, факта и `dds.dim_airplanes` переключите оба вида:
|
||||
выбор, позиции и прокрутка карты должны сохраниться.
|
||||
4. У факта откройте обе роли `dim_airports`, путь через `ods.routes`
|
||||
и выбор версии `dim_routes`. Проверьте отдельные карточки связей.
|
||||
5. Откройте адрес объекта, обновите страницу, проверьте назад/вперед.
|
||||
Откройте DDL, загрузку, DQ и пункт задания из карточки.
|
||||
6. Проверьте выбор клавишами Enter/пробел и вид на узком экране.
|
||||
На узком экране карта прокручивается, карточка находится под ней.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,160 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = []
|
||||
# ///
|
||||
|
||||
"""Проверить данные карты и собрать автономный HTML без внешних зависимостей."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[2]
|
||||
OUTPUT = HERE.parent / "architecture-map.html"
|
||||
MARKER = "/*__MAP_DATA__*/"
|
||||
NOTICE = (
|
||||
"<!-- Собрано из architecture-map/map.json и template.html. "
|
||||
"Команда: uv run docs/design/architecture-map/render.py -->\n"
|
||||
)
|
||||
|
||||
|
||||
def headings(text: str) -> set[str]:
|
||||
"""Якоря Markdown для ссылок на существующие разделы документации."""
|
||||
result = set(re.findall(r'<a\s+id="([^"]+)"', text))
|
||||
for title in re.findall(r"^#{1,6}\s+(.+)$", text, re.M):
|
||||
slug = re.sub(r"[^\w\- ]", "", title.lower()).replace(" ", "-")
|
||||
result.add(slug)
|
||||
return result
|
||||
|
||||
|
||||
def validate(data: dict) -> None:
|
||||
"""Сверить объекты, поля, статусы, входы готовых SQL и локальные ссылки."""
|
||||
nodes = {node["id"]: node for node in data["nodes"]}
|
||||
if len(nodes) != len(data["nodes"]):
|
||||
raise ValueError("Повторяется идентификатор объекта")
|
||||
edge_ids = [e["id"] for e in data["edges"]]
|
||||
if len(set(edge_ids)) != len(edge_ids):
|
||||
raise ValueError("Повторяется идентификатор связи")
|
||||
columns, externals, tables = {}, {}, set()
|
||||
for path in sorted((ROOT / "sql").glob("*/*_ddl.sql")):
|
||||
text = re.sub(r"--[^\n]*", "", path.read_text(encoding="utf-8"))
|
||||
pattern = r"CREATE\s+(EXTERNAL\s+)?TABLE\s+(?:IF NOT EXISTS\s+)?(\w+\.\w+)\s*\((.*?)\n\)"
|
||||
for external, table, body in re.findall(pattern, text, re.S | re.I):
|
||||
columns[table] = set(re.findall(r"^\s*(\w+)\s+\w+", body, re.M))
|
||||
if external:
|
||||
source = re.search(r"pxf://([^?]+)", text)[1]
|
||||
columns[source] = columns[table]
|
||||
externals[table] = source
|
||||
else:
|
||||
tables.add(table)
|
||||
expected = tables | set(externals.values())
|
||||
if set(nodes) != expected:
|
||||
raise ValueError(f"Карта и DDL расходятся: {set(nodes) ^ expected}")
|
||||
parts = {part for n in nodes.values() for part in n["parts"]}
|
||||
if parts != set(externals):
|
||||
raise ValueError("В карточках STG должны быть представлены все внешние таблицы")
|
||||
layers = {layer["id"] for layer in data["layers"]}
|
||||
positions = set()
|
||||
for node in nodes.values():
|
||||
if node["layer"] not in layers:
|
||||
raise ValueError(f"Неизвестный слой: {node['id']}")
|
||||
position = (node["layer"], node["order"])
|
||||
if position in positions:
|
||||
raise ValueError(f"Объекты перекрываются: {position}")
|
||||
positions.add(position)
|
||||
for field in ("what", "grain", "keys", "fields", "fill", "links"):
|
||||
if not node[field]:
|
||||
raise ValueError(f"Пустое поле {field}: {node['id']}")
|
||||
for field in node["fields"]:
|
||||
if field["name"] not in columns[node["id"]]:
|
||||
raise ValueError(f"Нет поля {node['id']}.{field['name']} в DDL/PXF")
|
||||
if node["layer"] == "source":
|
||||
continue
|
||||
layer, name = node["id"].split(".")
|
||||
for role in ("load", "dq"):
|
||||
path = ROOT / f"sql/{layer}/{name}_{role}.sql"
|
||||
sql = re.sub(r"--[^\n]*", "", path.read_text(encoding="utf-8")).strip()
|
||||
stub = sql.upper() == "SELECT 1;"
|
||||
if stub != (node["status"] == "student"):
|
||||
raise ValueError(
|
||||
f"Статус карты не совпадает с {path.relative_to(ROOT)}"
|
||||
)
|
||||
if role != "load" or stub:
|
||||
continue
|
||||
sources = set(re.findall(r"\b(?:FROM|JOIN)\s+(\w+\.\w+)", sql, re.I))
|
||||
sources = {externals.get(s, s) for s in sources if s != node["id"]}
|
||||
sources &= expected
|
||||
drawn = {
|
||||
e["from"]
|
||||
for e in data["edges"]
|
||||
if e["mode"] == "flow" and e["to"] == node["id"]
|
||||
}
|
||||
if sources != drawn:
|
||||
raise ValueError(
|
||||
f"Входы SQL и карты расходятся у {node['id']}: {sources ^ drawn}"
|
||||
)
|
||||
for edge in data["edges"]:
|
||||
if edge["from"] not in nodes or edge["to"] not in nodes:
|
||||
raise ValueError(f"Неизвестный конец связи {edge['id']}")
|
||||
if edge["mode"] not in ("flow", "keys") or not edge["join"] or not edge["refs"]:
|
||||
raise ValueError(f"Неполное описание связи {edge['id']}")
|
||||
for ref in edge["refs"]:
|
||||
if (
|
||||
ref["node"] not in nodes
|
||||
or not set(ref["columns"]) <= columns[ref["node"]]
|
||||
):
|
||||
raise ValueError(f"Неизвестные поля связи {edge['id']}: {ref}")
|
||||
count = 0
|
||||
for item in [*nodes.values(), *data["edges"]]:
|
||||
for link in item["links"]:
|
||||
url = urlsplit(link["url"])
|
||||
if url.scheme or url.netloc:
|
||||
raise ValueError(f"Ссылка должна вести в локальный репозиторий: {link}")
|
||||
path = (OUTPUT.parent / unquote(url.path)).resolve()
|
||||
if not path.is_relative_to(ROOT) or not path.is_file():
|
||||
raise ValueError(f"Файл по ссылке не найден: {link['url']}")
|
||||
if url.fragment and unquote(url.fragment) not in headings(
|
||||
path.read_text(encoding="utf-8")
|
||||
):
|
||||
raise ValueError(f"Раздел по ссылке не найден: {link['url']}")
|
||||
count += 1
|
||||
print(
|
||||
f"Проверены {len(nodes)} объектов, {len(edge_ids)} связей, {count} ссылок; поля, статусы и входы SQL совпадают"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Собрать HTML либо проверить его свежесть вместе с исходными данными."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check", action="store_true", help="проверить без записи")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
data = json.loads((HERE / "map.json").read_text(encoding="utf-8"))
|
||||
validate(data)
|
||||
template = (HERE / "template.html").read_text(encoding="utf-8")
|
||||
if template.count(MARKER) != 1:
|
||||
raise ValueError("В шаблоне должен быть ровно один маркер данных")
|
||||
payload = json.dumps(data, ensure_ascii=False, indent=2).replace("<", "\\u003c")
|
||||
result = NOTICE + template.replace(MARKER, payload)
|
||||
if args.check:
|
||||
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != result:
|
||||
raise ValueError(
|
||||
"HTML устарел. Выполните uv run docs/design/architecture-map/render.py"
|
||||
)
|
||||
print("HTML соответствует исходникам")
|
||||
else:
|
||||
OUTPUT.write_text(result, encoding="utf-8")
|
||||
print(f"Собрана карта: {OUTPUT.relative_to(ROOT)}")
|
||||
return 0
|
||||
except (ValueError, KeyError, OSError) as exc:
|
||||
print(f"Не удалось собрать карту: {exc}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,448 @@
|
||||
<!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: 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) 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);
|
||||
}
|
||||
svg { 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 {
|
||||
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 { stroke-dasharray: 5 3; }
|
||||
.node.student .stream-mark { fill: #875a14; }
|
||||
.edge.expected .edge-line { stroke-dasharray: 5 4; }
|
||||
.node.is-upstream rect { fill: #e3eff5; stroke: #307c9a; }
|
||||
.node.is-downstream rect { fill: #e7f1e5; stroke: #527a43; }
|
||||
.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>Сплошная рамка: готово</span><span>Пунктирная рамка: задание</span>
|
||||
<span id="upstream-legend">Синий: источники</span><span id="downstream-legend">Зеленый: потребители</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("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>
|
||||
Reference in new issue
Block a user