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 with a element and multiple s. + const text = g.querySelector("text"); + if (!text) return ""; + let out = ""; + const tspans = text.querySelectorAll("tspan"); + if (tspans && tspans.length) { + for (const t of tspans) out += (t.textContent || "") + " "; + } else { + out = text.textContent || ""; + } + return out.replace(/\s+/g, " ").trim(); +} + +function svgEl(tag, attrs = {}, ...children) { + const n = document.createElementNS("http://www.w3.org/2000/svg", tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (k === "class") n.setAttribute("class", v); + else n.setAttribute(k, String(v)); + } + for (const c of children) { + if (c == null) continue; + n.appendChild(typeof c === "string" ? document.createTextNode(c) : c); + } + return n; +} + +function pathForEdgeLR(a, b) { + const x1 = a.x + a.w; + const y1 = a.y + a.h / 2; + const x2 = b.x; + const y2 = b.y + b.h / 2; + const dx = Math.max(40, (x2 - x1) * 0.5); + return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`; +} + +function pathForEdgeTB(a, b) { + const x1 = a.x + a.w / 2; + const y1 = a.y + a.h; + const x2 = b.x + b.w / 2; + const y2 = b.y; + const dy = Math.max(40, (y2 - y1) * 0.5); + return `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`; +} + +function mountGraph(state, host, graph, opts = {}) { + const { miniHost = null, showMini = true, nodeClick = "pin" } = opts; + + host.textContent = ""; + if (!graph || !graph.nodes || !graph.nodes.length) { + host.appendChild(el("p", { class: "empty" }, "No DAG data available.")); + return; + } + + const dir = (graph.direction || "LR").toUpperCase(); + const nodes = graph.nodes || []; + const edges = graph.edges || []; + const byId = new Map(nodes.map(n => [n.id, n])); + + // SVG skeleton + const svg = svgEl("svg", { class: "dagSvg", tabindex: "0" }); + const defs = svgEl("defs", {}, + svgEl("marker", { + id: "arrow", + markerWidth: "10", + markerHeight: "10", + refX: "9", + refY: "3", + orient: "auto", + markerUnits: "strokeWidth" + }, svgEl("path", { d: "M0,0 L10,3 L0,6 Z", class: "dagArrow" })) + ); + svg.appendChild(defs); + + const viewport = svgEl("g", { class: "dagViewport" }); + svg.appendChild(viewport); + + const edgeLayer = svgEl("g", { class: "dagEdges" }); + const nodeLayer = svgEl("g", { class: "dagNodes" }); + viewport.append(edgeLayer, nodeLayer); + + // adjacency + element maps live INSIDE mountGraph + const outAdj = new Map(); // id -> [{to, edgeEl}] + const inAdj = new Map(); // id -> [{from, edgeEl}] + const edgeEls = []; // [{from,to,el}] + const nodeEls = new Map(); // id -> + + function pushAdj(map, key, val) { + if (!map.has(key)) map.set(key, []); + map.get(key).push(val); + } + + // Edges + for (const e of edges) { + const a = byId.get(e.from); + const b = byId.get(e.to); + if (!a || !b) continue; + + const d = (dir === "TB") ? pathForEdgeTB(a, b) : pathForEdgeLR(a, b); + const p = svgEl("path", { + d, + class: `dagEdge ${e.kind || ""}`, + "data-from": e.from, + "data-to": e.to + }); + edgeLayer.appendChild(p); + + edgeEls.push({ from: e.from, to: e.to, el: p }); + pushAdj(outAdj, e.from, { to: e.to, el: p }); + pushAdj(inAdj, e.to, { from: e.from, el: p }); + } + + function setHover(id, on) { + const sel = (q) => Array.from(svg.querySelectorAll(q)); + const nodes = sel(`g.dagNode[data-id="${CSS.escape(id)}"]`); + for (const n of nodes) n.classList.toggle("hover", on); + + const connected = sel(`path.dagEdge[data-from="${CSS.escape(id)}"], path.dagEdge[data-to="${CSS.escape(id)}"]`); + for (const p of connected) p.classList.toggle("hover", on); + } + + function bfsSet(startId, mode, depth) { + const nodesSet = new Set([startId]); + const edgesSet = new Set(); + + let frontier = new Set([startId]); + + for (let d = 0; d < depth; d++) { + const next = new Set(); + + for (const id of frontier) { + if (mode === "down" || mode === "both") { + for (const e of (outAdj.get(id) || [])) { + nodesSet.add(e.to); + edgesSet.add(e.el); + next.add(e.to); + } + } + if (mode === "up" || mode === "both") { + for (const e of (inAdj.get(id) || [])) { + nodesSet.add(e.from); + edgesSet.add(e.el); + next.add(e.from); + } + } + } + + frontier = next; + if (!frontier.size) break; + } + + return { nodesSet, edgesSet }; + } + + function applyHighlight() { + const pinned = state.graphUI?.pinned || ""; + const mode = state.graphUI?.mode || "off"; + const depth = Number(state.graphUI?.depth || 0); + + // reset + for (const [id, g] of nodeEls) { + g.classList.toggle("selected", id === pinned); + g.classList.remove("dim", "hl"); + } + for (const e of edgeEls) e.el.classList.remove("dim", "hl"); + + if (!pinned || mode === "off" || depth <= 0) return; + + const { nodesSet, edgesSet } = bfsSet(pinned, mode, depth); + + for (const [id, g] of nodeEls) { + const on = nodesSet.has(id); + g.classList.toggle("hl", on); + g.classList.toggle("dim", !on); + } + for (const e of edgeEls) { + const on = edgesSet.has(e.el); + e.el.classList.toggle("hl", on); + e.el.classList.toggle("dim", !on); + } + } + + function setPinned(id) { + state.graphUI.pinned = id || ""; + applyHighlight(); + } + + function togglePinned(id) { + setPinned(state.graphUI.pinned === id ? "" : id); + } + + // Nodes + for (const n of nodes) { + const isModel = n.kind === "model"; + const g = svgEl("g", { + class: `dagNode ${n.kind} ${isModel ? (n.type || "sql") : "source"}`, + transform: `translate(${n.x} ${n.y})`, + tabindex: "0", + role: "link", + "data-id": n.id + }); + + const rect = svgEl("rect", { + width: n.w, + height: n.h, + rx: 14, + ry: 14, + class: "dagRect" + }); + + const title = isModel ? (n.name || "") : `${n.source_name}.${n.table_name}`; + const subtitle = n.relation || ""; + + const t1 = svgEl("text", { x: 12, y: 20, class: "dagTitle" }, title); + const t2 = svgEl("text", { x: 12, y: 38, class: "dagSub" }, subtitle); + + // badges (right side) + const badges = []; + if (isModel) { + const b1 = svgEl("text", { x: n.w - 12, y: 20, class: "dagBadge", "text-anchor": "end" }, (n.type || "sql")); + badges.push(b1); + if (n.materialized) { + const b2 = svgEl("text", { x: n.w - 12, y: 38, class: "dagBadge2", "text-anchor": "end" }, n.materialized); + badges.push(b2); + } + } else { + const b1 = svgEl("text", { x: n.w - 12, y: 20, class: "dagBadge", "text-anchor": "end" }, "source"); + badges.push(b1); + } + + g.append(rect, t1); + if (subtitle) g.appendChild(t2); + for (const b of badges) g.appendChild(b); + + const route = n.route || ""; + const go = (ev) => { + if (!route) return; + try { ev?.preventDefault?.(); ev?.stopPropagation?.(); } catch {} + // route contains raw parts; encode at the last moment + if (route.startsWith("#/model/")) { + const nm = route.slice("#/model/".length); + location.hash = routeWithFacets(`#/model/${escapeHashPart(nm)}`); + } else if (route.startsWith("#/source/")) { + const rest = route.slice("#/source/".length).split("/"); + const s = rest[0] || ""; + const t = rest[1] || ""; + location.hash = routeWithFacets(`#/source/${escapeHashPart(s)}/${escapeHashPart(t)}`); + } else { + location.hash = routeWithFacets(route); + } + }; + + g.addEventListener("click", (ev) => { + if (nodeClick === "navigate") return go(ev); + + // Ctrl/Cmd click keeps the old "navigate" behavior + if (ev.ctrlKey || ev.metaKey) return go(ev); + + ev.preventDefault(); + ev.stopPropagation(); + + // toggle pinned selection + togglePinned(n.id); + }); + + // optional: double click navigates too + g.addEventListener("dblclick", (ev) => go(ev)); + + g.addEventListener("keydown", (ev) => { + if (ev.key === "Enter" || ev.key === " ") go(ev); + }); + + // hover highlight + g.addEventListener("mouseenter", () => setHover(n.id, true)); + g.addEventListener("mouseleave", () => setHover(n.id, false)); + + nodeLayer.appendChild(g); + + nodeEls.set(n.id, g); + } + + // --- pan/zoom (package-free) ----------------------------------------- + let scale = 1; + let tx = 0; + let ty = 0; + let panning = false; + let panStart = null; + + let miniSvg = null; + let miniView = null; + + function updateMini() { + if (!miniSvg || !miniView) return; + + const r = svg.getBoundingClientRect(); + const vx = (0 - tx) / scale; + const vy = (0 - ty) / scale; + const vw = r.width / scale; + const vh = r.height / scale; + + miniView.setAttribute("x", String(vx)); + miniView.setAttribute("y", String(vy)); + miniView.setAttribute("width", String(vw)); + miniView.setAttribute("height", String(vh)); + } -function cssSafeId(s) { - return String(s || "").replace(/[^a-zA-Z0-9_-]+/g, "_"); -} + function apply() { + viewport.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`); + updateMini(); + } -function renderSource(state, sourceName, tableName) { - const key = `${sourceName}.${tableName}`; - const s = state.bySource.get(key); + function fit() { + const r = host.getBoundingClientRect(); + const b = graph.bounds || {}; + const gw = (b.width || 1000); + const gh = (b.height || 600); - if (!s) { - return el("div", { class: "card" }, el("h2", {}, "Source not found"), el("p", { class: "empty" }, key)); + const pad = 24; + const sx = (r.width - pad * 2) / gw; + const sy = (r.height - pad * 2) / gh; + scale = Math.max(0.1, Math.min(2.5, Math.min(sx, sy))); + + tx = pad; + ty = pad; + apply(); } - const consumers = (s.consumers || []).map(m => el("a", { href: `#/model/${escapeHashPart(m)}` }, m)); + function reset() { + scale = 1; tx = 0; ty = 0; apply(); + } - 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(" • ") : "—"; - })(); + function zoomBy(factor, cx, cy) { + const rect = svg.getBoundingClientRect(); + const px = cx - rect.left; + const py = cy - rect.top; - return el("div", { class: "grid" }, - el("div", { class: "card" }, - el("h2", {}, key), - 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 - ); -} + const wx = (px - tx) / scale; + const wy = (py - ty) / scale; -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.") - ); -} + const next = Math.max(0.1, Math.min(3.0, scale * factor)); + scale = next; -function joinInline(nodes) { - const wrap = el("span", {}); - nodes.forEach((n, i) => { - if (i) wrap.appendChild(document.createTextNode(", ")); - wrap.appendChild(n); + tx = px - wx * scale; + ty = py - wy * scale; + apply(); + } + + svg.addEventListener("wheel", (ev) => { + ev.preventDefault(); + const factor = ev.deltaY < 0 ? 1.08 : 1 / 1.08; + zoomBy(factor, ev.clientX, ev.clientY); + }, { passive: false }); + + svg.addEventListener("pointerdown", (ev) => { + // don't pan when clicking a node + if (ev.target && ev.target.closest && ev.target.closest("g.dagNode")) return; + panning = true; + panStart = { x: ev.clientX, y: ev.clientY, tx, ty }; + svg.setPointerCapture(ev.pointerId); }); - 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; -} + svg.addEventListener("pointermove", (ev) => { + if (!panning || !panStart) return; + tx = panStart.tx + (ev.clientX - panStart.x); + ty = panStart.ty + (ev.clientY - panStart.y); + apply(); + }); -function toastOnce({ key, title, body, actionLabel, onAction }) { - try { - if (localStorage.getItem(key) === "1") return; - localStorage.setItem(key, "1"); - } catch {} + svg.addEventListener("pointerup", (ev) => { + panning = false; + panStart = null; + try { svg.releasePointerCapture(ev.pointerId); } 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") - ) - ); + // initial mount + host.appendChild(svg); + queueMicrotask(() => fit()); - document.body.appendChild(node); - setTimeout(() => { try { node.remove(); } catch {} }, 5500); -} + if (showMini && miniHost) { + miniHost.textContent = ""; + miniSvg = svgEl("svg", { class: "miniSvg" }); + // miniSvg.setAttribute("preserveAspectRatio", "none"); -function renderTabs(active, onPick) { - const tabs = [ - ["overview", "Overview"], - ["columns", "Columns"], - ["lineage", "Lineage"], - ["code", "Code"], - ["meta", "Meta"], - ]; + // viewBox = graph bounds + const b = graph.bounds || {}; + miniSvg.setAttribute("viewBox", `${b.minx || 0} ${b.miny || 0} ${b.width || 1000} ${b.height || 600}`); - return el("div", { class: "tabs" }, - ...tabs.map(([id, label]) => - el("button", { - class: `tab ${active === id ? "active" : ""}`, - onclick: () => onPick(id), - }, label) - ) - ); -} + const miniEdges = svgEl("g"); + const miniNodes = svgEl("g"); + miniSvg.append(miniEdges, miniNodes); -function makeSnippet(text, query, maxLen = 90) { - const t = (text || "").replace(/\s+/g, " ").trim(); - if (!t) return ""; + for (const e of edgeEls) { + const a = byId.get(e.from), c = byId.get(e.to); + if (!a || !c) continue; + const d = (dir === "TB") ? pathForEdgeTB(a, c) : pathForEdgeLR(a, c); + miniEdges.appendChild(svgEl("path", { d, class: "miniEdge" })); + } - const q = (query || "").trim().toLowerCase(); - if (!q) return t.length > maxLen ? t.slice(0, maxLen - 1) + "…" : t; + for (const n of nodes) { + miniNodes.appendChild(svgEl("rect", { + x: n.x, y: n.y, width: n.w, height: n.h, rx: 6, ry: 6, + class: "miniNode" + })); + } - const idx = t.toLowerCase().indexOf(q); - if (idx < 0) return t.length > maxLen ? t.slice(0, maxLen - 1) + "…" : t; + miniView = svgEl("rect", { class: "miniView", x: 0, y: 0, width: 10, height: 10, rx: 4, ry: 4 }); + miniView.style.cursor = "grab"; + miniSvg.appendChild(miniView); + + // ensure it is visible + hittable + miniView.setAttribute("fill", "#000"); + miniView.setAttribute("fill-opacity", "0.12"); + miniView.setAttribute("stroke", "#000"); + miniView.setAttribute("stroke-opacity", "0.45"); + miniView.setAttribute("stroke-width", "1"); + + // critical: make sure it can receive pointer events even if CSS disables it + miniView.setAttribute("pointer-events", "all"); + miniView.style.pointerEvents = "all"; + + // helpful on touch devices + miniSvg.style.touchAction = "none"; + + function miniClientToGraph(ev) { + const rect = miniSvg.getBoundingClientRect(); + const px = (ev.clientX - rect.left) / rect.width; + const py = (ev.clientY - rect.top) / rect.height; + const vb = miniSvg.viewBox.baseVal; + return { x: vb.x + px * vb.width, y: vb.y + py * vb.height }; + } + const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); - const start = Math.max(0, idx - Math.floor(maxLen * 0.35)); - const end = Math.min(t.length, start + maxLen); + let miniDrag = null; + let miniSuppressClick = false; - const prefix = start > 0 ? "…" : ""; - const suffix = end < t.length ? "…" : ""; - return prefix + t.slice(start, end) + suffix; + function miniGetViewRect() { + const x = parseFloat(miniView.getAttribute("x")) || 0; + const y = parseFloat(miniView.getAttribute("y")) || 0; + const w = parseFloat(miniView.getAttribute("width")) || 0; + const h = parseFloat(miniView.getAttribute("height")) || 0; + return { x, y, w, h }; + } + + miniSvg.addEventListener("pointerdown", (ev) => { + if (ev.button != null && ev.button !== 0) return; + + const p0 = miniClientToGraph(ev); + const r = miniGetViewRect(); + + // Only start dragging if you pressed INSIDE the viewport rectangle + const inside = + p0.x >= r.x && p0.x <= r.x + r.w && + p0.y >= r.y && p0.y <= r.y + r.h; + + if (!inside) return; + + ev.preventDefault(); + ev.stopPropagation(); + + miniSvg.setPointerCapture(ev.pointerId); + miniView.style.cursor = "grabbing"; + + miniDrag = { + id: ev.pointerId, + p0, + vx0: (-tx) / scale, + vy0: (-ty) / scale, + moved: false, + }; + }); + + miniSvg.addEventListener("pointermove", (ev) => { + if (!miniDrag || ev.pointerId !== miniDrag.id) return; + ev.preventDefault(); + + const p = miniClientToGraph(ev); + const dx = p.x - miniDrag.p0.x; + const dy = p.y - miniDrag.p0.y; + + // visible size (graph coords) + const sr = svg.getBoundingClientRect(); + const vw = sr.width / scale; + const vh = sr.height / scale; + + const vb = miniSvg.viewBox.baseVal; + const boundX = vb.x + vb.width - vw; + const boundY = vb.y + vb.height - vh; + + const vx = clamp(miniDrag.vx0 + dx, Math.min(vb.x, boundX), Math.max(vb.x, boundX)); + const vy = clamp(miniDrag.vy0 + dy, Math.min(vb.y, boundY), Math.max(vb.y, boundY)); + + tx = -vx * scale; + ty = -vy * scale; + + if (Math.abs(dx) + Math.abs(dy) > 0.5) miniDrag.moved = true; + apply(); + }); + + function endMiniDrag(ev) { + if (!miniDrag || ev.pointerId !== miniDrag.id) return; + ev.preventDefault(); + + miniSuppressClick = miniDrag.moved; + miniDrag = null; + + try { miniSvg.releasePointerCapture(ev.pointerId); } catch {} + miniView.style.cursor = "grab"; + } + + miniSvg.addEventListener("pointerup", endMiniDrag); + miniSvg.addEventListener("pointercancel", endMiniDrag); + + miniSvg.addEventListener("click", (ev) => { + if (miniSuppressClick) { miniSuppressClick = false; return; } + const rect = miniSvg.getBoundingClientRect(); + const px = (ev.clientX - rect.left) / rect.width; + const py = (ev.clientY - rect.top) / rect.height; + + const vb = miniSvg.viewBox.baseVal; + const gx = vb.x + px * vb.width; + const gy = vb.y + py * vb.height; + + // center clicked point + const sr = svg.getBoundingClientRect(); + tx = sr.width / 2 - gx * scale; + ty = sr.height / 2 - gy * scale; + apply(); + }); + + miniHost.appendChild(miniSvg); + updateMini(); + } + + function centerOn(id, zoom = 1.25) { + const n = byId.get(id); + if (!n) return; + + const sr = svg.getBoundingClientRect(); + const cx = n.x + n.w / 2; + const cy = n.y + n.h / 2; + + scale = Math.max(0.1, Math.min(3.0, zoom)); + tx = sr.width / 2 - cx * scale; + ty = sr.height / 2 - cy * scale; + apply(); + } + + return { + fit, reset, + zoomIn: () => zoomBy(1.12, svg.getBoundingClientRect().left + 10, svg.getBoundingClientRect().top + 10), + zoomOut: () => zoomBy(1 / 1.12, svg.getBoundingClientRect().left + 10, svg.getBoundingClientRect().top + 10), + svg, + focus: (id, { zoom = 1.25, pin = true } = {}) => { centerOn(id, zoom); if (pin) setPinned(id); }, + refresh: () => applyHighlight(), + setPinned, + }; +} + +async function copyText(text) { + try { await navigator.clipboard.writeText(String(text ?? "")); return true; } + catch { return false; } } async function loadManifest() { @@ -528,15 +1912,20 @@ async function main() { const app = document.getElementById("app"); app.textContent = "Loading…"; - const [manifest, mermaid] = await Promise.all([loadManifest(), initMermaid()]); + const manifest = await loadManifest(); const state = { manifest, - mermaid, filter: "", byModel: byName(manifest.models || [], (m) => m.name), bySource: byName(manifest.sources || [], (s) => `${s.source_name}.${s.table_name}`), }; state.sidebarMatches = { models: 0, sources: 0 }; + state.graphUI = { + mode: "both", // "up" | "down" | "both" | "off" + depth: 2, + pinned: "", // node id like "m:orders" + }; + state.graphUI.dir = state.manifest.dag.graph.direction || "LR"; const ui = { app: document.getElementById("app"), @@ -569,6 +1958,8 @@ async function main() { STORE.modelTab = `fft_docs:${projKey}:model_tab_default`; state.modelTabDefault = safeGet(STORE.modelTab) || "overview"; state.STORE = STORE; + // Allow replaceHashQuery() (global) to keep lastHash in sync even when we use history.replaceState. + window.__fftLastHashKey = STORE.lastHash; // Persisted UI state state.filter = safeGet(STORE.filter) ?? ""; @@ -584,6 +1975,9 @@ async function main() { location.hash = last; } + // Initialize model facets from URL (shareable filters) + state.modelFacets = currentModelFacets(); + toastOnce({ key: `fft_docs_search_toast_seen:${projKey}`, title: "Quick search", @@ -615,6 +2009,7 @@ async function main() { subtitle: m.relation || (m.path || ""), route: `#/model/${escapeHashPart(m.name)}`, haystack: baseHay, + graphId: `m:${m.name}`, }); // Columns as their own results (so you can jump directly) @@ -643,6 +2038,7 @@ async function main() { subtitle: `${m.relation || ""}${c.dtype ? " • " + c.dtype : ""}`, route: `#/model/${escapeHashPart(m.name)}?tab=columns&col=${escapeHashPart(c.name)}`, haystack: colHay, + graphId: `m:${m.name}`, // focus model node }); } } @@ -667,6 +2063,7 @@ async function main() { subtitle: s.relation || "", route: `#/source/${escapeHashPart(s.source_name)}/${escapeHashPart(s.table_name)}`, haystack: hay, + graphId: `s:${s.source_name}.${s.table_name}`, }); } @@ -705,7 +2102,33 @@ async function main() { const sel = Math.max(0, Math.min(state.search.selected || 0, results.length - 1)); const q = (state.search.query || "").trim(); - const sub = (() => { + // const sub = (() => { + // if (r.kind === "column") { + // const parts = [ + // "COLUMN", + // r.model || "", + // r.relation ? `• ${r.relation}` : "", + // r.dtype ? `• ${r.dtype}` : "", + // ].filter(Boolean).join(" "); + // const snip = makeSnippet(r.descText || "", q, 90); + // return snip ? `${parts} • ${snip}` : parts; + // } + // if (r.kind === "model") { + // const snip = makeSnippet((r.descText || ""), q, 90); + // return snip ? `MODEL • ${r.subtitle || ""} • ${snip}` : `MODEL • ${r.subtitle || ""}`; + // } + // if (r.kind === "source") { + // const snip = makeSnippet((r.descText || ""), q, 90); + // return snip ? `SOURCE • ${r.subtitle || ""} • ${snip}` : `SOURCE • ${r.subtitle || ""}`; + // } + // return `${(r.kind || "").toUpperCase()} • ${r.subtitle || ""}`; + // })(); + + // const right = r.kind === "column" && r.dtype + // ? el("span", { class: "pill" }, r.dtype) + // : el("div", { class: "kbd" }, "↵"); + + const subFor = (r) => { if (r.kind === "column") { const parts = [ "COLUMN", @@ -725,11 +2148,12 @@ async function main() { return snip ? `SOURCE • ${r.subtitle || ""} • ${snip}` : `SOURCE • ${r.subtitle || ""}`; } return `${(r.kind || "").toUpperCase()} • ${r.subtitle || ""}`; - })(); + }; - const right = r.kind === "column" && r.dtype - ? el("span", { class: "pill" }, r.dtype) - : el("div", { class: "kbd" }, "↵"); + const rightFor = (r) => + (r.kind === "column" && r.dtype) + ? el("span", { class: "pill" }, r.dtype) + : el("div", { class: "kbd" }, "↵"); state.ui.paletteList.replaceChildren( ...(results.length @@ -738,14 +2162,14 @@ async function main() { class: `result ${idx === sel ? "sel" : ""}`, onclick: () => { closePalette(); - location.hash = r.route; + location.hash = routeWithFacets(r.route); }, }, el("div", { class: "resultMain" }, el("div", { class: "resultTitle" }, r.title), - el("div", { class: "resultSub" }, sub) + el("div", { class: "resultSub" }, subFor(r)) ), - right + rightFor(r) ) ) : [el("div", { class: "result" }, @@ -807,10 +2231,30 @@ async function main() { const results = state.search.results || []; const idx = Math.max(0, Math.min(state.search.selected || 0, results.length - 1)); const hit = results[idx]; - if (hit) { + if (!hit) return; + + // Ctrl/Cmd+Enter => focus in DAG + if (e.ctrlKey || e.metaKey) { closePalette(); - location.hash = hit.route; + + const gid = hit.graphId; + if (!gid) return; + + // If not on home, go home with focus param + const r = parseRoute(); + if (r.route !== "home") { + location.hash = routeWithFacets(`#/?focus=${encodeURIComponent(gid)}`); + return; + } + + // Already on home: focus immediately + state._graphCtl?.focus?.(gid, { zoom: 1.25, pin: true }); + return; } + + // Normal Enter => navigate + closePalette(); + location.hash = routeWithFacets(hit.route); } } }); @@ -872,6 +2316,18 @@ async function main() { ui.sidebar = { root: null, input: null, + + // facet UI (near sidebar search) + facetBox: null, + kindSqlBtn: null, + kindPyBtn: null, + matSelect: null, + pathInput: null, + pathDatalist: null, + clearFacetsBtn: null, + brandLink: null, + overviewLink: null, + modelsTitle: null, sourcesTitle: null, modelsList: null, @@ -919,64 +2375,192 @@ async function main() { const q = (state.filter || "").trim(); const total = (state.sidebarMatches.models || 0) + (state.sidebarMatches.sources || 0); - // Empty input => Enter opens global palette if (!q) { e.preventDefault(); openPalette(""); return; } - // No sidebar matches => Enter escalates to global palette (prefilled) if (total === 0) { e.preventDefault(); openPalette(q); return; } - - // Otherwise: normal behavior (do nothing special) }, }); - ui.sidebar.modelsTitle = el("div"); - ui.sidebar.sourcesTitle = el("div"); - ui.sidebar.macrosTitle = el("div"); + // --- Facets live next to the sidebar search (not under Models) --- + const applyFacetsToUrl = () => { + replaceHashQuery((q) => writeModelFacetsToQuery(q, state.modelFacets)); + // keep state in sync (routeWithFacets reads from URL) + state.modelFacets = currentModelFacets(); + }; - ui.sidebar.modelsList = el("ul", { class: "list" }); - ui.sidebar.sourcesList = el("ul", { class: "list" }); - ui.sidebar.macrosList = el("ul", { class: "list" }); + const debouncedPath = debounce((val) => { + state.modelFacets.pathPrefix = (val || "").trim(); + applyFacetsToUrl(); + updateSidebarLists(); + }, 180); - ui.sidebar.modelsSection = el("div", { class: "section" }, ui.sidebar.modelsTitle, ui.sidebar.modelsList); - ui.sidebar.sourcesSection = el("div", { class: "section" }, ui.sidebar.sourcesTitle, ui.sidebar.sourcesList); - ui.sidebar.macrosSection = el("div", { class: "section" }, ui.sidebar.macrosTitle, ui.sidebar.macrosList); + ui.sidebar.kindSqlBtn = el("button", { + class: "facetChip", + type: "button", + onclick: () => { + const kinds = new Set(state.modelFacets.kinds || ["sql", "python"]); + if (kinds.has("sql")) kinds.delete("sql"); else kinds.add("sql"); + if (kinds.size === 0) { kinds.add("sql"); kinds.add("python"); } // avoid empty selection + state.modelFacets.kinds = Array.from(kinds); - ui.sidebar.root = el( - "div", - { class: "sidebar" }, - el( - "div", - { class: "brand" }, - el("h1", {}, state.manifest.project?.name || "Docs"), - el("span", { class: "badge", title: `Generated: ${state.manifest.project?.generated_at || ""}` }, "SPA") - ), - el( - "div", - { class: "searchWrap" }, - ui.sidebar.input, - el("span", { class: "searchKbd kbd" }, "/") - ), - el("div", { class: "searchTip" }, "Tip: Press / (or Ctrl+K) to search everything (models, sources, columns)."), - ui.sidebar.modelsSection, - ui.sidebar.sourcesSection, - ui.sidebar.macrosSection, + applyFacetsToUrl(); + updateSidebarLists(); + } + }, "SQL"); + + ui.sidebar.kindPyBtn = el("button", { + class: "facetChip", + type: "button", + onclick: () => { + const kinds = new Set(state.modelFacets.kinds || ["sql", "python"]); + if (kinds.has("python")) kinds.delete("python"); else kinds.add("python"); + if (kinds.size === 0) { kinds.add("sql"); kinds.add("python"); } + state.modelFacets.kinds = Array.from(kinds); + + applyFacetsToUrl(); + updateSidebarLists(); + } + }, "Python"); + + ui.sidebar.matSelect = el("select", { + class: "facetSelect", + onchange: (e) => { + state.modelFacets.materialized = normalizeMaterialized(e.target.value || ""); + applyFacetsToUrl(); + updateSidebarLists(); + } + }); + + ui.sidebar.pathDatalist = el("datalist", { id: "modelPathPrefixes" }); + ui.sidebar.pathInput = el("input", { + class: "facetInput", + type: "search", + placeholder: "Path prefix…", + list: "modelPathPrefixes", + value: state.modelFacets.pathPrefix || "", + oninput: (e) => debouncedPath(e.target.value || ""), + onkeydown: (e) => { + if (e.key !== "Enter") return; + debouncedPath.cancel(); + state.modelFacets.pathPrefix = (e.target.value || "").trim(); + applyFacetsToUrl(); + updateSidebarLists(); + } + }); + + ui.sidebar.clearFacetsBtn = el("button", { + class: "facetClear", + type: "button", + onclick: () => { + debouncedPath.cancel(); + state.modelFacets = { kinds: ["sql", "python"], materialized: "", pathPrefix: "" }; + ui.sidebar.pathInput.value = ""; + applyFacetsToUrl(); + updateSidebarLists(); + } + }, "Clear"); + + ui.sidebar.facetBox = el("div", { class: "facetBox" }, + el("div", { class: "facetRow" }, + el("div", { class: "facetChips" }, ui.sidebar.kindSqlBtn, ui.sidebar.kindPyBtn), + ui.sidebar.matSelect + ), + el("div", { class: "facetRow" }, + ui.sidebar.pathInput, + ui.sidebar.clearFacetsBtn + ), + ui.sidebar.pathDatalist + ); + + const overviewSection = el("div", { class: "section" }, + el("div", {}, + (ui.sidebar.overviewLink = el("a", { + href: routeWithFacets("#/"), + onclick: (e) => { e.preventDefault(); location.hash = routeWithFacets("#/"); }, + class: "itemLink", + style: "display:flex; align-items:center; justify-content:space-between; padding:8px 10px; border:1px solid var(--border); border-radius:12px; text-decoration:none; color:inherit;" + }, + el("span", {}, "Overview (DAG)"), + el("span", { class: "pill" }, "Home") + )) + ) + ); + + ui.sidebar.modelsTitle = el("div"); + ui.sidebar.sourcesTitle = el("div"); + ui.sidebar.macrosTitle = el("div"); + + ui.sidebar.modelsList = el("ul", { class: "list" }); + ui.sidebar.sourcesList = el("ul", { class: "list" }); + ui.sidebar.macrosList = el("ul", { class: "list" }); + + ui.sidebar.modelsSection = el("div", { class: "section" }, ui.sidebar.modelsTitle, ui.sidebar.modelsList); + ui.sidebar.sourcesSection = el("div", { class: "section" }, ui.sidebar.sourcesTitle, ui.sidebar.sourcesList); + ui.sidebar.macrosSection = el("div", { class: "section" }, ui.sidebar.macrosTitle, ui.sidebar.macrosList); + + ui.sidebar.projectTitle = el("div"); + const statRow = (k, v) => + el("div", { class: "kvRow" }, + el("span", { class: "k" }, k), + el("span", { class: "v" }, v) ); - ui.sidebarHost.replaceChildren(ui.sidebar.root); + ui.sidebar.projectBody = el("div", { class: "kvRows" }, + statRow("Models", String((state.manifest.models || []).length)), + statRow("Sources", String((state.manifest.sources || []).length)), + statRow("Macros", String((state.manifest.macros || []).length)), + statRow("Schema", state.manifest.project?.with_schema ? "enabled" : "disabled"), + statRow("Generated", state.manifest.project?.generated_at || "—"), + ); + + ui.sidebar.projectSection = el("div", { class: "section" }, + ui.sidebar.projectTitle, + ui.sidebar.projectBody + ); + + ui.sidebar.root = el( + "div", + { class: "sidebar" }, + el( + "div", + { class: "brand" }, + (ui.sidebar.brandLink = el("a", { + href: routeWithFacets("#/"), + style: "color:inherit; text-decoration:none;", + onclick: (e) => { e.preventDefault(); location.hash = routeWithFacets("#/"); } + }, el("h1", {}, state.manifest.project?.name || "Docs"))), + el("span", { class: "badge", title: `Generated: ${state.manifest.project?.generated_at || ""}` }, "SPA") + ), + el( + "div", + { class: "searchWrap" }, + ui.sidebar.input, + el("span", { class: "searchKbd kbd" }, "/") + ), + ui.sidebar.facetBox, + el("div", { class: "searchTip" }, "Tip: Press / (or Ctrl+K) to search everything (models, sources, columns)."), + overviewSection, + ui.sidebar.projectSection, + ui.sidebar.modelsSection, + ui.sidebar.sourcesSection, + ui.sidebar.macrosSection, + ); - // Turn titles into toggle headers - sectionHeader(ui.sidebar.modelsTitle, "models", "Models"); - sectionHeader(ui.sidebar.sourcesTitle, "sources", "Sources"); - sectionHeader(ui.sidebar.macrosTitle, "macros", "Macros"); + ui.sidebarHost.replaceChildren(ui.sidebar.root); + // Turn titles into toggle headers + sectionHeader(ui.sidebar.modelsTitle, "models", "Models"); + sectionHeader(ui.sidebar.sourcesTitle, "sources", "Sources"); + sectionHeader(ui.sidebar.macrosTitle, "macros", "Macros"); + sectionHeader(ui.sidebar.projectTitle, "project", "Project"); } function applySidebarCollapse() { @@ -984,14 +2568,18 @@ async function main() { ui.sidebar.modelsList.style.display = c.models ? "none" : ""; ui.sidebar.sourcesList.style.display = c.sources ? "none" : ""; ui.sidebar.macrosList.style.display = c.macros ? "none" : ""; + ui.sidebar.projectBody.style.display = c.project ? "none" : ""; } function updateSidebarLists() { + // Keep facets in sync with URL in case of back/forward or manual edits + state.modelFacets = currentModelFacets(); + const q = (state.filter || "").trim().toLowerCase(); const models = state.manifest.models || []; const sources = state.manifest.sources || []; - const filteredModels = q + const modelsAfterText = q ? models.filter(m => (m.name || "").toLowerCase().includes(q) || (m.relation || "").toLowerCase().includes(q) || @@ -999,16 +2587,99 @@ async function main() { ) : models; - const filteredSources = q + const sourcesAfterText = q ? sources.filter(s => (`${s.source_name}.${s.table_name}`).toLowerCase().includes(q) || (s.relation || "").toLowerCase().includes(q) ) : sources; + // Apply model facets (kinds/materialized/pathPrefix) on top of text filtering + const filteredModels = filterModelsWithFacets(modelsAfterText, state.modelFacets); + const filteredSources = sourcesAfterText; + state.sidebarMatches.models = filteredModels.length; state.sidebarMatches.sources = filteredSources.length; + // ---- Facet UI: counts + selected state ---- + const facets = state.modelFacets || { kinds: ["sql", "python"], materialized: "", pathPrefix: "" }; + + // counts for kind based on other facets (materialized + pathPrefix) + const forKindCounts = filterModelsWithFacets(modelsAfterText, { ...facets, kinds: ["sql", "python"] }); + let sqlCount = 0, pyCount = 0; + for (const m of forKindCounts) { + if (normalizeModelKind(m.kind) === "python") pyCount++; else sqlCount++; + } + + // counts for materialized based on other facets (kind + pathPrefix) + const forMatCounts = filterModelsWithFacets(modelsAfterText, { ...facets, materialized: "" }); + const matCounts = new Map(); + let unknownCount = 0; + for (const m of forMatCounts) { + const mm = normalizeMaterialized(m.materialized || ""); + if (!mm) unknownCount++; + else matCounts.set(mm, (matCounts.get(mm) || 0) + 1); + } + const matsSorted = Array.from(matCounts.entries()).sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0])); + + // path prefix suggestions based on other facets (kind + materialized) + const forPathCounts = filterModelsWithFacets(modelsAfterText, { ...facets, pathPrefix: "" }); + const prefixCounts = new Map(); + for (const m of forPathCounts) { + const p = stripModelsPrefix(m.path || ""); + if (!p) continue; + const parts = p.split("/").filter(Boolean); + + // suggest first segment and first two segments + for (const depth of [1, 2]) { + if (parts.length >= depth) { + const pref = parts.slice(0, depth).join("/") + "/"; + prefixCounts.set(pref, (prefixCounts.get(pref) || 0) + 1); + } + } + + // also include full directory if available (without file name) + if (parts.length > 1) { + const fullDir = parts.slice(0, parts.length - 1).join("/") + "/"; + prefixCounts.set(fullDir, (prefixCounts.get(fullDir) || 0) + 1); + } + } + const prefixesSorted = Array.from(prefixCounts.entries()) + .sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0])) + .slice(0, 40); + + const kindsSet = new Set((facets.kinds || ["sql", "python"]).map(k => (k || "").toLowerCase())); + ui.sidebar.kindSqlBtn.textContent = `SQL (${sqlCount})`; + ui.sidebar.kindPyBtn.textContent = `Python (${pyCount})`; + ui.sidebar.kindSqlBtn.classList.toggle("active", kindsSet.has("sql")); + ui.sidebar.kindPyBtn.classList.toggle("active", kindsSet.has("python")); + + // Update materialized select options (keep selection) + const currentMat = normalizeMaterialized(facets.materialized || ""); + ui.sidebar.matSelect.replaceChildren( + el("option", { value: "" }, `Any materialization (${forMatCounts.length})`), + ...(unknownCount ? [el("option", { value: MAT_UNKNOWN }, `(unknown) (${unknownCount})`)] : []), + ...matsSorted.map(([mm, n]) => el("option", { value: mm }, `${mm} (${n})`)) + ); + ui.sidebar.matSelect.value = currentMat; + + // Update datalist suggestions and keep input in sync if URL changed + ui.sidebar.pathDatalist.replaceChildren( + ...prefixesSorted.map(([p, n]) => el("option", { value: p }, `${p} (${n})`)) + ); + if ((ui.sidebar.pathInput.value || "") !== (facets.pathPrefix || "")) { + ui.sidebar.pathInput.value = facets.pathPrefix || ""; + } + + const activeN = facetsActiveCount(facets); + ui.sidebar.clearFacetsBtn.textContent = activeN ? `Clear (${activeN})` : "Clear"; + ui.sidebar.clearFacetsBtn.disabled = activeN === 0; + + // Keep "home" hrefs up-to-date for copy/open-in-new-tab + if (ui.sidebar.brandLink) ui.sidebar.brandLink.href = routeWithFacets("#/"); + if (ui.sidebar.overviewLink) ui.sidebar.overviewLink.href = routeWithFacets("#/"); + + // ---- Sidebar lists ---- ui.sidebar.modelsTitle.textContent = `Models (${filteredModels.length})`; ui.sidebar.sourcesTitle.textContent = `Sources (${filteredSources.length})`; @@ -1016,9 +2687,9 @@ async function main() { ...filteredModels.map(m => el("li", { class: "item" }, el("a", { - href: `#/model/${escapeHashPart(m.name)}`, - onclick: (e) => { e.preventDefault(); location.hash = `#/model/${escapeHashPart(m.name)}`; }, - title: m.description_short || m.name, + href: routeWithFacets(`#/model/${escapeHashPart(m.name)}`), + onclick: (e) => { e.preventDefault(); location.hash = routeWithFacets(`#/model/${escapeHashPart(m.name)}`); }, + title: [m.description_short || "", m.path ? `(${m.path})` : ""].filter(Boolean).join(" ") || m.name, }, el("span", {}, m.name), pillForKind(m.kind === "python" ? "python" : "sql") @@ -1030,10 +2701,11 @@ async function main() { ui.sidebar.sourcesList.replaceChildren( ...filteredSources.map(s => { const key = `${s.source_name}.${s.table_name}`; + const href = routeWithFacets(`#/source/${escapeHashPart(s.source_name)}/${escapeHashPart(s.table_name)}`); return el("li", { class: "item" }, el("a", { - href: `#/source/${escapeHashPart(s.source_name)}/${escapeHashPart(s.table_name)}`, - onclick: (e) => { e.preventDefault(); location.hash = `#/source/${escapeHashPart(s.source_name)}/${escapeHashPart(s.table_name)}`; }, + href, + onclick: (e) => { e.preventDefault(); location.hash = href; }, title: s.relation || key, }, el("span", {}, key), @@ -1044,17 +2716,13 @@ async function main() { ); const macros = state.manifest.macros || []; - - sectionHeader(ui.sidebar.modelsTitle, "models", `Models (${filteredModels.length})`); - sectionHeader(ui.sidebar.sourcesTitle, "sources", `Sources (${filteredSources.length})`); - sectionHeader(ui.sidebar.macrosTitle, "macros", `Macros (${macros.length})`); - + ui.sidebar.macrosTitle.textContent = `Macros (${macros.length})`; ui.sidebar.macrosList.replaceChildren( ...macros.map(m => el("li", { class: "item" }, el("a", { - href: "#/macros", - onclick: (e) => { e.preventDefault(); location.hash = "#/macros"; }, + href: routeWithFacets("#/macros"), + onclick: (e) => { e.preventDefault(); location.hash = routeWithFacets("#/macros"); }, title: m.path || m.name, }, el("span", {}, m.name), @@ -1063,9 +2731,13 @@ async function main() { ) ) ); - - applySidebarCollapse(); + // re-attach section headers with live counts + sectionHeader(ui.sidebar.modelsTitle, "models", `Models (${filteredModels.length})`); + sectionHeader(ui.sidebar.sourcesTitle, "sources", `Sources (${filteredSources.length})`); + sectionHeader(ui.sidebar.macrosTitle, "macros", `Macros (${macros.length})`); + + applySidebarCollapse(); } function updateMain() { @@ -1111,7 +2783,8 @@ async function main() { window.addEventListener("hashchange", () => { safeSet(STORE.lastHash, location.hash || "#/"); - closePalette(); // optional: close palette on navigation + closePalette(); + updateSidebarLists(); // facets live in URL query params updateMain(); });