Files
palladium/assessments/CX_AI_Diagnostic/diaglib/visuals.py
Robert Helewka a967f73d09 feat: add master notebook library scaffolding and review tooling
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.
2026-07-31 16:16:07 +00:00

209 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 15 · 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