"""Plotly figure builders — presentation only, consuming engine outputs. Chart chrome follows the house dataviz rules (see the repo dataviz reference and docs/brand.md): recessive grid and axes, ink text tokens, fixed entity→color assignments so a color means one thing across every figure, 2px surface gaps between adjacent fills, selective direct labels, one axis per chart. Room-facing: sized and typed to hold attention on a shared screen, not for print. """ from __future__ import annotations import plotly.graph_objects as go from .models import DiagnosticConfig, ValueAtStake from .value_math import html_money # ── Chrome (dataviz reference palette, light surface) ──────────────── INK, INK2, MUTED = "#0b0b0b", "#52514e", "#898781" SURFACE, GRID, BASELINE = "#fcfcfb", "#e1e0d9", "#c3c2b7" FONT_STACK = 'system-ui, -apple-system, "Segoe UI", sans-serif' # Fixed entity colors — color follows the entity across every figure. THEORETICAL = "#9ec5f4" # light blue: the outer envelope REALIZABLE = "#2a78d6" # blue: what capability can actually capture TRAPPED = "#eda100" # amber: value the capability gap strands COST = "#e34948" # red: unlock investment UNLOCKED = "#1baf7a" # aqua-green: unlock payoff # Diverging maturity scale, centered on level 3 — soft poles so ink text # stays readable in every cell (two hues + neutral midpoint, never rainbow). SCORE_SCALE = [ (0.0, "#ef8a76"), (0.5, "#f0efe9"), (1.0, "#57c993"), ] def diag_layout(fig: go.Figure, title: str, subtitle: str | None = None, height: int = 440) -> go.Figure: t = f"{title}" if subtitle: t += f"
{subtitle}" fig.update_layout( title=dict(text=t, font=dict(size=16, color=INK), x=0.02, xanchor="left"), paper_bgcolor=SURFACE, plot_bgcolor=SURFACE, font=dict(family=FONT_STACK, size=13, color=INK2), legend=dict(orientation="h", yanchor="top", y=-0.12, x=0, font=dict(size=11, color=INK2)), height=height, margin=dict(t=70, r=30, b=60, l=70), ) return fig # ── 1 · Capability heatmap (4 dimensions × 3 competencies) ─────────── def heatmap_fig(grid: dict) -> go.Figure: """``grid`` comes from scoring.heatmap_grid — pure data in.""" n_rows = len(grid["rows"]) fig = go.Figure(go.Heatmap( z=grid["z"], text=grid["text"], customdata=grid["hover"], x=list(range(len(grid["cols"]))), y=grid["rows"], zmin=1, zmax=5, colorscale=SCORE_SCALE, showscale=False, texttemplate="%{text}", textfont=dict(size=13, color=INK), hovertemplate="%{customdata}", xgap=3, ygap=3, )) fig.update_xaxes(visible=False) fig.update_yaxes(autorange="reversed", tickfont=dict(size=13, color=INK2), showgrid=False) diag_layout(fig, "Capability heatmap", "12 competencies, levels 1–5 · hover a cell for the evidence", height=90 * n_rows + 120) return fig # ── 2 · Value at stake (theoretical vs realizable ranges) ──────────── def value_bands_fig(vas: ValueAtStake) -> go.Figure: rows = [ ("Theoretical value (annual)", vas.theoretical_annual_value_low, vas.theoretical_annual_value_high, THEORETICAL), ("Realizable over 18 months", vas.realizable_18mo_low, vas.realizable_18mo_high, REALIZABLE), ] fig = go.Figure() for label, low, high, color in rows: fig.add_trace(go.Bar( y=[label], x=[max(high - low, 1)], base=[low], orientation="h", marker=dict(color=color, line=dict(width=2, color=SURFACE)), showlegend=False, hovertemplate=(f"{label}: {html_money(low)} – {html_money(high)}" ""), )) fig.add_annotation(x=high, y=label, xanchor="left", xshift=6, text=f"{html_money(low)} – {html_money(high)}", showarrow=False, font=dict(size=12, color=INK)) fig.add_annotation( xref="paper", yref="paper", x=0.02, y=-0.32, xanchor="left", showarrow=False, align="left", text=(f"Trapped by the capability gap: " f"{html_money(vas.trapped_value_low)} – " f"{html_money(vas.trapped_value_high)} per year"), font=dict(size=13, color=INK)) fig.update_xaxes(tickformat="$~s", gridcolor=GRID, zeroline=False, tickfont=dict(color=MUTED), rangemode="tozero") fig.update_yaxes(tickfont=dict(size=13, color=INK2), showgrid=False, autorange="reversed") # theoretical on top, then realizable diag_layout(fig, "Value at stake", "ranges, never points · realization capped by the weakest foundation", height=300) fig.update_layout(margin=dict(b=90), bargap=0.5) return fig # ── 3 · Realizable vs trapped split (scenario-consistent) ──────────── def split_fig(vas: ValueAtStake) -> go.Figure: """Each scenario bar splits its own theoretical total: realizable run-rate vs trapped, at that scenario's realization factor.""" scenarios = [ ("Conservative", vas.theoretical_annual_value_low, vas.realization_factor_low), ("Optimistic", vas.theoretical_annual_value_high, vas.realization_factor_high), ] labels = [s[0] for s in scenarios] realizable = [th * r for _, th, r in scenarios] trapped = [th * (1 - r) for _, th, r in scenarios] fig = go.Figure([ go.Bar(name="Realizable (annual run-rate)", y=labels, x=realizable, orientation="h", marker=dict(color=REALIZABLE, line=dict(width=2, color=SURFACE)), text=[html_money(v) for v in realizable], textposition="inside", insidetextfont=dict(color="#ffffff"), hovertemplate="Realizable: %{x:$,.0f}%{y}"), go.Bar(name="Trapped by capability gap", y=labels, x=trapped, orientation="h", marker=dict(color=TRAPPED, line=dict(width=2, color=SURFACE)), text=[html_money(v) for v in trapped], textposition="inside", insidetextfont=dict(color=INK), hovertemplate="Trapped: %{x:$,.0f}%{y}"), ]) fig.update_layout(barmode="stack", bargap=0.5) fig.update_xaxes(tickformat="$~s", gridcolor=GRID, zeroline=False, tickfont=dict(color=MUTED)) fig.update_yaxes(tickfont=dict(size=13, color=INK2), showgrid=False, autorange="reversed") # conservative on top diag_layout(fig, "Where the annual value goes", "each scenario splits its own theoretical total", height=300) fig.update_layout(legend=dict(traceorder="normal")) return fig # ── 4 · Unlock sequence (cost vs value per move) ───────────────────── def _move_label(vas_move, config: DiagnosticConfig, idx: int) -> str: """Compact tick label — full competency names live in the hover and in the on-stage moves table (long joint-lift names don't fit ticks).""" if len(vas_move.competency_ids) == 1: what = config.competency(vas_move.competency_ids[0]).name else: what = f"joint lift ×{len(vas_move.competency_ids)}" return (f"{idx} · {what}
" f"level {vas_move.current_level} → {vas_move.target_level}") def unlock_fig(vas: ValueAtStake, config: DiagnosticConfig) -> go.Figure: moves = vas.unlock_sequence labels = [_move_label(m, config, i + 1) for i, m in enumerate(moves)] names = [" + ".join(config.competency(c).name for c in m.competency_ids) for m in moves] fig = go.Figure() fig.add_trace(go.Bar( name="Investment (range)", x=labels, y=[(m.est_cost_high - m.est_cost_low) if m.est_cost_low is not None else 0 for m in moves], base=[m.est_cost_low if m.est_cost_low is not None else 0 for m in moves], customdata=[[n, html_money(m.est_cost_low) + " – " + html_money(m.est_cost_high) if m.est_cost_low is not None else "not configured"] for n, m in zip(names, moves)], marker=dict(color=COST, line=dict(width=2, color=SURFACE)), hovertemplate="%{customdata[0]}
Investment: %{customdata[1]}", )) fig.add_trace(go.Bar( name="Annual value unlocked (range)", x=labels, y=[m.value_unlocked_high - m.value_unlocked_low for m in moves], base=[m.value_unlocked_low for m in moves], customdata=[[n, html_money(m.value_unlocked_low) + " – " + html_money(m.value_unlocked_high)] for n, m in zip(names, moves)], marker=dict(color=UNLOCKED, line=dict(width=2, color=SURFACE)), hovertemplate="%{customdata[0]}
Unlocked: %{customdata[1]}", )) for i, m in enumerate(moves): if m.est_cost_low is None: fig.add_annotation(x=labels[i], y=0, yanchor="bottom", text="cost not
configured", showarrow=False, font=dict(size=11, color=MUTED)) fig.update_layout(barmode="group", bargap=0.35, bargroupgap=0.12) fig.update_xaxes(tickfont=dict(size=12, color=INK2), showgrid=False, tickangle=0) fig.update_yaxes(tickformat="$~s", gridcolor=GRID, zerolinecolor=BASELINE, tickfont=dict(color=MUTED)) diag_layout(fig, "Unlock sequence", "sequential moves — each is the prerequisite of the next", height=420) return fig