Files
clickstream-data-platform/docs/handbook/architecture-map.html
T
ddadmin f3373f1e8f docs(map): упрощен язык интерактивной карты хранилища
Зачем: помочь учащемуся понять назначение объектов и обработку данных по стрелкам карты.

Что: вычитаны карточки и подписи, пояснены термины, удалены повторы. Описания обработки уточнены по SQL, HTML пересобран.

Проверка: сохранены 17 объектов и 17 стрелок, проверены 85 ссылок. В Chromium проверены карточки, фильтры, подсветка, Escape и отображение при ширине 1920 и 1280 пикселей. make lint и git diff --cached --check прошли.
2026-09-13 13:20:58 -04:00

1518 lines
76 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- Файл собран автоматически. Правьте 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>