prototype
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -61,3 +61,4 @@ web/src/padelnomics/static/css/output.css
|
||||
|
||||
# Generated report PDFs (built locally via make report-pdf, not committed)
|
||||
data/content/reports/_build/
|
||||
_serving_meta.json
|
||||
|
||||
860
scratch/lineage-ux-prototype.html
Normal file
860
scratch/lineage-ux-prototype.html
Normal file
@@ -0,0 +1,860 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Lineage UX Prototype</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--green-bg: #F0FDF4; --green-border: #BBF7D0; --green-accent: #16A34A;
|
||||
--green-fill: #DCFCE7; --green-text: #14532D;
|
||||
--blue-bg: #EFF6FF; --blue-border: #BFDBFE; --blue-accent: #1D4ED8;
|
||||
--blue-fill: #DBEAFE; --blue-text: #1E3A8A;
|
||||
--amber-bg: #FFFBEB; --amber-border: #FDE68A; --amber-accent: #D97706;
|
||||
--amber-fill: #FEF3C7; --amber-text: #78350F;
|
||||
--slate-50: #F8FAFC; --slate-100: #F1F5F9; --slate-200: #E2E8F0;
|
||||
--slate-400: #94A3B8; --slate-500: #64748B; --slate-700: #334155;
|
||||
--slate-800: #1E293B; --slate-900: #0F172A;
|
||||
--panel-w: 340px;
|
||||
--font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'DM Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--slate-50);
|
||||
color: var(--slate-800);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Page shell ── */
|
||||
.page-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid var(--slate-200);
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.page-header h1 { font-size: 0.875rem; font-weight: 600; color: var(--slate-800); }
|
||||
.badge {
|
||||
font-size: 0.6875rem; font-weight: 500; padding: 2px 7px;
|
||||
border-radius: 99px; background: var(--slate-100); color: var(--slate-500);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.75rem; color: var(--slate-400); margin-left: auto;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── DAG canvas ── */
|
||||
.canvas-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1.5rem;
|
||||
transition: margin-right 0.22s cubic-bezier(0.4,0,0.2,1);
|
||||
}
|
||||
.canvas-wrap.panel-open { margin-right: var(--panel-w); }
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid var(--slate-200);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-header {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--slate-100);
|
||||
color: var(--slate-700);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.svg-wrap { padding: 1rem 0.75rem 0.75rem; overflow-x: auto; }
|
||||
|
||||
/* ── Lineage SVG node/edge states ── */
|
||||
.lineage-node { cursor: pointer; }
|
||||
.lineage-node rect:first-child { transition: filter 0.1s; }
|
||||
.lineage-node:hover rect:first-child { filter: brightness(0.93); }
|
||||
.lineage-node.selected rect:first-child { filter: brightness(0.88); }
|
||||
.lineage-edge { transition: stroke 0.12s, stroke-width 0.12s, opacity 0.12s; }
|
||||
.lineage-edge.hi { stroke: #1D4ED8 !important; stroke-width: 2 !important; opacity: 1 !important; }
|
||||
.lineage-edge.dim { opacity: 0.1; }
|
||||
|
||||
/* ── Hover tooltip ── */
|
||||
#tooltip {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.12));
|
||||
}
|
||||
#tooltip.visible { opacity: 1; }
|
||||
.tt-box {
|
||||
background: var(--slate-900);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
min-width: 200px;
|
||||
max-width: 260px;
|
||||
}
|
||||
.tt-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 7px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.tt-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tt-layer {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tt-layer.staging { background: rgba(22,163,74,0.25); color: #86EFAC; }
|
||||
.tt-layer.foundation { background: rgba(29,78,216,0.3); color: #93C5FD; }
|
||||
.tt-layer.serving { background: rgba(217,119,6,0.25); color: #FCD34D; }
|
||||
|
||||
.tt-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 2px 0;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.tt-col { font-family: var(--font-mono); color: #CBD5E1; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tt-type { font-family: var(--font-mono); color: #64748B; font-size: 0.625rem; flex-shrink: 0; }
|
||||
.tt-more { font-size: 0.625rem; color: #64748B; margin-top: 5px; padding-top: 5px; border-top: 1px solid rgba(255,255,255,0.06); }
|
||||
|
||||
/* ── Detail panel ── */
|
||||
#detail-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: var(--panel-w);
|
||||
background: white;
|
||||
border-left: 1px solid var(--slate-200);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.22s cubic-bezier(0.4,0,0.2,1);
|
||||
z-index: 50;
|
||||
overflow: hidden;
|
||||
}
|
||||
#detail-panel.open { transform: translateX(0); }
|
||||
|
||||
.panel-top {
|
||||
padding: 0.875rem 1rem;
|
||||
border-bottom: 1px solid var(--slate-100);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.panel-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.panel-model-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--slate-800);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.panel-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--slate-400);
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
font-size: 1rem;
|
||||
transition: color 0.1s, background 0.1s;
|
||||
}
|
||||
.panel-close:hover { color: var(--slate-700); background: var(--slate-100); }
|
||||
|
||||
.panel-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--slate-500);
|
||||
}
|
||||
.meta-chip {
|
||||
padding: 2px 7px;
|
||||
border-radius: 99px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.meta-chip.staging { background: var(--green-fill); color: var(--green-text); border: 1px solid var(--green-border); }
|
||||
.meta-chip.foundation { background: var(--blue-fill); color: var(--blue-text); border: 1px solid var(--blue-border); }
|
||||
.meta-chip.serving { background: var(--amber-fill); color: var(--amber-text); border: 1px solid var(--amber-border); }
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
border-bottom: 1px solid var(--slate-100);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.panel-section:last-child { border-bottom: none; }
|
||||
.section-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--slate-400);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Schema table */
|
||||
.schema-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.schema-table th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--slate-500);
|
||||
padding: 0 0 0.375rem;
|
||||
font-size: 0.625rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.schema-table td {
|
||||
padding: 3px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.schema-table tr + tr td { border-top: 1px solid var(--slate-100); }
|
||||
.col-name { font-family: var(--font-mono); color: var(--slate-800); font-weight: 500; }
|
||||
.col-type { font-family: var(--font-mono); color: var(--slate-400); font-size: 0.625rem; }
|
||||
.col-null { font-size: 0.5625rem; color: var(--slate-300); text-align: right; }
|
||||
.col-null.yes { color: var(--amber-accent); }
|
||||
|
||||
/* Row count */
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.stat-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--slate-800);
|
||||
}
|
||||
.stat-unit { font-size: 0.6875rem; color: var(--slate-500); }
|
||||
|
||||
/* Dep lists */
|
||||
.dep-list { display: flex; flex-direction: column; gap: 3px; }
|
||||
.dep-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 7px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--slate-700);
|
||||
}
|
||||
.dep-item:hover { background: var(--slate-50); }
|
||||
.dep-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.dep-dot.staging { background: var(--green-accent); }
|
||||
.dep-dot.foundation { background: var(--blue-accent); }
|
||||
.dep-dot.serving { background: var(--amber-accent); }
|
||||
|
||||
.empty-state { color: var(--slate-400); font-size: 0.6875rem; font-style: italic; }
|
||||
|
||||
/* ── "Click to explore" annotation ── */
|
||||
.interaction-hint {
|
||||
font-size: 0.6875rem; color: var(--slate-400);
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.kbd {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: var(--slate-100); border: 1px solid var(--slate-200);
|
||||
border-radius: 3px; padding: 1px 5px;
|
||||
font-family: var(--font-mono); font-size: 0.5625rem;
|
||||
color: var(--slate-600); line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Data Lineage</h1>
|
||||
<span class="badge">26 models</span>
|
||||
<span class="badge">staging → foundation → serving</span>
|
||||
<span class="hint interaction-hint">
|
||||
hover to preview schema · click to inspect
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="workspace">
|
||||
<div class="canvas-wrap" id="canvas-wrap">
|
||||
<div class="card">
|
||||
<div class="svg-wrap" id="svg-wrap">
|
||||
<!-- SVG injected by JS below -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail panel -->
|
||||
<div id="detail-panel">
|
||||
<div class="panel-top">
|
||||
<div class="panel-title-row">
|
||||
<span class="panel-model-name" id="panel-model-name">—</span>
|
||||
<button class="panel-close" id="panel-close" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="panel-meta">
|
||||
<span class="meta-chip" id="panel-layer-chip">—</span>
|
||||
<span id="panel-materialization">view</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body" id="panel-body">
|
||||
<!-- injected by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating tooltip -->
|
||||
<div id="tooltip">
|
||||
<div class="tt-box" id="tt-box"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Mock data (would come from DuckDB DESCRIBE in production) ──────────────
|
||||
const SCHEMA = {
|
||||
stg_padel_courts: {
|
||||
layer: 'staging', materialization: 'view',
|
||||
rows: null,
|
||||
columns: [
|
||||
{ name: 'court_id', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'name', type: 'VARCHAR', nullable: true },
|
||||
{ name: 'lat', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'lon', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'country_code', type: 'VARCHAR', nullable: true },
|
||||
{ name: 'source_file', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'extracted_at', type: 'TIMESTAMP', nullable: false },
|
||||
],
|
||||
},
|
||||
stg_playtomic_venues: {
|
||||
layer: 'staging', materialization: 'view', rows: null,
|
||||
columns: [
|
||||
{ name: 'tenant_id', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'name', type: 'VARCHAR', nullable: true },
|
||||
{ name: 'city', type: 'VARCHAR', nullable: true },
|
||||
{ name: 'country_code', type: 'VARCHAR', nullable: true },
|
||||
{ name: 'lat', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'lon', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'sport_ids', type: 'VARCHAR[]', nullable: true },
|
||||
],
|
||||
},
|
||||
stg_playtomic_resources: {
|
||||
layer: 'staging', materialization: 'view', rows: null,
|
||||
columns: [
|
||||
{ name: 'resource_id', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'tenant_id', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'name', type: 'VARCHAR', nullable: true },
|
||||
{ name: 'sport_id', type: 'VARCHAR', nullable: true },
|
||||
],
|
||||
},
|
||||
stg_playtomic_availability: {
|
||||
layer: 'staging', materialization: 'view', rows: null,
|
||||
columns: [
|
||||
{ name: 'slot_id', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'tenant_id', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'resource_id', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'start_at', type: 'TIMESTAMP', nullable: false },
|
||||
{ name: 'duration_min', type: 'INTEGER', nullable: false },
|
||||
{ name: 'price_cents', type: 'INTEGER', nullable: true },
|
||||
{ name: 'currency', type: 'VARCHAR', nullable: true },
|
||||
],
|
||||
},
|
||||
stg_population: {
|
||||
layer: 'staging', materialization: 'view', rows: null,
|
||||
columns: [
|
||||
{ name: 'city_code', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'city_name', type: 'VARCHAR', nullable: true },
|
||||
{ name: 'population', type: 'INTEGER', nullable: true },
|
||||
{ name: 'year', type: 'INTEGER', nullable: false },
|
||||
],
|
||||
},
|
||||
dim_venues: {
|
||||
layer: 'foundation', materialization: 'table', rows: 4821,
|
||||
columns: [
|
||||
{ name: 'venue_hk', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'tenant_id', type: 'VARCHAR', nullable: true },
|
||||
{ name: 'overpass_id', type: 'VARCHAR', nullable: true },
|
||||
{ name: 'name', type: 'VARCHAR', nullable: true },
|
||||
{ name: 'country_code', type: 'VARCHAR', nullable: true },
|
||||
{ name: 'city', type: 'VARCHAR', nullable: true },
|
||||
{ name: 'lat', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'lon', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'court_count', type: 'INTEGER', nullable: true },
|
||||
{ name: 'has_playtomic', type: 'BOOLEAN', nullable: false },
|
||||
{ name: 'loaded_at', type: 'TIMESTAMP', nullable: false },
|
||||
],
|
||||
},
|
||||
dim_cities: {
|
||||
layer: 'foundation', materialization: 'table', rows: 1203,
|
||||
columns: [
|
||||
{ name: 'city_hk', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'city_name', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'country_code', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'population', type: 'INTEGER', nullable: true },
|
||||
{ name: 'nuts2_code', type: 'VARCHAR', nullable: true },
|
||||
{ name: 'income_eur', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'venue_count', type: 'INTEGER', nullable: false },
|
||||
{ name: 'lat', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'lon', type: 'DOUBLE', nullable: true },
|
||||
],
|
||||
},
|
||||
dim_venue_capacity: {
|
||||
layer: 'foundation', materialization: 'table', rows: 4812,
|
||||
columns: [
|
||||
{ name: 'tenant_id', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'court_count', type: 'INTEGER', nullable: false },
|
||||
{ name: 'open_hours_wday',type: 'DOUBLE', nullable: true },
|
||||
{ name: 'open_hours_wend',type: 'DOUBLE', nullable: true },
|
||||
],
|
||||
},
|
||||
fct_daily_availability: {
|
||||
layer: 'foundation', materialization: 'table', rows: 382104,
|
||||
columns: [
|
||||
{ name: 'date', type: 'DATE', nullable: false },
|
||||
{ name: 'tenant_id', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'resource_id', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'slots_total', type: 'INTEGER', nullable: false },
|
||||
{ name: 'slots_booked', type: 'INTEGER', nullable: false },
|
||||
{ name: 'occupancy_rate', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'avg_price_eur', type: 'DOUBLE', nullable: true },
|
||||
],
|
||||
},
|
||||
venue_pricing_benchmarks: {
|
||||
layer: 'serving', materialization: 'table', rows: 4201,
|
||||
columns: [
|
||||
{ name: 'tenant_id', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'p25_price_eur', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'p50_price_eur', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'p75_price_eur', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'peak_price_eur', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'avg_occupancy', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'sample_days', type: 'INTEGER', nullable: false },
|
||||
],
|
||||
},
|
||||
city_market_profile: {
|
||||
layer: 'serving', materialization: 'table', rows: 987,
|
||||
columns: [
|
||||
{ name: 'city_hk', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'city_name', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'country_code', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'venue_count', type: 'INTEGER', nullable: false },
|
||||
{ name: 'avg_price_eur', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'median_occ_rate', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'population', type: 'INTEGER', nullable: true },
|
||||
{ name: 'income_eur', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'opportunity_score',type:'DOUBLE', nullable: true },
|
||||
],
|
||||
},
|
||||
pseo_city_costs_de: {
|
||||
layer: 'serving', materialization: 'table', rows: 847,
|
||||
columns: [
|
||||
{ name: 'city_slug', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'city_name_de', type: 'VARCHAR', nullable: true },
|
||||
{ name: 'avg_build_cost', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'land_cost_m2', type: 'DOUBLE', nullable: true },
|
||||
{ name: 'venue_count', type: 'INTEGER', nullable: false },
|
||||
{ name: 'opportunity_score',type:'DOUBLE', nullable: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// fallback for models not in mock
|
||||
function getSchema(model) {
|
||||
if (SCHEMA[model]) return SCHEMA[model];
|
||||
const layer = model.startsWith('stg_') ? 'staging'
|
||||
: (model.startsWith('dim_') || model.startsWith('fct_')) ? 'foundation'
|
||||
: 'serving';
|
||||
return { layer, materialization: 'view', rows: null,
|
||||
columns: [
|
||||
{ name: 'id', type: 'VARCHAR', nullable: false },
|
||||
{ name: 'created_at', type: 'TIMESTAMP', nullable: false },
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// ── DAG definition ────────────────────────────────────────────────────────
|
||||
const DAG = {
|
||||
stg_padel_courts: [],
|
||||
stg_playtomic_venues: [],
|
||||
stg_playtomic_resources: [],
|
||||
stg_playtomic_opening_hours: [],
|
||||
stg_playtomic_availability: [],
|
||||
stg_population: [],
|
||||
stg_population_usa: [],
|
||||
stg_population_uk: [],
|
||||
stg_population_geonames: [],
|
||||
stg_income: [],
|
||||
stg_income_usa: [],
|
||||
stg_city_labels: [],
|
||||
stg_nuts2_boundaries: [],
|
||||
stg_regional_income: [],
|
||||
stg_tennis_courts: [],
|
||||
dim_venues: ['stg_playtomic_venues','stg_playtomic_resources','stg_padel_courts'],
|
||||
dim_cities: ['dim_venues','stg_income','stg_city_labels','stg_population','stg_population_usa','stg_population_uk','stg_population_geonames'],
|
||||
dim_locations: ['stg_population_geonames','stg_income','stg_nuts2_boundaries','stg_regional_income','stg_income_usa','stg_padel_courts','stg_tennis_courts'],
|
||||
dim_venue_capacity: ['stg_playtomic_venues','stg_playtomic_resources','stg_playtomic_opening_hours'],
|
||||
fct_availability_slot: ['stg_playtomic_availability'],
|
||||
fct_daily_availability: ['fct_availability_slot','dim_venue_capacity'],
|
||||
venue_pricing_benchmarks: ['fct_daily_availability'],
|
||||
city_market_profile: ['dim_cities','venue_pricing_benchmarks'],
|
||||
planner_defaults: ['venue_pricing_benchmarks','city_market_profile'],
|
||||
location_opportunity_profile: ['dim_locations'],
|
||||
pseo_city_costs_de: ['city_market_profile','planner_defaults','location_opportunity_profile'],
|
||||
pseo_city_pricing: ['venue_pricing_benchmarks','city_market_profile'],
|
||||
pseo_country_overview: ['pseo_city_costs_de'],
|
||||
};
|
||||
|
||||
// Compute downstream map
|
||||
const DOWNSTREAM = {};
|
||||
Object.keys(DAG).forEach(n => DOWNSTREAM[n] = []);
|
||||
Object.entries(DAG).forEach(([name, deps]) => {
|
||||
deps.forEach(dep => {
|
||||
if (!DOWNSTREAM[dep]) DOWNSTREAM[dep] = [];
|
||||
DOWNSTREAM[dep].push(name);
|
||||
});
|
||||
});
|
||||
|
||||
function classifyLayer(name) {
|
||||
if (name.startsWith('stg_')) return 'staging';
|
||||
if (name.startsWith('dim_') || name.startsWith('fct_')) return 'foundation';
|
||||
return 'serving';
|
||||
}
|
||||
|
||||
// ── SVG rendering (mirrors Python logic) ─────────────────────────────────
|
||||
const COLORS = {
|
||||
staging: { bg:'#F0FDF4', border:'#BBF7D0', accent:'#16A34A', fill:'#DCFCE7', text:'#14532D' },
|
||||
foundation: { bg:'#EFF6FF', border:'#BFDBFE', accent:'#1D4ED8', fill:'#DBEAFE', text:'#1E3A8A' },
|
||||
serving: { bg:'#FFFBEB', border:'#FDE68A', accent:'#D97706', fill:'#FEF3C7', text:'#78350F' },
|
||||
};
|
||||
const LANE_ORDER = ['staging','foundation','serving'];
|
||||
const LANE_LABELS = { staging:'STAGING', foundation:'FOUNDATION', serving:'SERVING' };
|
||||
|
||||
function buildSVG() {
|
||||
const CW = 7.4, PAD_H = 10, NH = 26, VGAP = 10, PAD_TOP = 52, PAD_BOT = 24;
|
||||
const INNER_W = 210, LANE_GAP = 40, LANE_PAD_L = 16;
|
||||
|
||||
// downstream counts
|
||||
const dnCount = {};
|
||||
Object.keys(DAG).forEach(n => dnCount[n] = 0);
|
||||
Object.values(DAG).forEach(deps => deps.forEach(d => dnCount[d] = (dnCount[d]||0)+1));
|
||||
|
||||
const layers = { staging:[], foundation:[], serving:[] };
|
||||
Object.keys(DAG).forEach(n => layers[classifyLayer(n)].push(n));
|
||||
LANE_ORDER.forEach(l => layers[l].sort((a,b) => (dnCount[b]||0)-(dnCount[a]||0)||a.localeCompare(b)));
|
||||
|
||||
const nodeW = n => Math.max(n.length * CW + PAD_H*2, 80);
|
||||
|
||||
const laneX = {};
|
||||
let xc = 0;
|
||||
LANE_ORDER.forEach(l => { laneX[l] = xc; xc += INNER_W + LANE_PAD_L*2 + LANE_GAP; });
|
||||
|
||||
const pos = {};
|
||||
const laneH = {};
|
||||
LANE_ORDER.forEach(l => {
|
||||
let y = PAD_TOP;
|
||||
layers[l].forEach(n => { pos[n] = [laneX[l]+LANE_PAD_L, y]; y += NH+VGAP; });
|
||||
laneH[l] = y + PAD_BOT - VGAP;
|
||||
});
|
||||
|
||||
const W = xc - LANE_GAP;
|
||||
const H = Math.max(...Object.values(laneH));
|
||||
|
||||
let parts = [];
|
||||
|
||||
parts.push(`<defs>
|
||||
<marker id="arr" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto">
|
||||
<path d="M0,0 L0,6 L6,3 z" fill="#CBD5E1"/>
|
||||
</marker>
|
||||
<marker id="arr-hi" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto">
|
||||
<path d="M0,0 L0,6 L6,3 z" fill="#1D4ED8"/>
|
||||
</marker>
|
||||
</defs>`);
|
||||
|
||||
LANE_ORDER.forEach(l => {
|
||||
const c = COLORS[l], lx = laneX[l], lw = INNER_W+LANE_PAD_L*2, lh = laneH[l];
|
||||
parts.push(`<rect x="${lx}" y="0" width="${lw}" height="${lh}" rx="10" fill="${c.bg}" stroke="${c.border}" stroke-width="1"/>`);
|
||||
parts.push(`<text x="${lx+lw/2}" y="28" text-anchor="middle" font-family="'DM Sans',sans-serif" font-size="10" font-weight="700" letter-spacing="1.5" fill="${c.accent}">${LANE_LABELS[l]}</text>`);
|
||||
parts.push(`<line x1="${lx+12}" y1="36" x2="${lx+lw-12}" y2="36" stroke="${c.border}" stroke-width="1"/>`);
|
||||
});
|
||||
|
||||
// Edges
|
||||
Object.entries(DAG).forEach(([name, deps]) => {
|
||||
const [tx, ty] = pos[name];
|
||||
const tgt_cx = tx, tgt_cy = ty + NH/2;
|
||||
deps.forEach(dep => {
|
||||
if (!pos[dep]) return;
|
||||
const [sx, sy] = pos[dep];
|
||||
const sw = nodeW(dep);
|
||||
const src_cx = sx+sw, src_cy = sy+NH/2;
|
||||
const cpx1 = src_cx+(tgt_cx-src_cx)*0.45;
|
||||
const cpx2 = tgt_cx-(tgt_cx-src_cx)*0.45;
|
||||
parts.push(`<path class="lineage-edge" data-from="${dep}" data-to="${name}" d="M${src_cx},${src_cy} C${cpx1},${src_cy} ${cpx2},${tgt_cy} ${tgt_cx},${tgt_cy}" fill="none" stroke="#CBD5E1" stroke-width="1" marker-end="url(#arr)"/>`);
|
||||
});
|
||||
});
|
||||
|
||||
// Nodes
|
||||
Object.keys(DAG).forEach(name => {
|
||||
const l = classifyLayer(name);
|
||||
const c = COLORS[l];
|
||||
const [rx, ry] = pos[name];
|
||||
const rw = nodeW(name);
|
||||
const tx = rx+PAD_H, ty = ry+NH/2+4;
|
||||
parts.push(`<g class="lineage-node" data-model="${name}" tabindex="0" role="button" aria-label="${name}">
|
||||
<rect x="${rx}" y="${ry}" width="${rw}" height="${NH}" rx="5" fill="${c.fill}" stroke="${c.border}" stroke-width="1"/>
|
||||
<rect x="${rx}" y="${ry}" width="3" height="${NH}" rx="5" fill="${c.accent}"/>
|
||||
<text x="${tx}" y="${ty}" font-family="'DM Mono',monospace" font-size="11" fill="${c.text}">${name}</text>
|
||||
</g>`);
|
||||
});
|
||||
|
||||
return `<svg class="lineage-svg" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;min-width:${Math.ceil(W)}px">${parts.join('\n')}</svg>`;
|
||||
}
|
||||
|
||||
// ── Inject SVG ────────────────────────────────────────────────────────────
|
||||
document.getElementById('svg-wrap').innerHTML = buildSVG();
|
||||
|
||||
// ── Tooltip logic ─────────────────────────────────────────────────────────
|
||||
const tooltip = document.getElementById('tooltip');
|
||||
const ttBox = document.getElementById('tt-box');
|
||||
const PREVIEW_COUNT = 4;
|
||||
|
||||
function showTooltip(model, x, y) {
|
||||
const s = getSchema(model);
|
||||
const preview = s.columns.slice(0, PREVIEW_COUNT);
|
||||
const extra = s.columns.length - PREVIEW_COUNT;
|
||||
const layerClass = s.layer;
|
||||
|
||||
ttBox.innerHTML = `
|
||||
<div class="tt-header">
|
||||
<span class="tt-name">${model}</span>
|
||||
<span class="tt-layer ${layerClass}">${s.layer}</span>
|
||||
</div>
|
||||
${preview.map(c => `
|
||||
<div class="tt-row">
|
||||
<span class="tt-col">${c.name}</span>
|
||||
<span class="tt-type">${c.type}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
${extra > 0 ? `<div class="tt-more">+${extra} more column${extra===1?'':'s'} — click to view all</div>` : ''}
|
||||
${extra <= 0 && s.columns.length > 0 ? `<div class="tt-more" style="color:#94A3B8;font-style:italic">click to inspect</div>` : ''}
|
||||
`;
|
||||
|
||||
// Position: prefer right of cursor, flip if near right edge
|
||||
const W = tooltip.offsetWidth || 260, H = tooltip.offsetHeight || 120;
|
||||
const vw = window.innerWidth, vh = window.innerHeight;
|
||||
let left = x + 14, top = y - 10;
|
||||
if (left + W > vw - 12) left = x - W - 14;
|
||||
if (top + H > vh - 12) top = vh - H - 12;
|
||||
tooltip.style.left = left + 'px';
|
||||
tooltip.style.top = top + 'px';
|
||||
tooltip.classList.add('visible');
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
tooltip.classList.remove('visible');
|
||||
}
|
||||
|
||||
// ── Panel logic ───────────────────────────────────────────────────────────
|
||||
const panel = document.getElementById('detail-panel');
|
||||
const canvasWrap = document.getElementById('canvas-wrap');
|
||||
const panelModelName = document.getElementById('panel-model-name');
|
||||
const panelLayerChip = document.getElementById('panel-layer-chip');
|
||||
const panelMat = document.getElementById('panel-materialization');
|
||||
const panelBody = document.getElementById('panel-body');
|
||||
|
||||
let activeModel = null;
|
||||
|
||||
function fmt(n) {
|
||||
return n == null ? '—' : n.toLocaleString();
|
||||
}
|
||||
|
||||
function openPanel(model) {
|
||||
activeModel = model;
|
||||
const s = getSchema(model);
|
||||
panelModelName.textContent = model;
|
||||
panelLayerChip.textContent = s.layer;
|
||||
panelLayerChip.className = 'meta-chip ' + s.layer;
|
||||
panelMat.textContent = s.materialization;
|
||||
|
||||
const ups = DAG[model] || [];
|
||||
const downs = DOWNSTREAM[model] || [];
|
||||
|
||||
panelBody.innerHTML = `
|
||||
<div class="panel-section">
|
||||
<div class="section-label">Row count</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-val">${fmt(s.rows)}</span>
|
||||
${s.rows != null ? '<span class="stat-unit">rows</span>' : ''}
|
||||
</div>
|
||||
${s.rows == null ? '<div class="empty-state" style="margin-top:2px">staging views have no row count</div>' : ''}
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<div class="section-label">Schema · ${s.columns.length} columns</div>
|
||||
<table class="schema-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>column</th>
|
||||
<th>type</th>
|
||||
<th style="text-align:right">nullable</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${s.columns.map(c => `
|
||||
<tr>
|
||||
<td class="col-name">${c.name}</td>
|
||||
<td class="col-type">${c.type}</td>
|
||||
<td class="col-null ${c.nullable?'yes':''}">${c.nullable?'null':'—'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<div class="section-label">Upstream · ${ups.length}</div>
|
||||
${ups.length ? `<div class="dep-list">${ups.map(d => `
|
||||
<div class="dep-item" data-model="${d}" onclick="openPanel('${d}')">
|
||||
<span class="dep-dot ${classifyLayer(d)}"></span>${d}
|
||||
</div>
|
||||
`).join('')}</div>` : '<div class="empty-state">no upstream dependencies</div>'}
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<div class="section-label">Downstream · ${downs.length}</div>
|
||||
${downs.length ? `<div class="dep-list">${downs.map(d => `
|
||||
<div class="dep-item" data-model="${d}" onclick="openPanel('${d}')">
|
||||
<span class="dep-dot ${classifyLayer(d)}"></span>${d}
|
||||
</div>
|
||||
`).join('')}</div>` : '<div class="empty-state">nothing depends on this model</div>'}
|
||||
</div>
|
||||
`;
|
||||
|
||||
panel.classList.add('open');
|
||||
canvasWrap.classList.add('panel-open');
|
||||
|
||||
// Highlight selected node
|
||||
document.querySelectorAll('.lineage-node').forEach(n => {
|
||||
n.classList.toggle('selected', n.dataset.model === model);
|
||||
});
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panel.classList.remove('open');
|
||||
canvasWrap.classList.remove('panel-open');
|
||||
document.querySelectorAll('.lineage-node').forEach(n => n.classList.remove('selected'));
|
||||
activeModel = null;
|
||||
}
|
||||
|
||||
document.getElementById('panel-close').addEventListener('click', closePanel);
|
||||
|
||||
// ── Wire up SVG nodes ─────────────────────────────────────────────────────
|
||||
const svg = document.querySelector('.lineage-svg');
|
||||
const nodes = svg.querySelectorAll('.lineage-node');
|
||||
const edges = svg.querySelectorAll('.lineage-edge');
|
||||
|
||||
nodes.forEach(g => {
|
||||
const model = g.dataset.model;
|
||||
|
||||
g.addEventListener('mouseenter', e => {
|
||||
// Highlight edges
|
||||
edges.forEach(edge => {
|
||||
if (edge.dataset.from === model || edge.dataset.to === model) {
|
||||
edge.classList.add('hi');
|
||||
edge.classList.remove('dim');
|
||||
edge.setAttribute('marker-end', 'url(#arr-hi)');
|
||||
} else {
|
||||
edge.classList.add('dim');
|
||||
edge.classList.remove('hi');
|
||||
}
|
||||
});
|
||||
showTooltip(model, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
g.addEventListener('mousemove', e => {
|
||||
showTooltip(model, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
g.addEventListener('mouseleave', () => {
|
||||
edges.forEach(e => {
|
||||
e.classList.remove('hi', 'dim');
|
||||
e.setAttribute('marker-end', 'url(#arr)');
|
||||
});
|
||||
hideTooltip();
|
||||
});
|
||||
|
||||
g.addEventListener('click', () => {
|
||||
hideTooltip();
|
||||
if (activeModel === model) {
|
||||
closePanel();
|
||||
} else {
|
||||
openPanel(model);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Close panel on Escape
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closePanel();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user