"""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