diff --git a/src/fastflowtransform/dag.py b/src/fastflowtransform/dag.py index a91a984..6287b44 100644 --- a/src/fastflowtransform/dag.py +++ b/src/fastflowtransform/dag.py @@ -2,9 +2,10 @@ import heapq import re from collections import defaultdict +from typing import Any -from .core import Node, relation_for -from .errors import DependencyNotFoundError, ModelCycleError +from fastflowtransform.core import Node, relation_for +from fastflowtransform.errors import DependencyNotFoundError, ModelCycleError def topo_sort(nodes: dict[str, Node]) -> list[str]: @@ -154,3 +155,361 @@ def mermaid( lines.append("") return "\n".join(lines) + + +# --- SPA Graph (layout computed server-side) ----------------------------- + + +def _clamp(n: float, lo: float, hi: float) -> float: + return lo if n < lo else hi if n > hi else n + + +def _approx_node_size(title: str, subtitle: str | None) -> tuple[int, int]: + """ + Approximate label width without font measurement. + Produces stable, decent-looking boxes for SVG render. + """ + title = title or "" + subtitle = subtitle or "" + longest = max(len(title), len(subtitle)) + # ~7px per char + padding + w = int(_clamp(longest * 7.2 + 56, 170, 380)) + h = 52 if subtitle else 44 + return w, h + + +def _barycenter_order( + level: list[str], + parents: dict[str, list[str]], + prev_pos: dict[str, int], +) -> list[str]: + """ + Order nodes in a level by the average position of their parents in the previous level. + Stable fallback to name. + """ + + def key(nm: str) -> tuple[float, str]: + ps = [p for p in parents.get(nm, []) if p in prev_pos] + if not ps: + return (1e12, nm) + avg = sum(prev_pos[p] for p in ps) / max(1, len(ps)) + return (avg, nm) + + return sorted(level, key=key) + + +_SRC_PREFIX = "__src__:" + + +def _collect_source_keys( + *, + sources_by_key: dict[tuple[str, str], Any], + model_source_refs: dict[str, list[tuple[str, str]]], +) -> list[tuple[str, str]]: + seen: set[tuple[str, str]] = set() + out: list[tuple[str, str]] = [] + + for refs in model_source_refs.values(): + for key in refs or []: + if key in sources_by_key and key not in seen: + seen.add(key) + out.append(key) + + out.sort(key=lambda k: (k[0], k[1])) + return out + + +def _build_parents(nodes: dict[str, "Node"]) -> dict[str, list[str]]: + return {nm: [d for d in (n.deps or []) if d in nodes] for nm, n in nodes.items()} + + +def _build_ordered_levels( + *, + lvls: list[list[str]], + parents: dict[str, list[str]], + model_rank_offset: int, + source_keys: list[tuple[str, str]], +) -> dict[int, list[str]]: + ordered_levels: dict[int, list[str]] = {} + prev_positions: dict[str, int] = {} + + if source_keys: + ordered_levels[0] = [f"{_SRC_PREFIX}{s}.{t}" for (s, t) in source_keys] + + for i, lvl in enumerate(lvls): + r = i + model_rank_offset + ordered = sorted(lvl) if i == 0 else _barycenter_order(lvl, parents, prev_positions) + ordered_levels[r] = ordered + prev_positions = {nm: idx for idx, nm in enumerate(ordered)} + + return ordered_levels + + +def _rank_xy( + *, + r: int, + idx: int, + rank_len: int, + max_count: int, + rank_spacing: int, + node_spacing: int, + padding: int, +) -> tuple[int, int]: + y0 = padding + int((max_count - rank_len) * node_spacing * 0.5) + x = padding + r * rank_spacing + y = y0 + idx * node_spacing + return x, y + + +def _model_payload( + *, + nm: str, + nodes: dict[str, "Node"], + r: int, + idx: int, + rank_len: int, + max_count: int, + rank_spacing: int, + node_spacing: int, + padding: int, +) -> dict[str, Any]: + n = nodes[nm] + rel = relation_for(nm) + mat = (getattr(n, "meta", {}) or {}).get("materialized", "table") + w, h = _approx_node_size(nm, rel) + x, y = _rank_xy( + r=r, + idx=idx, + rank_len=rank_len, + max_count=max_count, + rank_spacing=rank_spacing, + node_spacing=node_spacing, + padding=padding, + ) + + return { + "id": f"m:{nm}", + "kind": "model", + "name": nm, + "route": f"#/model/{nm}", + "type": getattr(n, "kind", "sql"), + "materialized": mat, + "relation": rel, + "rank": r, + "x": x, + "y": y, + "w": w, + "h": h, + } + + +def _source_payload( + *, + src: str, + tbl: str, + sources_by_key: dict[tuple[str, str], Any], + r: int, + idx: int, + rank_len: int, + max_count: int, + rank_spacing: int, + node_spacing: int, + padding: int, +) -> dict[str, Any]: + doc = sources_by_key.get((src, tbl)) + rel = ( + getattr(doc, "relation", None) + or (doc.get("relation") if isinstance(doc, dict) else None) + or f"{src}.{tbl}" + ) + label = f"{src}.{tbl}" + w, h = _approx_node_size(label, rel) + x, y = _rank_xy( + r=r, + idx=idx, + rank_len=rank_len, + max_count=max_count, + rank_spacing=rank_spacing, + node_spacing=node_spacing, + padding=padding, + ) + + return { + "id": f"s:{src}.{tbl}", + "kind": "source", + "source_name": src, + "table_name": tbl, + "route": f"#/source/{src}/{tbl}", + "relation": rel, + "rank": r, + "x": x, + "y": y, + "w": w, + "h": h, + } + + +def _emit_nodes( + *, + nodes: dict[str, "Node"], + sources_by_key: dict[tuple[str, str], Any], + ordered_levels: dict[int, list[str]], + max_count: int, + rank_spacing: int, + node_spacing: int, + padding: int, +) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + + for r, names in ordered_levels.items(): + rank_len = len(names) + for idx, item in enumerate(names): + if item.startswith(_SRC_PREFIX): + raw = item.split(":", 1)[1] + src, tbl = raw.split(".", 1) + out.append( + _source_payload( + src=src, + tbl=tbl, + sources_by_key=sources_by_key, + r=r, + idx=idx, + rank_len=rank_len, + max_count=max_count, + rank_spacing=rank_spacing, + node_spacing=node_spacing, + padding=padding, + ) + ) + else: + out.append( + _model_payload( + nm=item, + nodes=nodes, + r=r, + idx=idx, + rank_len=rank_len, + max_count=max_count, + rank_spacing=rank_spacing, + node_spacing=node_spacing, + padding=padding, + ) + ) + + return out + + +def _emit_edges( + *, + nodes: dict[str, "Node"], + sources_by_key: dict[tuple[str, str], Any], + model_source_refs: dict[str, list[tuple[str, str]]], +) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + + # model deps + for nm, n in nodes.items(): + for d in n.deps or []: + if d in nodes: + out.append({"from": f"m:{d}", "to": f"m:{nm}", "kind": "dep"}) + + # sources -> models + for nm, refs in model_source_refs.items(): + for src, tbl in refs or []: + if (src, tbl) in sources_by_key: + out.append({"from": f"s:{src}.{tbl}", "to": f"m:{nm}", "kind": "source"}) + + return out + + +def _apply_direction(direction: str, out_nodes: list[dict[str, Any]]) -> str: + d = direction.upper() + if d == "TB": + for n in out_nodes: + n["x"], n["y"] = n["y"], n["x"] + return d + + +def _bounds(out_nodes: list[dict[str, Any]], padding: int) -> dict[str, int]: + if not out_nodes: + return {"minx": 0, "miny": 0, "maxx": 0, "maxy": 0, "width": padding, "height": padding} + + minx = min(n["x"] for n in out_nodes) + miny = min(n["y"] for n in out_nodes) + maxx = max(n["x"] + n["w"] for n in out_nodes) + maxy = max(n["y"] + n["h"] for n in out_nodes) + + return { + "minx": int(minx), + "miny": int(miny), + "maxx": int(maxx), + "maxy": int(maxy), + "width": int(maxx - minx + padding), + "height": int(maxy - miny + padding), + } + + +def spa_graph( + nodes: dict[str, "Node"], + *, + sources_by_key: dict[tuple[str, str], Any] | None = None, + model_source_refs: dict[str, list[tuple[str, str]]] | None = None, + direction: str = "LR", # LR or TB + rank_spacing: int = 280, + node_spacing: int = 84, + padding: int = 24, +) -> dict[str, Any]: + """ + Build a lightweight graph payload for the SPA: + - Layout computed here (no JS graph libs needed) + - Browser renders SVG + pan/zoom + navigation + + sources_by_key values may be SourceDoc or dict-like; we only access: + .source_name, .table_name, .relation + """ + sources_by_key = sources_by_key or {} + model_source_refs = model_source_refs or {} + + # Levels for models only (sources aren't Nodes) + lvls = levels(nodes) # raises on cycles/missing deps like topo_sort + + source_keys = _collect_source_keys( + sources_by_key=sources_by_key, + model_source_refs=model_source_refs, + ) + has_sources = bool(source_keys) + model_rank_offset = 1 if has_sources else 0 + + parents = _build_parents(nodes) + ordered_levels = _build_ordered_levels( + lvls=lvls, + parents=parents, + model_rank_offset=model_rank_offset, + source_keys=source_keys, + ) + max_count = max((len(v) for v in ordered_levels.values()), default=1) + + out_nodes = _emit_nodes( + nodes=nodes, + sources_by_key=sources_by_key, + ordered_levels=ordered_levels, + max_count=max_count, + rank_spacing=rank_spacing, + node_spacing=node_spacing, + padding=padding, + ) + out_edges = _emit_edges( + nodes=nodes, + sources_by_key=sources_by_key, + model_source_refs=model_source_refs, + ) + + normalized_direction = _apply_direction(direction, out_nodes) + bounds = _bounds(out_nodes, padding) + + return { + "direction": normalized_direction, + "nodes": out_nodes, + "edges": out_edges, + "bounds": bounds, + } diff --git a/src/fastflowtransform/docs.py b/src/fastflowtransform/docs.py index d1e6147..2d4cba5 100644 --- a/src/fastflowtransform/docs.py +++ b/src/fastflowtransform/docs.py @@ -14,7 +14,7 @@ from markupsafe import Markup from fastflowtransform.core import REGISTRY, Node, relation_for -from fastflowtransform.dag import mermaid as dag_mermaid +from fastflowtransform.dag import mermaid as dag_mermaid, spa_graph as dag_spa_graph from fastflowtransform.executors.base import ColumnInfo from fastflowtransform.lineage import ( infer_py_lineage, @@ -206,6 +206,7 @@ def _build_spa_manifest( env_name: str | None, with_schema: bool, mermaid_src: str, + graph: dict[str, Any], models: list[ModelDoc], sources: list[SourceDoc], macros: list[dict[str, str]], @@ -293,7 +294,7 @@ def _col_to_dict(c: ColumnInfo) -> dict[str, Any]: "env": env_name, "with_schema": bool(with_schema), }, - "dag": {"mermaid": mermaid_src}, + "dag": {"graph": graph, "mermaid": mermaid_src}, "models": out_models, "sources": out_sources, "macros": macros, @@ -673,6 +674,12 @@ def render_site( mermaid_src = dag_mermaid( nodes, source_links=source_link_meta, model_source_refs=model_source_refs ) + graph = dag_spa_graph( + nodes, + sources_by_key=sources_by_key, + model_source_refs=model_source_refs, + direction="LR", + ) proj_dir = _get_project_dir() docs_meta = read_docs_metadata(proj_dir) if proj_dir else {"models": {}, "columns": {}} models = _collect_models(nodes) @@ -694,6 +701,7 @@ def render_site( env_name=env_name, with_schema=with_schema, mermaid_src=str(mermaid_src), + graph=graph, models=models, sources=sources, macros=macro_list, diff --git a/src/fastflowtransform/executors/postgres.py b/src/fastflowtransform/executors/postgres.py index 787fcd9..540d2dd 100644 --- a/src/fastflowtransform/executors/postgres.py +++ b/src/fastflowtransform/executors/postgres.py @@ -582,7 +582,7 @@ def collect_docs_columns(self) -> dict[str, list[ColumnInfo]]: try: with self.engine.begin() as conn: self._set_search_path(conn) - rows = conn.execute(text(sql)).fetchall() + rows = self._execute_sql_maintenance(sql, conn=conn).fetchall() except Exception: return {} diff --git a/src/fastflowtransform/templates/assets/spa.css b/src/fastflowtransform/templates/assets/spa.css index 6d0f7a4..dc59ec7 100644 --- a/src/fastflowtransform/templates/assets/spa.css +++ b/src/fastflowtransform/templates/assets/spa.css @@ -26,6 +26,7 @@ hr{ border:0; border-top:1px solid var(--border); margin:16px 0; } @media (max-width: 1000px){ .sidebar{ position:relative; height:auto; border-right:0; border-bottom:1px solid var(--border); } } +.sidebar .kv { grid-template-columns: 90px 1fr; } .brand{ display:flex; align-items:baseline; justify-content:space-between; gap:8px; margin-bottom:12px; } .brand h1{ font-size:14px; margin:0; } @@ -68,6 +69,26 @@ hr{ border:0; border-top:1px solid var(--border); margin:16px 0; } .desc p{ margin: 0 0 10px 0; } .desc p:last-child{ margin-bottom:0; } .mermaidWrap{ overflow:auto; } +/* Clickable Mermaid nodes */ +.mermaidWrap svg g.node.ffMermaidLink { cursor: pointer; } + +.mermaidWrap svg g.node.ffMermaidLink:hover rect, +.mermaidWrap svg g.node.ffMermaidLink:hover polygon, +.mermaidWrap svg g.node.ffMermaidLink:hover ellipse, +.mermaidWrap svg g.node.ffMermaidLink:hover circle, +.mermaidWrap svg g.node.ffMermaidLink:hover path { + stroke: var(--accent); + stroke-width: 2px; +} + +.mermaidWrap svg g.node.ffMermaidLink:focus rect, +.mermaidWrap svg g.node.ffMermaidLink:focus polygon, +.mermaidWrap svg g.node.ffMermaidLink:focus ellipse, +.mermaidWrap svg g.node.ffMermaidLink:focus circle, +.mermaidWrap svg g.node.ffMermaidLink:focus path { + stroke: var(--accent); + stroke-width: 2px; +} /* Global search palette */ .overlay{ @@ -202,3 +223,310 @@ tr.colHit{ outline: 1px solid color-mix(in srgb, var(--accent), transparent 45%); outline-offset: -1px; } + +/* Column table UX */ +.thBtn{ + border:0; + background:transparent; + color:var(--muted); + font: inherit; + font-weight:600; + font-size:12px; + cursor:pointer; + padding:0; + display:inline-flex; + gap:6px; + align-items:center; +} +.thBtn:hover{ color: var(--fg); } +.sortArrow{ opacity:.75; font-size:11px; } + +.pillSmall{ + font-size:11px; + padding:1px 8px; + border-radius:999px; + border:1px solid var(--border); + color:var(--muted); + display:inline-block; +} +.pillGood{ color: color-mix(in srgb, var(--accent2), var(--muted) 30%); } +.pillBad{ color: color-mix(in srgb, var(--warn), var(--muted) 30%); } + +.colRow{ cursor:pointer; } +.colRow:hover td{ background: color-mix(in srgb, var(--accent), transparent 94%); } +.colRow.undoc td{ background: color-mix(in srgb, var(--warn), transparent 92%); } + +.drawerRow td{ + padding: 0; + border-bottom: 1px solid var(--border); +} +.drawer{ + padding: 12px 12px; + background: color-mix(in srgb, var(--card), transparent 0%); + border-top: 1px dashed color-mix(in srgb, var(--border), transparent 30%); +} +.drawerGrid{ + display:grid; + gap:12px; + grid-template-columns: 1.4fr 1fr; +} +@media (max-width: 900px){ .drawerGrid{ grid-template-columns: 1fr; } } + +.drawerTitle{ margin:0 0 6px 0; font-size:12px; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); } +.drawerBox{ border:1px solid var(--border); border-radius:14px; padding:10px 10px; } + +.colTools{ + display:flex; + gap:10px; + flex-wrap:wrap; + align-items:center; + margin-bottom:10px; +} + +.colCount{ color: var(--muted); font-size:12px; } + +/* Small icon-ish buttons */ +.btnTiny{ + border:1px solid var(--border); + background:transparent; + color:var(--fg); + padding:4px 8px; + border-radius:10px; + cursor:pointer; + font-size:12px; +} +.btnTiny:hover{ border-color:var(--accent); } + +.drawerTools{ + display:flex; + gap:8px; + flex-wrap:wrap; + align-items:center; + margin-top:10px; +} + +.graphWrap{ + border:1px solid var(--border); + border-radius: 16px; + overflow:hidden; + background: rgba(127,127,127,.04); + min-height: 62vh; +} + +.graphHost{ width:100%; height:62vh; } +.dagSvg{ width:100%; height:100%; display:block; outline:none; cursor: grab; } +.dagSvg:active{ cursor: grabbing; } + +.dagEdges .dagEdge{ + fill:none; + stroke: color-mix(in srgb, var(--fg), transparent 55%); + stroke-width: 2.2px; + opacity: 1; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} + +.dagEdges .dagEdge.source{ + stroke-dasharray: 6 5; + opacity: .95; +} + +.dagEdges .dagEdge.hover{ + stroke: var(--accent); + stroke-width: 3px; + opacity: 1; +} + +.dagArrow{ fill: var(--border); } +.dagEdge.hover + defs .dagArrow { fill: var(--accent); } /* harmless if not applied */ + +.dagNode .dagRect{ + fill: var(--card); + stroke: var(--border); + stroke-width: 1.2px; +} +.dagNode.model.sql .dagRect{ stroke: rgba(96,165,250,.55); } +.dagNode.model.python .dagRect{ stroke: rgba(134,239,172,.55); } +.dagNode.source .dagRect{ stroke: rgba(245,158,11,.55); stroke-dasharray: 5 4; } + +.dagNode.hover .dagRect{ + stroke: var(--accent); + stroke-width: 2px; +} + +.dagTitle{ + font: 600 13px ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; + fill: var(--fg); +} +.dagSub{ + font: 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; + fill: var(--muted); +} + +.dagBadge{ + font: 600 11px ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; + fill: var(--muted); +} +.dagBadge2{ + font: 11px ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; + fill: var(--muted); +} + +.graphWrap{ position:relative; } +.minimapHost{ + position:absolute; + right:12px; + bottom:12px; + width: 220px; + height: 140px; + border:1px solid var(--border); + border-radius: 12px; + background: color-mix(in srgb, var(--card), transparent 5%); + overflow:hidden; + box-shadow: 0 12px 30px rgba(0,0,0,.25); +} +.miniSvg{ width:100%; height:100%; display:block; } +.miniNode{ fill: color-mix(in srgb, var(--muted), transparent 75%); } +.miniEdge{ stroke: color-mix(in srgb, var(--muted), transparent 70%); stroke-width: 1; fill:none; } +.miniView{ + fill: color-mix(in srgb, var(--accent), transparent 88%); + stroke: var(--accent); + stroke-width: 1.2; +} +.dagNode.dim{ opacity: .18; } +.dagEdge.dim{ opacity: .12; } +.dagNode.hl{ opacity: 1; } +.dagEdge.hl{ opacity: 1; stroke: var(--accent); stroke-width: 2.2px; } +.dagNode.selected .dagRect{ stroke: var(--accent); stroke-width: 2.6px; } + +.kvRows{ + display:flex; + flex-direction:column; + gap:8px; +} + +.kvRow{ + display:flex; + justify-content:space-between; + align-items:center; + gap:12px; + + padding:8px 10px; + /* border:1px solid var(--border); */ + border-radius:12px; + background: color-mix(in srgb, var(--card), transparent 70%); +} + +.kvRow .k{ + color:var(--muted); + font-size:11px; + letter-spacing:.06em; + text-transform:uppercase; +} + +.kvRow .v{ + font-weight:600; + font-variant-numeric: tabular-nums; + text-align:right; + max-width: 60%; + overflow-wrap:anywhere; +} + +.dagHeader{ + display:flex; + align-items:flex-start; + justify-content:space-between; + gap:16px; +} + +.dagTitleRow{ + display:flex; + align-items:baseline; + gap:10px; + margin-bottom:4px; +} +.dagHeaderLeft h2{ margin:0; } + +.dagSubtle{ + margin:0; + color:var(--muted); + font-size:12px; +} + +.dagHeaderRight{ + display:flex; + flex-direction:column; + gap:10px; + align-items:flex-end; +} + +.dagToolsRow{ + display:flex; + gap:10px; + flex-wrap:wrap; + justify-content:flex-end; + align-items:center; +} + +.dagDepth{ + display:flex; + gap:8px; + align-items:center; +} +.dagDepth input[type="range"]{ width:160px; } + +@media (max-width: 900px){ + .dagHeader{ flex-direction:column; align-items:stretch; } + .dagHeaderRight{ align-items:flex-start; } + .dagToolsRow{ justify-content:flex-start; } +} + +.miniGraphHost{ + height: 260px; + border: 1px solid var(--border); + border-radius: 16px; + overflow: hidden; + background: color-mix(in srgb, var(--card), transparent 55%); +} +.miniGraphHost svg{ width:100%; height:100%; display:block; } + +.facetBox { display:flex; flex-direction:column; gap:8px; margin:8px 0 2px; } +.facetRow { display:flex; gap:8px; align-items:center; flex-wrap:wrap; } +.facetChips { display:flex; gap:6px; } + +.facetChip { + border: 1px solid var(--border); + background: transparent; + color: inherit; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + cursor: pointer; +} +.facetChip.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent), transparent 88%); +} + +.facetSelect, .facetInput { + flex: 1; + min-width: 140px; + border: 1px solid var(--border); + background: transparent; + color: inherit; + padding: 6px 10px; + border-radius: 12px; + font-size: 12px; +} + +.facetClear { + border: 1px solid var(--border); + background: transparent; + color: inherit; + padding: 6px 10px; + border-radius: 12px; + font-size: 12px; + cursor: pointer; +} +.facetClear:disabled { opacity: 0.5; cursor: not-allowed; } diff --git a/src/fastflowtransform/templates/assets/spa.js b/src/fastflowtransform/templates/assets/spa.js index dd5c081..02a2ce1 100644 --- a/src/fastflowtransform/templates/assets/spa.js +++ b/src/fastflowtransform/templates/assets/spa.js @@ -89,6 +89,139 @@ function parseHashWithQuery() { return { parts, query }; } +// -------- Model facet filters (shareable via hash query params) -------- +const FACET_Q = { + kind: "mk", // "sql" | "python" (omit => both) + materialized: "mm", // normalized materialization, or "__unknown__" + path: "mp", // path prefix +}; +const MAT_UNKNOWN = "__unknown__"; + +function normalizeModelKind(k) { + return (k || "").toLowerCase() === "python" ? "python" : "sql"; +} +function normalizeMaterialized(v) { + return (v || "").toString().trim().toLowerCase(); +} +function normalizePath(p) { + return (p || "").toString().replace(/\\/g, "/").replace(/^\/+/, ""); +} +function stripModelsPrefix(p) { + const n = normalizePath(p); + return n.toLowerCase().startsWith("models/") ? n.slice("models/".length) : n; +} +function modelMatchesPathPrefix(modelPath, prefix) { + if (!prefix) return true; + const p = normalizePath(modelPath).toLowerCase(); + const pref = normalizePath(prefix).toLowerCase(); + if (p.startsWith(pref)) return true; + // also allow prefix relative to "models/" + return stripModelsPrefix(p).startsWith(stripModelsPrefix(pref)); +} + +function readModelFacetsFromQuery(query) { + const mk = (query.get(FACET_Q.kind) || "").trim().toLowerCase(); + const kinds = mk + ? mk.split(",").map(s => s.trim()).filter(Boolean) + : ["sql", "python"]; + + const validKinds = new Set(kinds.filter(k => k === "sql" || k === "python")); + if (validKinds.size === 0) { validKinds.add("sql"); validKinds.add("python"); } + + return { + kinds: Array.from(validKinds), + materialized: normalizeMaterialized(query.get(FACET_Q.materialized) || ""), + pathPrefix: query.get(FACET_Q.path) || "", + }; +} + +function writeModelFacetsToQuery(query, facets) { + const kinds = Array.from(new Set(facets.kinds || [])); + const hasSql = kinds.includes("sql"); + const hasPy = kinds.includes("python"); + + // Default => omit + if (hasSql && hasPy) query.delete(FACET_Q.kind); + else query.set(FACET_Q.kind, kinds.join(",")); + + if (facets.materialized) query.set(FACET_Q.materialized, facets.materialized); + else query.delete(FACET_Q.materialized); + + if (facets.pathPrefix) query.set(FACET_Q.path, facets.pathPrefix); + else query.delete(FACET_Q.path); +} + +function currentModelFacets() { + return readModelFacetsFromQuery(parseHashWithQuery().query); +} + +function facetsActiveCount(f) { + const kinds = new Set(f.kinds || []); + const kindActive = !(kinds.has("sql") && kinds.has("python")); + return (kindActive ? 1 : 0) + (f.materialized ? 1 : 0) + (f.pathPrefix ? 1 : 0); +} + +function filterModelsWithFacets(models, facets) { + const kinds = new Set((facets.kinds || []).map(k => (k || "").toLowerCase())); + const mat = normalizeMaterialized(facets.materialized || ""); + const pref = facets.pathPrefix || ""; + + return (models || []).filter(m => { + const kind = normalizeModelKind(m.kind); + if (kinds.size && !kinds.has(kind)) return false; + + if (mat) { + const mm = normalizeMaterialized(m.materialized || ""); + if (mat === MAT_UNKNOWN) { + if (mm) return false; + } else { + if (mm !== mat) return false; + } + } + + if (pref && !modelMatchesPathPrefix(m.path || "", pref)) return false; + + return true; + }); +} + +// Merge current facet params into an arbitrary hash route (e.g. "#/model/x?tab=columns") +function routeWithFacets(route) { + const facets = currentModelFacets(); + const r = (route || "#/").startsWith("#") ? (route || "#/").slice(1) : (route || "/"); + const [pathPart, queryPart] = r.split("?", 2); + const q = new URLSearchParams(queryPart || ""); + writeModelFacetsToQuery(q, facets); + + const next = q.toString() ? `${pathPart}?${q.toString()}` : `${pathPart}`; + return `#${next.startsWith("/") ? "" : "/"}${next}`; +} + +// Replace just the hash query string without triggering hashchange (good UX for filters) +function replaceHashQuery(mutator) { + const full = (location.hash || "#/").slice(1); + const [pathPart, queryPart] = full.split("?", 2); + const q = new URLSearchParams(queryPart || ""); + mutator(q, pathPart); + + const next = q.toString() ? `${pathPart}?${q.toString()}` : `${pathPart}`; + const nextHash = `#${next.startsWith("/") ? "" : "/"}${next}`; + + history.replaceState(null, "", nextHash); + if (window.__fftLastHashKey) safeSet(window.__fftLastHashKey, nextHash); +} + +// Debounce helper (with cancel) +function debounce(fn, ms = 180) { + let t = null; + const wrapped = (...args) => { + clearTimeout(t); + t = setTimeout(() => fn(...args), ms); + }; + wrapped.cancel = () => { clearTimeout(t); t = null; }; + return wrapped; +} + function setTabInHash(tab) { const full = (location.hash || "#/").slice(1); const [pathPart, queryPart] = full.split("?", 2); @@ -113,7 +246,9 @@ function setModelQuery({ tab, col }) { function parseRoute() { const { parts, query } = parseHashWithQuery(); - if (parts.length === 0) return { route: "home" }; + if (parts.length === 0) { + return { route: "home", focus: query.get("focus") || "" }; + } if (parts[0] === "model" && parts[1]) { return { @@ -131,19 +266,6 @@ function parseRoute() { return { route: "home" }; } -async function initMermaid() { - try { - const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; - const mod = await import("https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs"); - const mermaid = mod.default; - mermaid.initialize({ startOnLoad: false, securityLevel: "loose", theme: prefersDark ? "dark" : "default" }); - return mermaid; - } catch (e) { - console.warn("Mermaid failed to load:", e); - return null; - } -} - function byName(arr, keyFn) { const m = new Map(); for (const x of arr) m.set(keyFn(x), x); @@ -154,56 +276,333 @@ function pillForKind(kind) { return el("span", { class: `pill ${kind}` }, kind); } +function graphTransformDirection(graph, dir) { + dir = (dir || "LR").toUpperCase(); + + // manifest graph is already LR; just return it + if (dir === "LR") return graph; + + // --- TB layout derived from LR graph (no rectangle rotation) --- + const PAD = 24; + const NODE_GAP_X = 32; // space between siblings in the same row + + // Copy nodes so we don't mutate manifest + const nodes = (graph.nodes || []).map(n => ({ ...n })); + const byId = new Map(nodes.map(n => [n.id, n])); + + // Group ORIGINAL nodes by rank (preserve ordering using original y) + const byRank = new Map(); + for (const n of (graph.nodes || [])) { + const r = Number.isFinite(n.rank) ? n.rank : 0; + if (!byRank.has(r)) byRank.set(r, []); + byRank.get(r).push(n); + } + + const ranks = [...byRank.keys()].sort((a, b) => a - b); + const minRank = ranks.length ? ranks[0] : 0; + + const maxH = nodes.reduce((m, n) => Math.max(m, Number(n.h || 0)), 0); + const RANK_GAP_Y = Math.max(110, maxH + 70); // reduces the “too large” vertical spacing + + // First pass: compute each row width, so we can optionally center rows + let maxRowW = 0; + const rowInfo = new Map(); + + for (const r of ranks) { + const items = byRank.get(r).slice().sort((a, b) => (a.y || 0) - (b.y || 0)); + let rowW = 0; + for (const orig of items) { + const nn = byId.get(orig.id); + rowW += Number(nn?.w || 0); + } + if (items.length > 1) rowW += NODE_GAP_X * (items.length - 1); + maxRowW = Math.max(maxRowW, rowW); + rowInfo.set(r, { items, rowW }); + } + + // Second pass: assign TB positions + for (const r of ranks) { + const { items, rowW } = rowInfo.get(r); + + // center each row inside the widest row (optional, but looks nicer) + let x = PAD + Math.max(0, (maxRowW - rowW) / 2); + const y = PAD + (r - minRank) * RANK_GAP_Y; + + for (const orig of items) { + const n = byId.get(orig.id); + n.x = x; + n.y = y; + x += Number(n.w || 0) + NODE_GAP_X; + } + } + + // Recompute bounds from new positions + let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity; + for (const n of nodes) { + minx = Math.min(minx, n.x); + miny = Math.min(miny, n.y); + maxx = Math.max(maxx, n.x + n.w); + maxy = Math.max(maxy, n.y + n.h); + } + const bounds = { + minx, miny, maxx, maxy, + width: (maxx - minx + PAD), + height: (maxy - miny + PAD), + }; + + return { ...graph, direction: "TB", nodes, bounds }; +} + +function buildAdj(graph) { + const out = new Map(); + const inn = new Map(); + for (const e of (graph.edges || [])) { + if (!out.has(e.from)) out.set(e.from, []); + if (!inn.has(e.to)) inn.set(e.to, []); + out.get(e.from).push(e.to); + inn.get(e.to).push(e.from); + } + return { out, inn }; +} + +function bfsCollect(startId, getNeighbors, depth) { + const dist = new Map(); + const q = [startId]; + dist.set(startId, 0); + + while (q.length) { + const id = q.shift(); + const d = dist.get(id); + if (d >= depth) continue; + + const nbrs = getNeighbors(id) || []; + for (const nb of nbrs) { + if (!dist.has(nb)) { + dist.set(nb, d + 1); + q.push(nb); + } + } + } + return dist; // Map(id -> distance) +} + +function buildNeighborhoodGraph(fullGraph, centerId, opts) { + const depth = Math.max(1, Math.min(8, Number(opts?.depth || 2))); + const mode = (opts?.mode || "both").toLowerCase(); // "up" | "down" | "both" + + const nodeById = new Map((fullGraph.nodes || []).map(n => [n.id, n])); + if (!nodeById.has(centerId)) return { nodes: [], edges: [], bounds: { minx:0,miny:0,maxx:0,maxy:0,width:0,height:0 }, direction:"LR" }; + + const { out, inn } = buildAdj(fullGraph); + + const upDist = (mode === "down") ? new Map([[centerId, 0]]) + : bfsCollect(centerId, (id) => inn.get(id), depth); + + const downDist = (mode === "up") ? new Map([[centerId, 0]]) + : bfsCollect(centerId, (id) => out.get(id), depth); + + // Collect included ids + const ids = new Set([centerId]); + for (const [id] of upDist) ids.add(id); + for (const [id] of downDist) ids.add(id); + + // Build nodes (copy w/h from fullGraph) + const nodes = [...ids].map(id => { + const n = nodeById.get(id); + return { ...n, x: 0, y: 0 }; // x/y will be recomputed + }); + + // Keep only edges fully inside + const idSet = new Set(ids); + const edges = (fullGraph.edges || []).filter(e => idSet.has(e.from) && idSet.has(e.to)); + + // Assign layers: upstream negative, downstream positive + const layer = new Map(); + layer.set(centerId, 0); + for (const [id, d] of upDist) { + if (id === centerId) continue; + layer.set(id, -d); + } + for (const [id, d] of downDist) { + if (id === centerId) continue; + // if something is both up and down (cycle), keep the smaller magnitude, prefer downstream for ties + if (!layer.has(id) || Math.abs(d) < Math.abs(layer.get(id))) layer.set(id, d); + else if (Math.abs(d) === Math.abs(layer.get(id)) && layer.get(id) < 0) layer.set(id, d); + } + + // Group by layer + const byLayer = new Map(); + for (const n of nodes) { + const L = layer.get(n.id) ?? 0; + if (!byLayer.has(L)) byLayer.set(L, []); + byLayer.get(L).push(n); + } + const layers = [...byLayer.keys()].sort((a,b)=>a-b); + + // Order within each layer: use original rank/x/y as a stable hint + for (const L of layers) { + byLayer.get(L).sort((a,b) => (a.rank ?? 0) - (b.rank ?? 0) || (a.y ?? 0) - (b.y ?? 0) || (a.x ?? 0) - (b.x ?? 0)); + } + + // Layout parameters + const PAD = 20; + const GAP_Y = 18; + const GAP_X = 70; + + // Column widths per layer + const colW = new Map(); + for (const L of layers) { + let mw = 0; + for (const n of byLayer.get(L)) mw = Math.max(mw, Number(n.w || 0)); + colW.set(L, mw); + } + + // X positions by layer with variable column widths + const xPos = new Map(); + let x = PAD; + for (const L of layers) { + xPos.set(L, x); + x += colW.get(L) + GAP_X; + } + + // Y packing per column; then vertically center columns to the tallest column + const colH = new Map(); + for (const L of layers) { + const col = byLayer.get(L); + let h = 0; + for (const n of col) h += Number(n.h || 0); + if (col.length > 1) h += GAP_Y * (col.length - 1); + colH.set(L, h); + } + const maxColH = Math.max(...layers.map(L => colH.get(L) || 0), 0); + + for (const L of layers) { + const col = byLayer.get(L); + const startY = PAD + Math.max(0, (maxColH - (colH.get(L) || 0)) / 2); + let y = startY; + for (const n of col) { + n.x = xPos.get(L); + n.y = y; + y += Number(n.h || 0) + GAP_Y; + } + } + + // Bounds + let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity; + for (const n of nodes) { + minx = Math.min(minx, n.x); + miny = Math.min(miny, n.y); + maxx = Math.max(maxx, n.x + n.w); + maxy = Math.max(maxy, n.y + n.h); + } + const bounds = { minx, miny, maxx, maxy, width: (maxx - minx + PAD), height: (maxy - miny + PAD) }; + + return { ...fullGraph, nodes, edges, bounds, direction: "LR" }; +} + function renderHome(state) { - const { manifest, mermaid } = state; - const dagSrc = manifest.dag?.mermaid || ""; + const { manifest } = state; + const graph = manifest.dag?.graph; + + const graphHost = el("div", { class: "graphHost" }); + const miniHost = el("div", { class: "minimapHost" }); + + const modeBtn = (id, label) => + el("button", { + class: `btn ${state.graphUI.mode === id ? "active" : ""}`, + onclick: () => { state.graphUI.mode = id; state._graphCtl?.refresh?.(); } + }, label); + + const depthPill = el("span", { class: "pill" }, `Depth ${state.graphUI.depth}`); + const depthSlider = el("input", { + type: "range", min: "1", max: "8", + value: String(state.graphUI.depth), + oninput: (e) => { + state.graphUI.depth = Number(e.target.value || 2); + depthPill.textContent = `Depth ${state.graphUI.depth}`; + rerenderMini(); + } + }); + + const fitBtn = el("button", { class: "btnTiny", title: "Fit to screen", onclick: () => state._graphCtl?.fit?.() }, "Fit"); + const resetBtn = el("button", { class: "btnTiny", title: "Reset pan/zoom", onclick: () => state._graphCtl?.reset?.() }, "Reset"); + const zoomOutBtn = el("button", { class: "btnTiny", title: "Zoom out", onclick: () => state._graphCtl?.zoomOut?.() }, "–"); + const zoomInBtn = el("button", { class: "btnTiny", title: "Zoom in", onclick: () => state._graphCtl?.zoomIn?.() }, "+"); + + const dirPill = el("span", { class: "pillSmall" }, + state.graphUI.dir === "TB" ? "Top → Bottom" : "Left → Right" + ); + + const lrBtn = el("button", { class: `tab ${state.graphUI.dir === "LR" ? "active" : ""}` }, "LR"); + const tbBtn = el("button", { class: `tab ${state.graphUI.dir === "TB" ? "active" : ""}` }, "TB"); - const dagCard = el("div", { class: "card" }, + function setDir(dir) { + dir = (dir || "LR").toUpperCase(); + if (state.graphUI.dir === dir) return; + + state.graphUI.dir = dir; + + // update segmented control UI + lrBtn.classList.toggle("active", dir === "LR"); + tbBtn.classList.toggle("active", dir === "TB"); + dirPill.textContent = dir === "TB" ? "Top → Bottom" : "Left → Right"; + + // remount graph + const g = graphTransformDirection(state.manifest.dag.graph, dir); + state._graphCtl = mountGraph(state, graphHost, g, { miniHost, showMini: true }); + } + + lrBtn.onclick = () => setDir("LR"); + tbBtn.onclick = () => setDir("TB"); + + // use this in your toolbar row: + const layoutTabs = el("div", { class: "tabs" }, lrBtn, tbBtn); + + const graphCard = el("div", { class: "card" }, el("div", { class: "grid" }, - el("div", { class: "grid2" }, - el("div", {}, - el("h2", {}, "DAG"), - el("p", { class: "empty" }, "Mermaid is rendered client-side.") + el("div", { class: "dagHeader" }, + el("div", { class: "dagHeaderLeft" }, + el("div", { class: "dagTitleRow" }, + el("h2", {}, "DAG"), + dirPill + ), + el("p", { class: "dagSubtle" }, + "Pan/zoom • click a node to pin • click again to unpin • Ctrl/Cmd-click opens." + ) ), - el("div", {}, - el("button", { - class: "btn", - onclick: async () => { - try { await navigator.clipboard.writeText(dagSrc); } catch {} - } - }, "Copy Mermaid") + + el("div", { class: "dagHeaderRight" }, + el("div", { class: "dagToolsRow" }, + fitBtn, resetBtn, zoomOutBtn, zoomInBtn, + layoutTabs + ), + el("div", { class: "dagToolsRow" }, + el("div", { class: "tabs dagModeTabs" }, + modeBtn("up", "Up"), + modeBtn("down", "Down"), + modeBtn("both", "Both"), + modeBtn("off", "Off"), + ), + el("div", { class: "dagDepth" }, depthPill, depthSlider), + ) ) ), - el("div", { class: "mermaidWrap" }, - el("div", { id: "mermaidTarget" }) - ) + + el("div", { class: "graphWrap" }, graphHost, miniHost) ) ); - // Render mermaid after DOM is mounted - queueMicrotask(async () => { - const target = document.getElementById("mermaidTarget"); - if (!target) return; - if (!mermaid) { - target.textContent = dagSrc; - return; + queueMicrotask(() => { + const g0 = graphTransformDirection(graph, state.graphUI.dir); + state._graphCtl = mountGraph(state, graphHost, g0, { miniHost, showMini: true }); + + const r = parseRoute(); + if (r.route === "home" && r.focus) { + state._graphCtl?.focus?.(r.focus, { zoom: 1.25, pin: true }); } - target.innerHTML = `
${dagSrc}`;
- try { await mermaid.run({ querySelector: "#mermaidTarget .mermaid" }); } catch {}
});
- const stats = el("div", { class: "card" },
- el("h2", {}, "Overview"),
- el("div", { class: "kv" },
- el("div", { class: "k" }, "Models"), el("div", {}, String((manifest.models || []).length)),
- el("div", { class: "k" }, "Sources"), el("div", {}, String((manifest.sources || []).length)),
- el("div", { class: "k" }, "Macros"), el("div", {}, String((manifest.macros || []).length)),
- el("div", { class: "k" }, "Schema"), el("div", {}, manifest.project?.with_schema ? "enabled" : "disabled"),
- el("div", { class: "k" }, "Generated"), el("div", {}, manifest.project?.generated_at || "—")
- )
- );
-
- return el("div", { class: "grid2" }, dagCard, stats);
+ return graphCard;
}
function renderModel(state, name, tabFromRoute, colFromRoute) {
@@ -226,6 +625,10 @@ function renderModel(state, name, tabFromRoute, colFromRoute) {
el("p", { class: "empty" }, m.relation ? `Relation: ${m.relation}` : "")
),
el("div", {},
+ el("button", {
+ class: "btn",
+ onclick: () => { location.hash = routeWithFacets("#/"); }
+ }, "← Overview"),
el("button", {
class: "btn",
onclick: async () => { try { await navigator.clipboard.writeText(m.path || ""); } catch {} }
@@ -252,12 +655,74 @@ function renderModel(state, name, tabFromRoute, colFromRoute) {
function renderModelPanel(state, m, tab, colFromRoute) {
if (tab === "overview") {
- const deps = (m.deps || []).map(d => el("a", { href: `#/model/${escapeHashPart(d)}` }, d));
+ const deps = (m.deps || []).map(d => el("a", { href: routeWithFacets(`#/model/${escapeHashPart(d)}`) }, d));
const usedBy = (m.used_by || []).map(u => el("a", { href: `#/model/${escapeHashPart(u)}` }, u));
const sourcesUsed = (m.sources_used || []).map(s =>
- el("a", { href: `#/source/${escapeHashPart(s.source_name)}/${escapeHashPart(s.table_name)}` }, `${s.source_name}.${s.table_name}`)
+ el("a", { href: routeWithFacets(`#/source/${escapeHashPart(s.source_name)}/${escapeHashPart(s.table_name)}`) }, `${s.source_name}.${s.table_name}`)
+ );
+ const modelId = m.name;
+
+ // --- Neighborhood mini-graph (MODEL PAGE) ---
+ state.modelMini = state.modelMini || { mode: "both", depth: 2 };
+
+ const miniGraphHost = el("div", { class: "miniGraphHost" });
+ let miniCtl = null;
+
+ const depthPill = el("span", { class: "pill" }, `Depth ${state.modelMini.depth}`);
+ const depthSlider = el("input", {
+ type: "range", min: "1", max: "6",
+ value: String(state.modelMini.depth),
+ oninput: (e) => {
+ state.modelMini.depth = Number(e.target.value || 2);
+ depthPill.textContent = `Depth ${state.modelMini.depth}`;
+ rerenderMini();
+ }
+ });
+
+ const miniModeTabs = el("div", { class: "tabs" },
+ el("button", { class: `tab ${state.modelMini.mode==="up"?"active":""}`, onclick:()=>{ state.modelMini.mode="up"; syncMiniTabs(); rerenderMini(); } }, "Upstream"),
+ el("button", { class: `tab ${state.modelMini.mode==="down"?"active":""}`, onclick:()=>{ state.modelMini.mode="down"; syncMiniTabs(); rerenderMini(); } }, "Downstream"),
+ el("button", { class: `tab ${state.modelMini.mode==="both"?"active":""}`, onclick:()=>{ state.modelMini.mode="both"; syncMiniTabs(); rerenderMini(); } }, "Both"),
+ );
+
+ function syncMiniTabs() {
+ const btns = miniModeTabs.querySelectorAll(".tab");
+ btns.forEach(b => b.classList.remove("active"));
+ const idx = state.modelMini.mode === "up" ? 0 : state.modelMini.mode === "down" ? 1 : 2;
+ btns[idx]?.classList.add("active");
+ }
+
+ function rerenderMini() {
+ miniGraphHost.textContent = "";
+ const centerNode = (state.manifest.dag?.graph?.nodes || [])
+ .find(n => n.kind === "model" && n.name === m.name);
+
+ const centerId = centerNode?.id || `m:${m.name}`;
+
+ const g = buildNeighborhoodGraph(state.manifest.dag.graph, centerId, state.modelMini);
+ miniCtl = mountGraph(state, miniGraphHost, g, { showMini: false, nodeClick: "navigate" });
+ miniCtl?.fit?.();
+ }
+
+ const miniPanel = el("div", { class: "card" },
+ el("div", { class: "dagHeader" },
+ el("div", { class: "dagHeaderLeft" },
+ el("div", { class: "dagTitleRow" }, el("h3", {}, "Neighborhood")),
+ el("p", { class: "dagSubtle" }, "Drag to pan • wheel to zoom • click nodes to open")
+ ),
+ el("div", { class: "dagHeaderRight" },
+ el("div", { class: "dagToolsRow" },
+ miniModeTabs,
+ el("div", { class: "dagDepth" }, depthPill, depthSlider),
+ el("button", { class: "btnTiny", onclick: () => miniCtl?.fit?.() }, "Fit"),
+ )
+ )
+ ),
+ miniGraphHost
);
+ queueMicrotask(rerenderMini);
+
return el("div", { class: "grid" },
el("div", { class: "card" },
el("h3", {}, "Summary"),
@@ -270,6 +735,7 @@ function renderModelPanel(state, m, tab, colFromRoute) {
el("div", { class: "k" }, "Sources"), el("div", {}, sourcesUsed.length ? joinInline(sourcesUsed) : el("span", { class: "empty" }, "—")),
)
),
+ miniPanel,
m.description_html
? el("div", { class: "card" }, el("h3", {}, "Description"), el("div", { class: "desc", html: m.description_html }))
: el("div", { class: "card" }, el("h3", {}, "Description"), el("p", { class: "empty" }, "No description."))
@@ -277,55 +743,7 @@ function renderModelPanel(state, m, tab, colFromRoute) {
}
if (tab === "columns") {
- const cols = m.columns || [];
-
- const card = cols.length
- ? el("div", { class: "card" },
- el("h3", {}, `Columns (${cols.length})`),
- el("table", { class: "table" },
- el("thead", {}, el("tr", {},
- el("th", {}, "Name"),
- el("th", {}, "Type"),
- el("th", {}, "Nullable"),
- el("th", {}, "Description"),
- )),
- el("tbody", {},
- ...cols.map(c => el(
- "tr",
- { id: `col-${cssSafeId(m.name)}-${cssSafeId(c.name)}` },
- el("td", {}, el("code", {}, c.name)),
- el("td", {}, el("code", {}, c.dtype || "")),
- el("td", {}, c.nullable ? "true" : "false"),
- el("td", { html: c.description_html || '—' }),
- ))
- )
- )
- )
- : el("div", { class: "card" },
- el("h3", {}, "Columns"),
- el("p", { class: "empty" }, state.manifest.project?.with_schema ? "No columns found." : "Schema collection disabled.")
- );
-
- // Scroll + highlight if col query param is present
- const colName = (colFromRoute || "").trim();
- if (cols.length && colName) {
- queueMicrotask(() => {
- const rowId = `col-${cssSafeId(m.name)}-${cssSafeId(colName)}`;
- const row = document.getElementById(rowId);
- if (!row) return;
-
- // clear previous hit
- document.querySelectorAll("tr.colHit").forEach(n => n.classList.remove("colHit"));
-
- row.classList.add("colHit");
- row.scrollIntoView({ block: "center", behavior: "smooth" });
-
- // remove highlight after a moment (optional)
- setTimeout(() => row.classList.remove("colHit"), 2200);
- });
- }
-
- return card;
+ return buildColumnsCard(state, m, colFromRoute);
}
if (tab === "lineage") {
@@ -376,146 +794,1112 @@ function renderModelPanel(state, m, tab, colFromRoute) {
);
}
- return el("div", { class: "card" }, el("p", { class: "empty" }, "Unknown tab."));
-}
+ return el("div", { class: "card" }, el("p", { class: "empty" }, "Unknown tab."));
+}
+
+function cssSafeId(s) {
+ return String(s || "").replace(/[^a-zA-Z0-9_-]+/g, "_");
+}
+
+function renderSource(state, sourceName, tableName) {
+ const key = `${sourceName}.${tableName}`;
+ const s = state.bySource.get(key);
+
+ if (!s) {
+ return el("div", { class: "card" }, el("h2", {}, "Source not found"), el("p", { class: "empty" }, key));
+ }
+
+ const consumers = (s.consumers || []).map(m => el("a", { href: routeWithFacets(`#/model/${escapeHashPart(m)}`) }, m));
+
+ const freshness = (() => {
+ const warn = s.warn_after_minutes != null ? `${s.warn_after_minutes}m warn` : null;
+ const err = s.error_after_minutes != null ? `${s.error_after_minutes}m error` : null;
+ const parts = [warn, err].filter(Boolean);
+ return parts.length ? parts.join(" • ") : "—";
+ })();
+
+ return el("div", { class: "grid" },
+ el("div", { class: "card" },
+ el("div", { class: "grid2" },
+ el("div", {}, el("h2", {}, key)),
+ el("div", {},
+ el("button", { class: "btn", onclick: () => { location.hash = "#/"; } }, "← Overview")
+ )
+ ),
+ el("div", { class: "kv" },
+ el("div", { class: "k" }, "Relation"), el("div", {}, el("code", {}, s.relation || "—")),
+ el("div", { class: "k" }, "Loaded at field"), el("div", {}, el("code", {}, s.loaded_at_field || "—")),
+ el("div", { class: "k" }, "Freshness"), el("div", {}, freshness),
+ el("div", { class: "k" }, "Consumers"), el("div", {}, consumers.length ? joinInline(consumers) : el("span", { class: "empty" }, "—")),
+ )
+ ),
+ s.description_html
+ ? el("div", { class: "card" }, el("h2", {}, "Description"), el("div", { class: "desc", html: s.description_html }))
+ : null
+ );
+}
+
+function renderMacros(state) {
+ const ms = state.manifest.macros || [];
+ return el("div", { class: "card" },
+ el("h2", {}, "Macros"),
+ ms.length
+ ? el("table", { class: "table" },
+ el("thead", {}, el("tr", {},
+ el("th", {}, "Name"),
+ el("th", {}, "Kind"),
+ el("th", {}, "Path"),
+ )),
+ el("tbody", {},
+ ...ms.map(m => el("tr", {},
+ el("td", {}, el("code", {}, m.name)),
+ el("td", {}, m.kind),
+ el("td", {}, el("code", {}, m.path)),
+ ))
+ )
+ )
+ : el("p", { class: "empty" }, "No macros discovered.")
+ );
+}
+
+function joinInline(nodes) {
+ const wrap = el("span", {});
+ nodes.forEach((n, i) => {
+ if (i) wrap.appendChild(document.createTextNode(", "));
+ wrap.appendChild(n);
+ });
+ return wrap;
+}
+
+function renderLineage(items) {
+ if (!items || !items.length) return el("span", { class: "empty" }, "—");
+ // items are already normalized by docs.py lineage logic:
+ // { from_relation, from_column, transformed }
+ const ul = el("ul", { style: "margin:0; padding-left:16px;" });
+ for (const it of items) {
+ const label = `${it.from_relation}.${it.from_column}` + (it.transformed ? " (xform)" : "");
+ ul.appendChild(el("li", {}, el("code", {}, label)));
+ }
+ return ul;
+}
+
+function toastOnce({ key, title, body, actionLabel, onAction }) {
+ try {
+ if (localStorage.getItem(key) === "1") return;
+ localStorage.setItem(key, "1");
+ } catch {}
+
+ const node = el("div", { class: "toast" },
+ el("div", {},
+ el("div", { class: "toastTitle" }, title),
+ el("div", { class: "toastBody" }, body)
+ ),
+ el("div", { class: "toastActions" },
+ actionLabel ? el("button", { class: "toastBtn", onclick: () => { try { onAction?.(); } finally { node.remove(); } } }, actionLabel) : null,
+ el("button", { class: "toastBtn", onclick: () => node.remove() }, "Got it")
+ )
+ );
+
+ document.body.appendChild(node);
+ setTimeout(() => { try { node.remove(); } catch {} }, 5500);
+}
+
+function renderTabs(active, onPick) {
+ const tabs = [
+ ["overview", "Overview"],
+ ["columns", "Columns"],
+ ["lineage", "Lineage"],
+ ["code", "Code"],
+ ["meta", "Meta"],
+ ];
+
+ return el("div", { class: "tabs" },
+ ...tabs.map(([id, label]) =>
+ el("button", {
+ class: `tab ${active === id ? "active" : ""}`,
+ onclick: () => onPick(id),
+ }, label)
+ )
+ );
+}
+
+function makeSnippet(text, query, maxLen = 90) {
+ const t = (text || "").replace(/\s+/g, " ").trim();
+ if (!t) return "";
+
+ const q = (query || "").trim().toLowerCase();
+ if (!q) return t.length > maxLen ? t.slice(0, maxLen - 1) + "…" : t;
+
+ const idx = t.toLowerCase().indexOf(q);
+ if (idx < 0) return t.length > maxLen ? t.slice(0, maxLen - 1) + "…" : t;
+
+ const start = Math.max(0, idx - Math.floor(maxLen * 0.35));
+ const end = Math.min(t.length, start + maxLen);
+
+ const prefix = start > 0 ? "…" : "";
+ const suffix = end < t.length ? "…" : "";
+ return prefix + t.slice(start, end) + suffix;
+}
+
+function snippet(text, maxLen = 70) {
+ const t = (text || "").replace(/\s+/g, " ").trim();
+ if (!t) return "";
+ return t.length > maxLen ? t.slice(0, maxLen - 1) + "…" : t;
+}
+
+function buildColumnsCard(state, m, colFromRoute) {
+ const cols = m.columns || [];
+ const withSchema = !!state.manifest.project?.with_schema;
+
+ // UI state (persisted in memory per model; easy to persist later if you want)
+ state.colUI ||= {};
+ const uiState = (state.colUI[m.name] ||= {
+ q: "",
+ sortKey: "name", // name | dtype | nullable | documented
+ sortDir: "asc", // asc | desc
+ undocOnly: false,
+ lineageOnly: false,
+ expanded: new Set(),
+ });
+
+ const card = el("div", { class: "card" });
+ const tools = el("div", { class: "colTools" });
+
+ const qInput = el("input", {
+ class: "input",
+ type: "search",
+ placeholder: "Filter columns…",
+ value: uiState.q || "",
+ oninput: (e) => {
+ uiState.q = e.target.value || "";
+ renderBody(); // updates tbody only
+ },
+ });
+
+ const undocBtn = el("button", {
+ class: "btn",
+ onclick: () => {
+ uiState.undocOnly = !uiState.undocOnly;
+ undocBtn.textContent = uiState.undocOnly ? "Showing undocumented" : "Undocumented only";
+ renderBody();
+ }
+ }, uiState.undocOnly ? "Showing undocumented" : "Undocumented only");
+
+ const lineageOnlyBtn = el("button", {
+ class: "btn",
+ onclick: () => {
+ uiState.lineageOnly = !uiState.lineageOnly;
+ lineageOnlyBtn.textContent = uiState.lineageOnly ? "Showing lineage-only" : "Lineage only";
+ renderBody();
+ }
+ }, uiState.lineageOnly ? "Showing lineage-only" : "Lineage only");
+
+ const expandAllBtn = el("button", {
+ class: "btn",
+ onclick: () => {
+ // Expand all rows currently visible (after filters)
+ const visible = getVisibleRows();
+ uiState.expanded = new Set(visible.map(c => c.name));
+ renderBody();
+ queueMicrotask(() => qInput.focus());
+ }
+ }, "Expand all");
+
+ const collapseAllBtn = el("button", {
+ class: "btn",
+ onclick: () => {
+ uiState.expanded.clear();
+ renderBody();
+ queueMicrotask(() => qInput.focus());
+ }
+ }, "Collapse all");
+
+ const countNode = el("span", { class: "colCount" }, "");
+
+ tools.append(
+ qInput,
+ undocBtn,
+ lineageOnlyBtn,
+ expandAllBtn,
+ collapseAllBtn,
+ countNode
+ );
+
+ const table = el("table", { class: "table" });
+ const thead = el("thead");
+ const tbody = el("tbody");
+ table.append(thead, tbody);
+
+ function sortArrow(key) {
+ if (uiState.sortKey !== key) return "";
+ return uiState.sortDir === "asc" ? "▲" : "▼";
+ }
+
+ function setSort(key) {
+ if (uiState.sortKey === key) uiState.sortDir = (uiState.sortDir === "asc" ? "desc" : "asc");
+ else { uiState.sortKey = key; uiState.sortDir = "asc"; }
+ renderBody();
+ renderHead();
+ }
+
+ function renderHead() {
+ thead.replaceChildren(
+ el("tr", {},
+ el("th", {},
+ el("button", { class: "thBtn", onclick: () => setSort("name") }, "Name", el("span", { class: "sortArrow" }, sortArrow("name")))
+ ),
+ el("th", {},
+ el("button", { class: "thBtn", onclick: () => setSort("dtype") }, "Type", el("span", { class: "sortArrow" }, sortArrow("dtype")))
+ ),
+ el("th", {},
+ el("button", { class: "thBtn", onclick: () => setSort("nullable") }, "Null", el("span", { class: "sortArrow" }, sortArrow("nullable")))
+ ),
+ el("th", {},
+ el("button", { class: "thBtn", onclick: () => setSort("documented") }, "Docs", el("span", { class: "sortArrow" }, sortArrow("documented")))
+ ),
+ el("th", {}, "Description")
+ )
+ );
+ }
+
+ function isDocumented(c) {
+ const txt = (c.description_text || "").trim();
+ const html = (c.description_html || "").trim();
+ return !!(txt || html);
+ }
+
+ function lineageCount(c) {
+ return (c.lineage || []).length;
+ }
+
+ function renderDrawer(c) {
+ const descHtml = (c.description_html && c.description_html.trim())
+ ? c.description_html
+ : 'No description.';
+
+ const lin = c.lineage || [];
+ const linNode = lin.length
+ ? renderLineage(lin)
+ : el("span", { class: "empty" }, "No lineage available.");
+
+ const copyName = el("button", {
+ class: "btnTiny",
+ onclick: async (e) => {
+ e.stopPropagation();
+ await copyText(`${m.name}.${c.name}`);
+ }
+ }, "Copy name");
+
+ const copyRelation = el("button", {
+ class: "btnTiny",
+ onclick: async (e) => {
+ e.stopPropagation();
+ await copyText(`${m.relation || ""}`.trim());
+ }
+ }, "Copy relation");
+
+ const copyDtype = el("button", {
+ class: "btnTiny",
+ onclick: async (e) => {
+ e.stopPropagation();
+ await copyText(c.dtype || "");
+ }
+ }, "Copy dtype");
+
+ const copyLineageJSON = el("button", {
+ class: "btnTiny",
+ onclick: async (e) => {
+ e.stopPropagation();
+ await copyText(JSON.stringify(lin || [], null, 2));
+ }
+ }, "Copy lineage JSON");
+
+ const copyLineageCSV = el("button", {
+ class: "btnTiny",
+ onclick: async (e) => {
+ e.stopPropagation();
+ const rows = (lin || []).map(x =>
+ [x.from_relation ?? "", x.from_column ?? "", x.transformed ? "1" : "0"].join(",")
+ );
+ await copyText(["from_relation,from_column,transformed", ...rows].join("\n"));
+ }
+ }, "Copy lineage CSV");
+
+ return el("div", { class: "drawer" },
+ el("div", { class: "colTools" },
+ el("span", { class: "pillSmall" }, "COLUMN"),
+ el("code", {}, `${m.name}.${c.name}`),
+ el("span", { class: "colCount" }, lin.length ? `${lin.length} lineage refs` : "")
+ ),
+ el("div", { class: "drawerTools" },
+ copyName,
+ copyRelation,
+ copyDtype,
+ copyLineageJSON,
+ copyLineageCSV
+ ),
+ el("div", { class: "drawerGrid" },
+ el("div", { class: "drawerBox" },
+ el("div", { class: "drawerTitle" }, "Description"),
+ el("div", { class: "desc", html: descHtml })
+ ),
+ el("div", { class: "drawerBox" },
+ el("div", { class: "drawerTitle" }, "Lineage"),
+ linNode
+ )
+ )
+ );
+ }
+
+ function isDocumented(c) {
+ const txt = (c.description_text || "").trim();
+ const html = (c.description_html || "").trim();
+ return !!(txt || html);
+ }
+
+ function lineageCount(c) {
+ return (c.lineage || []).length;
+ }
+
+ function getVisibleRows() {
+ if (!withSchema || !cols.length) return [];
+ const q = (uiState.q || "").trim().toLowerCase();
+
+ let rows = cols.slice();
+
+ if (q) {
+ rows = rows.filter(c => {
+ const name = (c.name || "").toLowerCase();
+ const dtype = (c.dtype || "").toLowerCase();
+ const dtxt = (c.description_text || "").toLowerCase();
+ return name.includes(q) || dtype.includes(q) || dtxt.includes(q);
+ });
+ }
+
+ if (uiState.undocOnly) rows = rows.filter(c => !isDocumented(c));
+ if (uiState.lineageOnly) rows = rows.filter(c => lineageCount(c) > 0);
+
+ // Respect sort settings so "expand all" expands the visible ordering
+ const dir = uiState.sortDir === "asc" ? 1 : -1;
+ rows.sort((a, b) => {
+ if (uiState.sortKey === "name") return dir * String(a.name).localeCompare(String(b.name));
+ if (uiState.sortKey === "dtype") return dir * String(a.dtype || "").localeCompare(String(b.dtype || ""));
+ if (uiState.sortKey === "nullable") return dir * ((a.nullable === b.nullable) ? 0 : (a.nullable ? 1 : -1));
+ if (uiState.sortKey === "documented") {
+ const da = isDocumented(a) ? 1 : 0;
+ const db = isDocumented(b) ? 1 : 0;
+ return dir * (da - db);
+ }
+ return 0;
+ });
+
+ return rows;
+ }
+
+ function renderBody() {
+ if (!withSchema) {
+ tbody.replaceChildren(
+ el("tr", {}, el("td", { colspan: "5" }, "Schema collection disabled."))
+ );
+ countNode.textContent = "";
+ return;
+ }
+
+ if (!cols.length) {
+ tbody.replaceChildren(
+ el("tr", {}, el("td", { colspan: "5" }, "No columns found."))
+ );
+ countNode.textContent = "";
+ return;
+ }
+
+ const q = (uiState.q || "").trim().toLowerCase();
+ let rows = cols.slice();
+
+ // filter
+ if (q) {
+ rows = rows.filter(c => {
+ const name = (c.name || "").toLowerCase();
+ const dtype = (c.dtype || "").toLowerCase();
+ const dtxt = (c.description_text || "").toLowerCase();
+ return name.includes(q) || dtype.includes(q) || dtxt.includes(q);
+ });
+ }
+
+ // undocumented-only toggle
+ if (uiState.undocOnly) rows = rows.filter(c => !isDocumented(c));
+
+ if (uiState.lineageOnly) rows = rows.filter(c => lineageCount(c) > 0);
+
+ const undocCount = cols.filter(c => !isDocumented(c)).length;
+ const linCount = cols.filter(c => lineageCount(c) > 0).length;
+ countNode.textContent = `Showing ${rows.length}/${cols.length} • Undocumented: ${undocCount} • With lineage: ${linCount}`;
+
+ // sort
+ const dir = uiState.sortDir === "asc" ? 1 : -1;
+ rows.sort((a, b) => {
+ if (uiState.sortKey === "name") return dir * String(a.name).localeCompare(String(b.name));
+ if (uiState.sortKey === "dtype") return dir * String(a.dtype || "").localeCompare(String(b.dtype || ""));
+ if (uiState.sortKey === "nullable") return dir * ((a.nullable === b.nullable) ? 0 : (a.nullable ? 1 : -1));
+ if (uiState.sortKey === "documented") {
+ const da = isDocumented(a) ? 1 : 0;
+ const db = isDocumented(b) ? 1 : 0;
+ return dir * (da - db);
+ }
+ return 0;
+ });
+
+ // build tbody
+ const out = [];
+ for (const c of rows) {
+ const documented = isDocumented(c);
+ const linN = lineageCount(c);
+
+ const rowId = `col-${cssSafeId(m.name)}-${cssSafeId(c.name)}`;
+
+ const tr = el("tr", {
+ id: rowId,
+ class: `colRow ${documented ? "" : "undoc"}`,
+ onclick: () => {
+ if (uiState.expanded.has(c.name)) uiState.expanded.delete(c.name);
+ else uiState.expanded.add(c.name);
+ renderBody(); // re-render tbody only
+ // keep focus in filter input
+ queueMicrotask(() => qInput.focus());
+ }
+ },
+ el("td", {}, el("code", {}, c.name)),
+ el("td", {}, el("code", {}, c.dtype || "")),
+ el("td", {},
+ c.nullable
+ ? el("span", { class: "pillSmall pillBad" }, "NULL")
+ : el("span", { class: "pillSmall pillGood" }, "NOT NULL")
+ ),
+ el("td", {},
+ documented
+ ? el("span", { class: "pillSmall pillGood" }, "DOCS")
+ : el("span", { class: "pillSmall pillBad" }, "MISSING")
+ ),
+ el("td", {},
+ // short preview + lineage count
+ (c.description_text && c.description_text.trim())
+ ? el("span", {}, snippet(c.description_text, 70), linN ? ` • ${linN} lineage` : "")
+ : el("span", { class: "empty" }, "—", linN ? ` • ${linN} lineage` : "")
+ ),
+ );
+
+ out.push(tr);
+
+ if (uiState.expanded.has(c.name)) {
+ out.push(
+ el("tr", { class: "drawerRow" },
+ el("td", { colspan: "5" }, renderDrawer(c))
+ )
+ );
+ }
+ }
+
+ tbody.replaceChildren(...out);
+
+ // Column deep-link: highlight + scroll + auto-expand
+ const colName = (colFromRoute || "").trim();
+ if (colName) {
+ // ensure expanded
+ uiState.expanded.add(colName);
+
+ queueMicrotask(() => {
+ const rowId = `col-${cssSafeId(m.name)}-${cssSafeId(colName)}`;
+ const row = document.getElementById(rowId);
+ if (!row) return;
+
+ document.querySelectorAll("tr.colHit").forEach(n => n.classList.remove("colHit"));
+ row.classList.add("colHit");
+ row.scrollIntoView({ block: "center", behavior: "smooth" });
+ setTimeout(() => row.classList.remove("colHit"), 2200);
+ });
+ }
+ }
+
+ // initial render
+ card.append(el("h3", {}, `Columns (${cols.length})`), tools, table);
+ renderHead();
+ renderBody();
+
+ return card;
+}
+
+function normalizeMermaidKey(s) {
+ s = (s || "").trim();
+ if (!s) return "";
+ // common prefixes some generators use
+ const prefixes = ["model:", "model__", "model_", "m__", "m_", "source:", "source__", "src__", "src_"];
+ for (const p of prefixes) {
+ if (s.startsWith(p)) return s.slice(p.length);
+ }
+ return s;
+}
+
+function extractMermaidNodeLabel(g) {
+ // Mermaid typically renders