Зачем: помочь учащемуся понять назначение объектов и обработку данных по стрелкам карты. Что: вычитаны карточки и подписи, пояснены термины, удалены повторы. Описания обработки уточнены по SQL, HTML пересобран. Проверка: сохранены 17 объектов и 17 стрелок, проверены 85 ссылок. В Chromium проверены карточки, фильтры, подсветка, Escape и отображение при ширине 1920 и 1280 пикселей. make lint и git diff --cached --check прошли.
1518 lines
76 KiB
HTML
1518 lines
76 KiB
HTML
<!-- Файл собран автоматически. Правьте architecture-map/map.yaml и template.html, затем выполните uv run docs/handbook/architecture-map/render.py. -->
|
||
<!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 = {
|
||
"title": "Карта слоев хранилища",
|
||
"lead": "Проследите путь событий и заказов от Kafka до отчетов.",
|
||
"chapter_url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/handbook/storage-layers.md#user-content-хранилище-и-его-слои",
|
||
"intro": [
|
||
"Выберите объект, чтобы узнать, что он хранит, как его читать и откуда в него поступают данные.",
|
||
"Выберите стрелку, чтобы посмотреть способ обработки и открыть SQL-запрос.",
|
||
"Для выбранного объекта подсвечиваются все пути, по которым данные поступают в него и используются дальше.",
|
||
"Кнопки «События» и «Заказы» выделяют нужный поток. Остальные объекты остаются на месте, но становятся бледнее.",
|
||
"Если вы впервые знакомитесь со слоями хранилища, начните с главы по ссылке ниже."
|
||
],
|
||
"streams": {
|
||
"all": "Все",
|
||
"events": "События",
|
||
"orders": "Заказы"
|
||
},
|
||
"dictionary": {
|
||
"title": "Словарь",
|
||
"caption": "справочные данные"
|
||
},
|
||
"layers": [
|
||
{
|
||
"id": "kafka",
|
||
"title": "Kafka",
|
||
"caption": "сообщения источников"
|
||
},
|
||
{
|
||
"id": "stg",
|
||
"title": "STG",
|
||
"caption": "исходные сообщения"
|
||
},
|
||
{
|
||
"id": "ods",
|
||
"title": "ODS",
|
||
"caption": "данные разобраны по полям"
|
||
},
|
||
{
|
||
"id": "dds",
|
||
"title": "DDS",
|
||
"caption": "события, заказы и сессии"
|
||
},
|
||
{
|
||
"id": "dm",
|
||
"title": "DM",
|
||
"caption": "витрины для отчетов"
|
||
}
|
||
],
|
||
"mechanisms": [
|
||
{
|
||
"id": "materialized_view",
|
||
"label": "материализованное представление",
|
||
"line": "solid",
|
||
"rhythm": "непрерывно, по мере прихода сообщений"
|
||
},
|
||
{
|
||
"id": "airflow_dag",
|
||
"label": "даг Airflow",
|
||
"line": "dashed",
|
||
"rhythm": "пакетно: раз в модельные сутки (`orders_ingest`) или при каждом запуске конвейера"
|
||
},
|
||
{
|
||
"id": "view",
|
||
"label": "представление",
|
||
"line": "dotted",
|
||
"rhythm": "при запросе к представлению, без копирования данных"
|
||
}
|
||
],
|
||
"nodes": [
|
||
{
|
||
"id": "kafka_hits",
|
||
"name": "hits",
|
||
"layer": "kafka",
|
||
"order": 0,
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"what": "Топик Kafka с событиями кликстрима. Каждое сообщение содержит одно событие.",
|
||
"read": "Для просмотра загруженных сообщений используйте `stg.hits_raw_dist`. Таблица `stg.hits_raw_kafka` служит для приема из топика.",
|
||
"fill": [
|
||
"Генератор отправляет события без пауз в пакетном режиме или постепенно в живом режиме."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства"
|
||
},
|
||
{
|
||
"label": "Формат",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/formats/clickstream-event.md#user-content-описание-выгрузки-событие-кликстрима"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "kafka_orders",
|
||
"name": "orders",
|
||
"layer": "kafka",
|
||
"order": 1,
|
||
"streams": [
|
||
"orders"
|
||
],
|
||
"what": "Топик Kafka с дневными выгрузками заказов — слепками. Каждый слепок содержит состояние заказов за последние семь модельных дней.",
|
||
"read": "Для просмотра загруженных сообщений используйте `stg.orders_raw_dist`. Таблица `stg.orders_raw_kafka` служит для приема из топика.",
|
||
"fill": [
|
||
"После генерации событий дня D источник отправляет слепок заказов за предыдущий день D−1."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных"
|
||
},
|
||
{
|
||
"label": "Устройство слепка",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/snapshot.md#user-content-слепок-и-его-доставка"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "stg_hits",
|
||
"name": "stg.hits_raw_dist",
|
||
"layer": "stg",
|
||
"order": 0,
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"what": "Исходное сообщение события и сведения о его доставке из Kafka. Содержимое сохраняется целой строкой, без разбора на поля.",
|
||
"read": "Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.",
|
||
"parts": [
|
||
"`stg.hits_raw_kafka` — таблица для чтения сообщений топика `hits`.",
|
||
"`stg.hits_raw_rep` — реплицируемая таблица на шарде.",
|
||
"`stg.hits_raw_dist` — распределенная таблица для записи и чтения.",
|
||
"`stg.hits_raw_mv` — материализованное представление, которое записывает сообщения из Kafka в STG."
|
||
],
|
||
"fill": [
|
||
"`stg.hits_raw_mv` пишет непрерывно, по мере прихода сообщений.",
|
||
"Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства"
|
||
},
|
||
{
|
||
"label": "DDL таблиц",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql"
|
||
},
|
||
{
|
||
"label": "DDL материализованного представления",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "stg_orders",
|
||
"name": "stg.orders_raw_dist",
|
||
"layer": "stg",
|
||
"order": 1,
|
||
"streams": [
|
||
"orders"
|
||
],
|
||
"what": "Исходные строки выгрузки заказов, сведения о доставке и идентификатор загруженной партии `_load_id`.",
|
||
"read": "Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.",
|
||
"parts": [
|
||
"`stg.orders_raw_kafka` — таблица для чтения топика `orders` на первом узле.",
|
||
"`stg.orders_raw_rep` — реплицируемая таблица на шарде.",
|
||
"`stg.orders_raw_dist` — распределенная таблица для записи и чтения."
|
||
],
|
||
"fill": [
|
||
"Задача `pull_batch` дага `orders_ingest` загружает слепок раз в модельные сутки.",
|
||
"Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных"
|
||
},
|
||
{
|
||
"label": "DDL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "ods_event",
|
||
"name": "ods.event_v",
|
||
"layer": "ods",
|
||
"order": 0,
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"what": "События, разобранные на колонки с типами данных. Имена полей сохранены как в источнике.",
|
||
"read": "Читайте `ods.event_v`: представление использует `FINAL` и возвращает последнюю версию каждого события.",
|
||
"parts": [
|
||
"`ods.event_rep` — реплицируемая таблица версий события на шарде.",
|
||
"`ods.event_dist` — распределенная таблица записи и диагностики.",
|
||
"`ods.event_v` — представление актуального состояния с `FINAL`.",
|
||
"`ods.event_mv` — материализованное представление для разбора сообщений STG без ошибок."
|
||
],
|
||
"keys": [
|
||
"`WatchID` — тот же идентификатор, что `event_id` в DDS.",
|
||
"`ClientID` — тот же посетитель, что `client_id` в DDS.",
|
||
"`EventDate` — тот же день, что `event_date` в DDS."
|
||
],
|
||
"fill": [
|
||
"`ods.event_mv` разбирает строки непрерывно, по мере записи в STG.",
|
||
"`ReplacingMergeTree(_load_ts)` хранит версии, а `FINAL` выбирает актуальную."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-актуальные-события"
|
||
},
|
||
{
|
||
"label": "DDL таблиц",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql"
|
||
},
|
||
{
|
||
"label": "DDL представлений",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "ods_event_errors",
|
||
"name": "ods.event_errors_dist",
|
||
"layer": "ods",
|
||
"order": 1,
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"what": "Сообщения событий, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и сведения о доставке.",
|
||
"read": "Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.",
|
||
"parts": [
|
||
"`ods.event_errors_rep` — реплицируемая таблица ошибок на шарде.",
|
||
"`ods.event_errors_dist` — распределенная таблица чтения и записи.",
|
||
"`ods.event_errors_mv` — материализованное представление для отбора сообщений STG с ошибками."
|
||
],
|
||
"fill": [
|
||
"`ods.event_errors_mv` пишет непрерывно, по мере записи в STG.",
|
||
"Ошибки хранятся один месяц. Остальные события продолжают загружаться."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок"
|
||
},
|
||
{
|
||
"label": "DDL таблиц",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql"
|
||
},
|
||
{
|
||
"label": "DDL материализованного представления",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "ods_order",
|
||
"name": "ods.order_v",
|
||
"layer": "ods",
|
||
"order": 2,
|
||
"streams": [
|
||
"orders"
|
||
],
|
||
"what": "Последняя загруженная версия заказа с именами полей из источника. Состав заказа пока хранится как фрагмент JSON.",
|
||
"read": "Читайте `ods.order_v`: запрос представления с `FINAL` возвращает последнюю версию по `snapshot_date`.",
|
||
"parts": [
|
||
"`ods.order_rep` — реплицируемая таблица версий заказа на шарде.",
|
||
"`ods.order_dist` — распределенная таблица записи и диагностики.",
|
||
"`ods.order_v` — представление текущего состояния с `FINAL`."
|
||
],
|
||
"keys": [
|
||
"`order_id` — тот же заказ в `dds.order_v` и `dm.purchase_vs_orders_v`.",
|
||
"`user_id` — пользователь магазина, который переходит в заказ DDS."
|
||
],
|
||
"fill": [
|
||
"Задача `parse_batch` дага `orders_ingest` разбирает загруженный слепок раз в модельные сутки.",
|
||
"`ReplacingMergeTree(snapshot_date)` оставляет версию из последнего принятого слепка."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-версии-заказов"
|
||
},
|
||
{
|
||
"label": "DDL таблиц",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql"
|
||
},
|
||
{
|
||
"label": "DDL представления",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "ods_order_errors",
|
||
"name": "ods.order_errors_dist",
|
||
"layer": "ods",
|
||
"order": 3,
|
||
"streams": [
|
||
"orders"
|
||
],
|
||
"what": "Строки выгрузки заказов, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и `_load_id`.",
|
||
"read": "Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.",
|
||
"parts": [
|
||
"`ods.order_errors_rep` — реплицируемая таблица ошибок на шарде.",
|
||
"`ods.order_errors_dist` — распределенная таблица чтения и записи."
|
||
],
|
||
"fill": [
|
||
"Задача `parse_batch` дага `orders_ingest` сохраняет строки с ошибками при разборе той же выгрузки, из которой загружаются заказы.",
|
||
"Ошибки хранятся один месяц. Остальные заказы продолжают загружаться."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок"
|
||
},
|
||
{
|
||
"label": "DDL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "dds_event",
|
||
"name": "dds.event_v",
|
||
"layer": "dds",
|
||
"order": 0,
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"what": "События с названиями полей, принятыми в DDS. Например, `WatchID` переименован в `event_id`, а числовой код устройства заменен названием категории.",
|
||
"read": "Читайте `dds.event_v`: представление возвращает явно перечисленные колонки DDS.",
|
||
"parts": [
|
||
"`dds.event_v` — только представление над `ods.event_v`; своей таблицы в DDS нет."
|
||
],
|
||
"keys": [
|
||
"`event_id` — тот же идентификатор, что `WatchID` в ODS.",
|
||
"`client_id` — тот же посетитель, что `ClientID` в ODS; соединяет событие с сессией и картой идентичностей.",
|
||
"`event_date` — тот же день, что `EventDate` в ODS.",
|
||
"`purchase_id[1]` — тот же заказ, что `order_id` в DDS и DM."
|
||
],
|
||
"fill": [
|
||
"При запросе к `dds.event_v` данные читаются из актуальных событий ODS. Отдельная загрузка в DDS не нужна."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/event.md#user-content-форма"
|
||
},
|
||
{
|
||
"label": "DDL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "dds_session",
|
||
"name": "dds.session_v",
|
||
"layer": "dds",
|
||
"order": 1,
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"what": "Сессии, рассчитанные дагом `dds_transform` по событиям посетителя. Новая сессия начинается после паузы дольше 30 минут или при смене модельной даты.",
|
||
"read": "Читайте `dds.session_v`: здесь есть начало, конец, длительность сессии, показатели событий и служебные поля загрузки.",
|
||
"parts": [
|
||
"`dds.session_rep` — реплицируемая таблица сессий на шарде.",
|
||
"`dds.session_dist` — распределенная таблица загрузки и диагностики.",
|
||
"`dds.session_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.",
|
||
"`dds.session_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.",
|
||
"`dds.session_v` — представление для чтения готовых данных."
|
||
],
|
||
"keys": [
|
||
"`client_id` — тот же посетитель, что в событии DDS и карте идентичностей.",
|
||
"`session_date` — день, который в `dm.daily_traffic_v` называется `report_date`."
|
||
],
|
||
"fill": [
|
||
"При каждом запуске `dds_transform` выбирает дни от первого пропущенного в DDS до последнего дня с завершенной генерацией.",
|
||
"Задача `session.rebuild` рассчитывает данные в промежуточной таблице (доноре). `session.replace` заменяет ими дневные партиции основной таблицы."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/session.md#user-content-движок-и-наполнение"
|
||
},
|
||
{
|
||
"label": "DDL таблиц",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/45-dds-tables.sql"
|
||
},
|
||
{
|
||
"label": "DDL представления",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "dds_order",
|
||
"name": "dds.order_v",
|
||
"layer": "dds",
|
||
"order": 2,
|
||
"streams": [
|
||
"orders"
|
||
],
|
||
"what": "Последнее известное состояние заказа, подготовленное для анализа. Артикулы, количества и цены товаров хранятся в массивах; элементы с одинаковым индексом относятся к одной позиции заказа.",
|
||
"read": "Читайте `dds.order_v`, чтобы получить готовые заказы из основной таблицы.",
|
||
"parts": [
|
||
"`dds.order_rep` — реплицируемая таблица заказов на шарде.",
|
||
"`dds.order_dist` — распределенная таблица загрузки и диагностики.",
|
||
"`dds.order_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.",
|
||
"`dds.order_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.",
|
||
"`dds.order_v` — представление для чтения готовых данных."
|
||
],
|
||
"keys": [
|
||
"`order_id` — тот же заказ, что в ODS, событии покупки и витрине сверки.",
|
||
"`user_id` — пользователь магазина, который входит в карту идентичностей.",
|
||
"`item_sku` — тот же артикул, что `sku` в `dic.products`.",
|
||
"`order_date` — день, который в витринах называется `report_date` или `order_day`."
|
||
],
|
||
"fill": [
|
||
"При каждом запуске `dds_transform` пересчитывает дни заказов из последнего слепка и заполняет пропуски в DDS.",
|
||
"Задача `order.rebuild` рассчитывает данные в промежуточной таблице (доноре). `order.replace` заменяет ими дневные партиции основной таблицы."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/order.md#user-content-движок-и-наполнение"
|
||
},
|
||
{
|
||
"label": "DDL таблиц",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/45-dds-tables.sql"
|
||
},
|
||
{
|
||
"label": "DDL представления",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "dds_identity",
|
||
"name": "dds.identity_map_v",
|
||
"layer": "dds",
|
||
"order": 3,
|
||
"streams": [
|
||
"events",
|
||
"orders"
|
||
],
|
||
"what": "Связи между посетителем сайта и пользователем магазина. Они появляются, когда событие покупки сопоставлено с заказом по его номеру.",
|
||
"read": "Читайте `dds.identity_map_v`: представление с `FINAL` возвращает последнюю версию каждой пары посетителя и пользователя.",
|
||
"parts": [
|
||
"`dds.identity_map_rep` — реплицируемая таблица версий пар на шарде.",
|
||
"`dds.identity_map_dist` — распределенная таблица записи.",
|
||
"`dds.identity_map_v` — представление последних версий строк с `FINAL`."
|
||
],
|
||
"keys": [
|
||
"`client_id` — идентификатор посетителя из cookie браузера, как в событии и сессии DDS.",
|
||
"`user_id` — пользователь магазина из заказа DDS."
|
||
],
|
||
"fill": [
|
||
"Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов.",
|
||
"При каждом запуске записывается новая версия набора связей."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка"
|
||
},
|
||
{
|
||
"label": "DDL таблиц",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/45-dds-tables.sql"
|
||
},
|
||
{
|
||
"label": "DDL представления",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "dm_daily_traffic",
|
||
"name": "dm.daily_traffic_v",
|
||
"layer": "dm",
|
||
"order": 0,
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"what": "Число посетителей и известных пользователей за каждый день. Известными считаются пользователи магазина, связанные с посетителями сайта через карту идентичностей.",
|
||
"read": "Читайте `dm.daily_traffic_v`: представление с `FINAL` возвращает последний результат расчета за каждый день.",
|
||
"parts": [
|
||
"`dm.daily_traffic_rep` — реплицируемая таблица версий счетчиков.",
|
||
"`dm.daily_traffic_dist` — распределенная таблица записи.",
|
||
"`dm.daily_traffic_v` — представление последних версий строк с `FINAL`."
|
||
],
|
||
"keys": [
|
||
"`report_date` — тот же день, что `session_date` в сессии DDS."
|
||
],
|
||
"fill": [
|
||
"Задача `daily_traffic` дага `dm_transform` при каждом запуске пересчитывает всю историю и записывает новую версию."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v"
|
||
},
|
||
{
|
||
"label": "DDL таблиц",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql"
|
||
},
|
||
{
|
||
"label": "DDL представления",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "dm_revenue",
|
||
"name": "dm.revenue_daily_v",
|
||
"layer": "dm",
|
||
"order": 1,
|
||
"streams": [
|
||
"orders"
|
||
],
|
||
"what": "Выручка, число заказов и единиц товара за день по категориям. В расчет входят оплаченные заказы.",
|
||
"read": "Читайте `dm.revenue_daily_v`, чтобы получить готовые показатели по дням и категориям товаров.",
|
||
"parts": [
|
||
"`dm.revenue_daily_rep` — реплицируемая таблица витрины на шарде.",
|
||
"`dm.revenue_daily_dist` — распределенная таблица загрузки.",
|
||
"`dm.revenue_daily_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.",
|
||
"`dm.revenue_daily_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.",
|
||
"`dm.revenue_daily_v` — представление для чтения готовых данных."
|
||
],
|
||
"keys": [
|
||
"`report_date` — тот же день, что `order_date` в заказе DDS.",
|
||
"`product_category` — категория, найденная по `item_sku` заказа и ключу `sku` словаря."
|
||
],
|
||
"fill": [
|
||
"При каждом запуске `dm_transform` пересчитывает дни заказов из последнего слепка и заполняет пропущенные дни витрины.",
|
||
"Задача `revenue_daily.rebuild` рассчитывает данные в промежуточной таблице (доноре). `revenue_daily.replace` заменяет ими дневные партиции витрины."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v"
|
||
},
|
||
{
|
||
"label": "DDL таблиц",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql"
|
||
},
|
||
{
|
||
"label": "DDL представления",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "dm_purchase",
|
||
"name": "dm.purchase_vs_orders_v",
|
||
"layer": "dm",
|
||
"order": 2,
|
||
"streams": [
|
||
"events",
|
||
"orders"
|
||
],
|
||
"what": "Результат сверки покупок из кликстрима с заказами магазина. Для каждого номера заказа указано, совпали ли данные двух источников и какое расхождение найдено.",
|
||
"read": "Читайте `dm.purchase_vs_orders_v`: если покупка или заказ отсутствует, поля этой стороны содержат `NULL`.",
|
||
"parts": [
|
||
"`dm.purchase_vs_orders_rep` — реплицируемая таблица сверки на шарде.",
|
||
"`dm.purchase_vs_orders_dist` — распределенная таблица загрузки.",
|
||
"`dm.purchase_vs_orders_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.",
|
||
"`dm.purchase_vs_orders_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.",
|
||
"`dm.purchase_vs_orders_v` — представление для чтения готовых данных."
|
||
],
|
||
"keys": [
|
||
"`order_id` — тот же заказ, что `purchase_id[1]` в событии и `order_id` в заказе DDS.",
|
||
"`order_day` — дата заказа; если заказа еще нет, берется дата события покупки."
|
||
],
|
||
"fill": [
|
||
"При каждом запуске `dm_transform` пересчитывает дни последнего слепка, добавляет новые дни покупок, заполняет пропуски и повторяет сверку дней с покупками без заказа.",
|
||
"Задача `purchase_vs_orders.rebuild` рассчитывает данные в промежуточной таблице (доноре). `purchase_vs_orders.replace` заменяет ими дневные партиции витрины."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v"
|
||
},
|
||
{
|
||
"label": "DDL таблиц",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql"
|
||
},
|
||
{
|
||
"label": "DDL представления",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "dm_dq",
|
||
"name": "dm.dq_summary_v",
|
||
"layer": "dm",
|
||
"order": 3,
|
||
"streams": [
|
||
"events",
|
||
"orders"
|
||
],
|
||
"what": "Последние результаты пяти проверок качества данных за каждый день. Историю запусков можно посмотреть в Airflow.",
|
||
"read": "Читайте `dm.dq_summary_v`, чтобы увидеть результат каждой проверки за день.",
|
||
"parts": [
|
||
"`dm.dq_summary_rep` — реплицируемая таблица опубликованной сводки.",
|
||
"`dm.dq_summary_dist` — распределенная таблица чтения и записи.",
|
||
"`dm.dq_summary_stage_rep` — промежуточная таблица для результатов всех проверок за день.",
|
||
"`dm.dq_summary_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.",
|
||
"`dm.dq_summary_v` — представление для чтения готовых данных."
|
||
],
|
||
"fill": [
|
||
"Даг `dq_check` сначала рассчитывает результаты всех пяти проверок в промежуточной таблице, затем заменяет ими дневные партиции сводки.",
|
||
"`sessions_vs_reference` — `sql/dq/sessions_vs_reference.sql`.",
|
||
"`classes_vs_inventory` — `sql/dq/classes_vs_inventory.sql` и эталонные числа из паспорта мира `data/world-inventory.json`.",
|
||
"`revenue_daily_vs_dds` — `sql/dq/revenue_daily_vs_dds.sql`.",
|
||
"`purchase_vs_orders_vs_dds` — `sql/dq/purchase_vs_orders_vs_dds.sql`.",
|
||
"`daily_traffic_vs_dds` — `sql/dq/daily_traffic_vs_dds.sql`."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdq_summary"
|
||
},
|
||
{
|
||
"label": "DDL таблиц",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql"
|
||
},
|
||
{
|
||
"label": "DDL представления",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "dic_products",
|
||
"name": "dic.products",
|
||
"layer": "dic",
|
||
"order": 0,
|
||
"streams": [
|
||
"orders"
|
||
],
|
||
"what": "Словарь товаров. По артикулу можно получить название, бренд, категорию и цену. Эти данные доступны для расчетов в разных слоях.",
|
||
"read": "Читайте `dic.products`: к словарю обращаются напрямую, суффикса `_v` у него нет.",
|
||
"parts": [
|
||
"`dic.products_file` — таблица, которая читает каталог товаров из CSV-файла репозитория.",
|
||
"`dic.products` — словарь в памяти, загруженный из этой таблицы."
|
||
],
|
||
"keys": [
|
||
"`sku` — тот же артикул, что `item_sku` в позиции заказа DDS."
|
||
],
|
||
"fill": [
|
||
"ClickHouse обновляет словарь из `dic.products_file` каждые 60–90 секунд согласно настройке `LIFETIME`."
|
||
],
|
||
"links": [
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-справочники"
|
||
},
|
||
{
|
||
"label": "DDL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/05-dic-catalog.sql"
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"edges": [
|
||
{
|
||
"id": "hits_to_stg",
|
||
"from": "kafka_hits",
|
||
"to": "stg_hits",
|
||
"mechanism": "materialized_view",
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"rhythm": "непрерывно, по мере прихода сообщений",
|
||
"actor": "Таблица `stg.hits_raw_kafka` и материализованное представление `stg.hits_raw_mv`.",
|
||
"join": "Каждое сообщение Kafka записывается целой строкой, без разбора на поля.",
|
||
"links": [
|
||
{
|
||
"label": "SQL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "stg_to_event",
|
||
"from": "stg_hits",
|
||
"to": "ods_event",
|
||
"mechanism": "materialized_view",
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"rhythm": "непрерывно, по мере прихода сообщений",
|
||
"actor": "Материализованное представление `ods.event_mv` разбирает сообщения без ошибок.",
|
||
"join": "Материализованное представление разбирает сообщение из STG на колонки с нужными типами данных.",
|
||
"links": [
|
||
{
|
||
"label": "SQL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "stg_to_event_errors",
|
||
"from": "stg_hits",
|
||
"to": "ods_event_errors",
|
||
"mechanism": "materialized_view",
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"rhythm": "непрерывно, по мере прихода сообщений",
|
||
"actor": "Материализованное представление `ods.event_errors_mv` отбирает сообщения с ошибками.",
|
||
"join": "Сообщение, которое не удалось разобрать по правилам ODS, сохраняется целиком вместе с классом ошибки.",
|
||
"links": [
|
||
{
|
||
"label": "SQL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "orders_to_stg",
|
||
"from": "kafka_orders",
|
||
"to": "stg_orders",
|
||
"mechanism": "airflow_dag",
|
||
"streams": [
|
||
"orders"
|
||
],
|
||
"rhythm": "одной выгрузкой, раз в модельные сутки",
|
||
"actor": "Задача `pull_batch` дага `orders_ingest` читает `stg.orders_raw_kafka`.",
|
||
"join": "Строки выгрузки сохраняются целиком. Общий `_load_id` позволяет найти все строки одной загрузки.",
|
||
"links": [
|
||
{
|
||
"label": "SQL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/stg/orders_raw_load.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-одно-чтение-kafka"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "stg_to_order",
|
||
"from": "stg_orders",
|
||
"to": "ods_order",
|
||
"mechanism": "airflow_dag",
|
||
"streams": [
|
||
"orders"
|
||
],
|
||
"rhythm": "одной выгрузкой, раз в модельные сутки",
|
||
"actor": "Задача `parse_batch` дага `orders_ingest` записывает версии заказов без ошибок разбора.",
|
||
"join": "Строка разбирается на колонки заказа. Версия определяется датой слепка `snapshot_date`.",
|
||
"links": [
|
||
{
|
||
"label": "SQL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-граница-строгого-при%D1%91ма"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "stg_to_order_errors",
|
||
"from": "stg_orders",
|
||
"to": "ods_order_errors",
|
||
"mechanism": "airflow_dag",
|
||
"streams": [
|
||
"orders"
|
||
],
|
||
"rhythm": "одной выгрузкой, раз в модельные сутки",
|
||
"actor": "Задача `parse_batch` дага `orders_ingest` записывает строки с ошибками из загруженной выгрузки.",
|
||
"join": "Строка с ошибкой сохраняется целиком вместе с классом ошибки и идентификатором загрузки.",
|
||
"links": [
|
||
{
|
||
"label": "SQL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-граница-строгого-при%D1%91ма"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "ods_to_dds_event",
|
||
"from": "ods_event",
|
||
"to": "dds_event",
|
||
"mechanism": "view",
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"rhythm": "при запросе к представлению, без копирования данных",
|
||
"actor": "Представление `dds.event_v` читает данные из `ods.event_v`.",
|
||
"join": "Колонки события переименовываются по правилам DDS. Данные не копируются в отдельную таблицу.",
|
||
"links": [
|
||
{
|
||
"label": "SQL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/event.md#user-content-форма"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "event_to_session",
|
||
"from": "dds_event",
|
||
"to": "dds_session",
|
||
"mechanism": "airflow_dag",
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"rhythm": "при каждом запуске `dds_transform`",
|
||
"actor": "Задачи `session.rebuild` и `session.replace` дага `dds_transform`.",
|
||
"join": "События одного `client_id` за день `event_date` упорядочиваются по времени. Пауза дольше 30 минут начинает новую сессию. Ее `session_id` рассчитывается как `cityHash64(client_id, min(event_time))`.",
|
||
"links": [
|
||
{
|
||
"label": "SQL сборки",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/session_rebuild.sql"
|
||
},
|
||
{
|
||
"label": "SQL замены",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/session_replace.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/session.md#user-content-движок-и-наполнение"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "order_to_dds",
|
||
"from": "ods_order",
|
||
"to": "dds_order",
|
||
"mechanism": "airflow_dag",
|
||
"streams": [
|
||
"orders"
|
||
],
|
||
"rhythm": "при каждом запуске `dds_transform`",
|
||
"actor": "Задачи `order.rebuild` и `order.replace` дага `dds_transform`.",
|
||
"join": "Поля заказа приводятся к типам DDS, а позиции из JSON разбираются в массивы. Дата `order_date` рассчитывается из `created_at`.",
|
||
"links": [
|
||
{
|
||
"label": "SQL сборки",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/order_rebuild.sql"
|
||
},
|
||
{
|
||
"label": "SQL замены",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/order_replace.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/order.md#user-content-движок-и-наполнение"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "event_to_identity",
|
||
"from": "dds_event",
|
||
"to": "dds_identity",
|
||
"mechanism": "airflow_dag",
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"rhythm": "при каждом запуске `dds_transform`",
|
||
"actor": "Задача `identity_map` дага `dds_transform` соединяет покупки с заказами.",
|
||
"join": "Событие покупки соединяется с заказом по `purchase_id[1] = order_id`. В результате связываются посетитель сайта и пользователь магазина.",
|
||
"links": [
|
||
{
|
||
"label": "SQL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "order_to_identity",
|
||
"from": "dds_order",
|
||
"to": "dds_identity",
|
||
"mechanism": "airflow_dag",
|
||
"streams": [
|
||
"orders"
|
||
],
|
||
"rhythm": "при каждом запуске `dds_transform`",
|
||
"actor": "Задача `identity_map` дага `dds_transform` соединяет заказы с покупками.",
|
||
"join": "Заказ соединяется с событием покупки по `order_id = purchase_id[1]`. Из заказа берется `user_id` пользователя магазина.",
|
||
"links": [
|
||
{
|
||
"label": "SQL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "session_to_traffic",
|
||
"from": "dds_session",
|
||
"to": "dm_daily_traffic",
|
||
"mechanism": "airflow_dag",
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"rhythm": "при каждом запуске `dm_transform`",
|
||
"actor": "Задача `daily_traffic` дага `dm_transform` считает посетителей по сессиям.",
|
||
"join": "К сессиям присоединяется карта идентичностей по `client_id`, затем данные группируются по `session_date`. Эта дата записывается в `report_date`.",
|
||
"links": [
|
||
{
|
||
"label": "SQL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "identity_to_traffic",
|
||
"from": "dds_identity",
|
||
"to": "dm_daily_traffic",
|
||
"mechanism": "airflow_dag",
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"rhythm": "при каждом запуске `dm_transform`",
|
||
"actor": "Задача `daily_traffic` дага `dm_transform` считает известных пользователей по `client_id`.",
|
||
"join": "`GLOBAL LEFT JOIN` присоединяет карту идентичностей к сессиям по `client_id`. Сессии без известного пользователя тоже участвуют в расчете посетителей.",
|
||
"links": [
|
||
{
|
||
"label": "SQL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "order_to_revenue",
|
||
"from": "dds_order",
|
||
"to": "dm_revenue",
|
||
"mechanism": "airflow_dag",
|
||
"streams": [
|
||
"orders"
|
||
],
|
||
"rhythm": "при каждом запуске `dm_transform`",
|
||
"actor": "Задачи `revenue_daily.rebuild` и `revenue_daily.replace` дага `dm_transform`.",
|
||
"join": "`ARRAY JOIN` разворачивает массивы `item_sku`, `item_quantity`, `item_price` в строки позиций заказа. Выручка считается по оплаченным заказам; `order_date` записывается в `report_date`.",
|
||
"links": [
|
||
{
|
||
"label": "SQL сборки",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql"
|
||
},
|
||
{
|
||
"label": "SQL замены",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_replace.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "products_to_revenue",
|
||
"from": "dic_products",
|
||
"to": "dm_revenue",
|
||
"mechanism": "airflow_dag",
|
||
"streams": [
|
||
"orders"
|
||
],
|
||
"rhythm": "при каждом запуске `dm_transform`",
|
||
"actor": "Задача `revenue_daily.rebuild` дага `dm_transform`.",
|
||
"join": "Функция `dictGet` получает категорию товара по артикулу `item_sku` из заказа. В словаре этому артикулу соответствует ключ `sku`.",
|
||
"links": [
|
||
{
|
||
"label": "SQL",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "event_to_purchase",
|
||
"from": "dds_event",
|
||
"to": "dm_purchase",
|
||
"mechanism": "airflow_dag",
|
||
"streams": [
|
||
"events"
|
||
],
|
||
"rhythm": "при каждом запуске `dm_transform`",
|
||
"actor": "Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают покупки.",
|
||
"join": "События покупки группируются по номеру `purchase_id[1]`, который получает имя `purchase_order_id`. Дата `event_date` записывается в `purchase_day`. Затем `GLOBAL FULL OUTER JOIN` сопоставляет покупки с заказами по `purchase_order_id = backend_order_id`.",
|
||
"links": [
|
||
{
|
||
"label": "SQL сборки",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql"
|
||
},
|
||
{
|
||
"label": "SQL замены",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_replace.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"id": "order_to_purchase",
|
||
"from": "dds_order",
|
||
"to": "dm_purchase",
|
||
"mechanism": "airflow_dag",
|
||
"streams": [
|
||
"orders"
|
||
],
|
||
"rhythm": "при каждом запуске `dm_transform`",
|
||
"actor": "Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают заказы.",
|
||
"join": "`order_id` заказа получает имя `backend_order_id`, а `purchase_id[1]` покупки — `purchase_order_id`. `GLOBAL FULL OUTER JOIN` соединяет их по `purchase_order_id = backend_order_id`. Покупки без заказа и заказы без покупки сохраняются; поля отсутствующей стороны содержат `NULL`.",
|
||
"links": [
|
||
{
|
||
"label": "SQL сборки",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql"
|
||
},
|
||
{
|
||
"label": "SQL замены",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_replace.sql"
|
||
},
|
||
{
|
||
"label": "Справочник",
|
||
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v"
|
||
}
|
||
]
|
||
}
|
||
]
|
||
};
|
||
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);
|
||
addSection("Что происходит с данными", edge.join);
|
||
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>
|