"""Plotly figure builders for the sync_pilot dashboard. All chart styling lives here so pages stay declarative. Plotly only (no matplotlib, no Altair). We use ``plotly.graph_objects.Figure`` for full control over chrome — anything the brief mandates (no axis titles, threshold lines, fixed [0,1] x-range on tag bars) is enforced once in the helpers below. """ from __future__ import annotations from collections import Counter from typing import Any, Iterable import plotly.graph_objects as go from sync_pilot.dashboard.styles import ( CATEGORY_COLORS, CLAP_COLOR, MAEST_COLOR, MUQ_COLOR, MUQ_REVIEW_COLOR, PASST_COLOR, STEM_COLOR, TAXONOMY_ADAPTER_COLOR, THEME_COLOR, ) _MAEST_DISPLAY_MIN_SCORE = 0.15 _CLAP_DISPLAY_TOP_K = 3 _CLAP_MOOD_DISPLAY_TOP_K = 2 _SOURCE_BUCKET_ORDER = [ "maest", "clap-zs", "muq-probe-v1", "muq-probe-expanded-v1", "passt-probe-expanded-v1", "taxonomy-adapter-v1", "muq-probe-review-v1", "lyrics-llm", "stem-window-v1", ] _SOURCE_LABELS = { "maest": "MAEST", "clap-zs": "CLAP", "muq-probe-v1": "MuQ probe", "muq-probe-expanded-v1": "MuQ expanded", "passt-probe-expanded-v1": "PaSST expanded", "taxonomy-adapter-v1": "taxonomy adapter", "muq-probe-review-v1": "review probe", "lyrics-llm": "lyrics LLM", "stem-window-v1": "stem windows", } _SOURCE_COLORS = { "maest": MAEST_COLOR, "clap-zs": CLAP_COLOR, "muq-probe-v1": MUQ_COLOR, "muq-probe-expanded-v1": MUQ_COLOR, "passt-probe-expanded-v1": PASST_COLOR, "taxonomy-adapter-v1": TAXONOMY_ADAPTER_COLOR, "muq-probe-review-v1": MUQ_REVIEW_COLOR, "lyrics-llm": THEME_COLOR, "stem-window-v1": STEM_COLOR, } def _empty_figure(message: str) -> go.Figure: """Placeholder figure for the "no data" case — keeps page layout stable.""" fig = go.Figure() fig.add_annotation( text=message, xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False, font={"color": "#9CA3AF", "size": 13}, ) fig.update_layout( height=220, margin={"l": 10, "r": 10, "t": 10, "b": 10}, plot_bgcolor="white", paper_bgcolor="white", xaxis={"visible": False}, yaxis={"visible": False}, ) return fig def _source_color(source: str) -> str: """Map a tag source to its canonical color.""" bucket = _source_bucket(source) or source return _SOURCE_COLORS.get(bucket, CATEGORY_COLORS["other"]) def _source_bucket(source: str) -> str | None: if source.startswith("clap"): return "clap-zs" if source.startswith("maest"): return "maest" if source.startswith("muq-probe-review"): return "muq-probe-review-v1" if source.startswith("muq-probe-expanded"): return "muq-probe-expanded-v1" if source.startswith("muq-probe"): return "muq-probe-v1" if source.startswith("passt-probe-expanded"): return "passt-probe-expanded-v1" if source.startswith("taxonomy-adapter"): return "taxonomy-adapter-v1" if source.startswith("lyrics"): return "lyrics-llm" if source.startswith("muq"): return "muq-mulan-zs" if source.startswith("stem-window"): return "stem-window-v1" return None def _source_label(source: str) -> str: return _SOURCE_LABELS.get(source, source) def _catalog_display_tags(tags: list[dict[str, Any]], category: str) -> list[dict[str, Any]]: rows = [t for t in tags if t.get("category") == category] filtered: list[dict[str, Any]] = [] for tag in rows: source = str(tag.get("source", "")) score = float(tag.get("score") or 0.0) if category == "genre" and source.startswith("maest") and score < _MAEST_DISPLAY_MIN_SCORE: continue filtered.append(tag) capped: list[dict[str, Any]] = [] clap_rows = [t for t in filtered if str(t.get("source", "")).startswith("clap")] other_rows = [t for t in filtered if not str(t.get("source", "")).startswith("clap")] clap_rows.sort(key=lambda t: float(t.get("score") or 0.0), reverse=True) clap_limit = _CLAP_MOOD_DISPLAY_TOP_K if category == "mood" else _CLAP_DISPLAY_TOP_K capped.extend(clap_rows[:clap_limit]) capped.extend(other_rows) return capped def tag_bar_chart( tags: list[dict[str, Any]], *, title: str, top_k: int = 10, threshold_lines: Iterable[tuple[float, str]] = ( (0.30, "CLAP τ"), (0.15, "MAEST τ"), ), height: int = 280, ) -> go.Figure: """Horizontal bar chart of a single category's tags, source-colored. ``tags`` is a list of ``{name, score, source}`` dicts (already filtered to the category of interest by the caller). Score range is **always** locked to [0, 1] so the eye can compare confidence across small- multiples without re-anchoring. Threshold lines are dashed verticals at the relevant source cutoffs; pass ``threshold_lines=()`` to suppress. """ if not tags: return _empty_figure("no tags above threshold") # Sort by descending score so the most-confident tag sits at the top # of the horizontal bar chart (Plotly's y-axis grows upward). sorted_tags = sorted(tags, key=lambda t: t.get("score", 0.0), reverse=True)[:top_k] sorted_tags = list(reversed(sorted_tags)) # Plotly draws bottom-up names = [t.get("name", "?") for t in sorted_tags] scores = [float(t.get("score", 0.0)) for t in sorted_tags] sources = [t.get("source", "") for t in sorted_tags] evidence = [t.get("evidence", "") for t in sorted_tags] colors = [_source_color(s) for s in sources] # customdata stacks (source, evidence) per bar so the hovertemplate can # surface the LLM-extracted evidence quote on theme tags without # cluttering the genre/mood/instrument/vocal hovers (where evidence is # always empty string and the conditional below skips its line). customdata = [[src, ev] for src, ev in zip(sources, evidence)] has_any_evidence = any(ev for ev in evidence) hovertemplate = ( "%{y}
score %{x:.3f}
source %{customdata[0]}" + ("
evidence: %{customdata[1]}" if has_any_evidence else "") + "" ) fig = go.Figure( go.Bar( x=scores, y=names, orientation="h", marker={"color": colors, "line": {"width": 0}}, text=[f"{s:.2f}" for s in scores], textposition="outside", cliponaxis=False, hovertemplate=hovertemplate, customdata=customdata, ) ) for x, _label in threshold_lines: fig.add_vline( x=x, line_dash="dash", line_color="#9CA3AF", opacity=0.6, line_width=1, ) fig.update_layout( title={ "text": title, "x": 0.0, "xanchor": "left", "font": {"size": 14, "color": "#1F2933"}, }, height=height, margin={"l": 10, "r": 50, "t": 40, "b": 20}, plot_bgcolor="white", paper_bgcolor="white", showlegend=False, font={"size": 12}, xaxis={ "range": [0, 1.05], "showgrid": False, "zeroline": False, "showline": False, "ticks": "", "tickvals": [0.0, 0.25, 0.5, 0.75, 1.0], "tickfont": {"color": "#6B7280", "size": 10}, }, yaxis={ "showgrid": False, "zeroline": False, "showline": False, "ticks": "", }, ) return fig def catalog_top_tags_chart( tracks: list[dict[str, Any]], *, category: str, title: str, top_k: int = 10, height: int = 300, ) -> go.Figure: """Top-K tags across the whole catalog for one category, source-split. Aggregates per-source counts after applying the same display precision policy as the track-level charts, so stale low-score tags do not dominate the catalog view. A grouped horizontal bar with two stacks per term (MAEST + CLAP) would be the strictest reading, but for top-10 readability we instead emit one bar per (term, source) pair so terms with both-source presence appear twice — visually showing the cross-source agreement (or lack thereof) without forcing same-axis alignment of disagreeing terms. """ per_source: dict[str, Counter[str]] = { source: Counter() for source in _SOURCE_BUCKET_ORDER } for tr in tracks: for tag in _catalog_display_tags(tr.get("tags", []) or [], category): src = tag.get("source", "") name = tag.get("name", "") bucket = _source_bucket(str(src)) if bucket and name: if bucket not in per_source: per_source[bucket] = Counter() per_source[bucket][name] += 1 # Build a single combined list of (count, name, source) and keep the # top_k overall. combined: list[tuple[int, str, str]] = [] for src, counter in per_source.items(): for name, count in counter.items(): combined.append((count, name, src)) if not combined: return _empty_figure(f"no {category} tags found") combined.sort(key=lambda r: r[0], reverse=True) combined = combined[:top_k] combined.reverse() # plotly draws bottom-up fig = go.Figure() fig.add_trace( go.Bar( x=[c for c, _, _ in combined], y=[f"{n} · {_source_label(s)}" for _, n, s in combined], orientation="h", marker={"color": [_source_color(s) for _, _, s in combined]}, text=[str(c) for c, _, _ in combined], textposition="outside", cliponaxis=False, hovertemplate="%{y}
%{x} tracks", ) ) fig.update_layout( title={ "text": title, "x": 0.0, "xanchor": "left", "font": {"size": 14, "color": "#1F2933"}, }, height=height, margin={"l": 10, "r": 40, "t": 40, "b": 20}, plot_bgcolor="white", paper_bgcolor="white", showlegend=False, font={"size": 12}, xaxis={ "showgrid": False, "zeroline": False, "showline": False, "ticks": "", "tickfont": {"color": "#6B7280", "size": 10}, }, yaxis={ "showgrid": False, "zeroline": False, "showline": False, "ticks": "", }, ) return fig def language_distribution_chart( distribution: dict[str, int], *, title: str = "Lyrics language distribution", height: int = 220, ) -> go.Figure: """Tiny horizontal bar chart over the transcription summary's language counts.""" if not distribution: return _empty_figure("no language data") items = sorted(distribution.items(), key=lambda kv: kv[1]) fig = go.Figure( go.Bar( x=[c for _, c in items], y=[lang for lang, _ in items], orientation="h", marker={"color": CLAP_COLOR}, text=[str(c) for _, c in items], textposition="outside", cliponaxis=False, ) ) fig.update_layout( title={"text": title, "x": 0.0, "xanchor": "left", "font": {"size": 13}}, height=height, margin={"l": 10, "r": 30, "t": 40, "b": 20}, plot_bgcolor="white", paper_bgcolor="white", showlegend=False, xaxis={ "showgrid": False, "zeroline": False, "showline": False, "ticks": "", "tickfont": {"color": "#6B7280", "size": 10}, }, yaxis={"showgrid": False, "zeroline": False, "showline": False, "ticks": ""}, ) return fig