Add CLAUDE.md defining the Palladium master notebook conventions and Red Panda Approval criteria, plus a review-notebook slash command for LLM-driven notebook review. Expand .gitignore to block client/engagement documents and generated exports, keeping masters client-clean while allowing text/image sources. Normalize slider widget numeric values from floats to integers in notebook JSON.
209 lines
9.7 KiB
Python
209 lines
9.7 KiB
Python
"""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"<b>{title}</b>"
|
||
if subtitle:
|
||
t += f"<br><span style='font-size:12px;color:{MUTED}'>{subtitle}</span>"
|
||
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}<extra></extra>",
|
||
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)}"
|
||
"<extra></extra>"),
|
||
))
|
||
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"<b>{html_money(vas.trapped_value_low)} – "
|
||
f"{html_money(vas.trapped_value_high)}</b> 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}<extra>%{y}</extra>"),
|
||
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}<extra>%{y}</extra>"),
|
||
])
|
||
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"<b>{idx} · {what}</b><br>"
|
||
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]}<br>Investment: %{customdata[1]}<extra></extra>",
|
||
))
|
||
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]}<br>Unlocked: %{customdata[1]}<extra></extra>",
|
||
))
|
||
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<br>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
|