@llodev/bi-dashboard 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,124 @@
1
+ ### 9.2 ECharts — TypeScript (React, Vue, Angular, or vanilla)
2
+
3
+ The ECharts canvas does not understand `var(--…)` nor, in every operation, `oklch()`. Resolve the tokens to `rgb` first:
4
+
5
+ ```ts
6
+ import type { EChartsOption } from "echarts";
7
+
8
+ /** Converts any CSS color (including var(--x) and oklch) into rgb(a) the canvas can read. */
9
+ export function resolveColor(css: string, el: HTMLElement = document.documentElement): string {
10
+ const probe = document.createElement("span");
11
+ probe.style.color = css;
12
+ el.appendChild(probe);
13
+ const computed = getComputedStyle(probe).color;
14
+ probe.remove();
15
+ const ctx = document.createElement("canvas").getContext("2d")!;
16
+ ctx.fillStyle = computed;
17
+ ctx.fillRect(0, 0, 1, 1);
18
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
19
+ return a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
20
+ }
21
+
22
+ export type Roles = {
23
+ ink: string;
24
+ muted: string;
25
+ context: string;
26
+ highlight: string;
27
+ highlightText: string;
28
+ font: string;
29
+ };
30
+
31
+ export const readRoles = (): Roles => ({
32
+ ink: resolveColor("var(--foreground)"),
33
+ muted: resolveColor("var(--muted-foreground)"),
34
+ context: resolveColor("color-mix(in oklab, var(--muted-foreground) 35%, transparent)"),
35
+ highlight: resolveColor("var(--chart-1)"),
36
+ highlightText: resolveColor("var(--chart-1)"),
37
+ font: getComputedStyle(document.body).fontFamily, // the canvas does not accept "inherit"
38
+ });
39
+
40
+ export function peakBarsOption(
41
+ dates: string[],
42
+ values: number[],
43
+ s: {
44
+ title: string;
45
+ subtitle: string;
46
+ baseline: number;
47
+ baselineLabel: string;
48
+ peakIndex: number;
49
+ peakValue: string;
50
+ peakNote: string;
51
+ },
52
+ c: Roles,
53
+ month: (iso: string) => string,
54
+ money: (v: number) => string,
55
+ ): EChartsOption {
56
+ return {
57
+ animation: false,
58
+ textStyle: { fontFamily: c.font },
59
+ title: {
60
+ left: 0,
61
+ text: s.title,
62
+ subtext: s.subtitle,
63
+ textStyle: { fontSize: 20, fontWeight: 800, color: c.ink },
64
+ subtextStyle: { fontSize: 12, color: c.muted },
65
+ },
66
+ grid: { left: 8, right: 8, top: 88, bottom: 28, containLabel: true },
67
+ tooltip: { trigger: "axis", valueFormatter: (v) => money(Number(v)) },
68
+ xAxis: {
69
+ type: "category",
70
+ data: dates,
71
+ axisLine: { show: false },
72
+ axisTick: { show: false },
73
+ splitLine: { show: false },
74
+ axisLabel: {
75
+ color: c.muted,
76
+ fontSize: 11,
77
+ interval: (_i: number, v: string) => v.endsWith("-01"),
78
+ formatter: month,
79
+ },
80
+ },
81
+ yAxis: { type: "value", show: false },
82
+ series: [
83
+ {
84
+ type: "bar",
85
+ barCategoryGap: "35%",
86
+ data: values.map((v, i) => ({
87
+ value: v,
88
+ itemStyle: { color: i === s.peakIndex ? c.highlight : c.context },
89
+ })),
90
+ markLine: {
91
+ symbol: "none",
92
+ silent: true,
93
+ lineStyle: { type: "dashed", width: 1, color: c.muted },
94
+ label: {
95
+ position: "insideStartTop",
96
+ color: c.muted,
97
+ fontSize: 11,
98
+ formatter: s.baselineLabel,
99
+ },
100
+ data: [{ yAxis: s.baseline }],
101
+ },
102
+ markPoint: {
103
+ symbol: "rect",
104
+ symbolSize: 1,
105
+ itemStyle: { color: "transparent" },
106
+ label: {
107
+ show: true,
108
+ position: "left",
109
+ align: "right",
110
+ formatter: `{v|${s.peakValue}}\n{d|${s.peakNote}}`,
111
+ rich: {
112
+ v: { fontSize: 22, fontWeight: 800, color: c.highlightText },
113
+ d: { fontSize: 12, color: c.highlightText },
114
+ },
115
+ },
116
+ data: [{ name: "peak", coord: [s.peakIndex, values[s.peakIndex]] }],
117
+ },
118
+ },
119
+ ],
120
+ };
121
+ }
122
+ ```
123
+
124
+ Use: `chart.setOption(peakBarsOption(...))`; when the theme changes (the `.dark` class or `prefers-color-scheme`), call `readRoles()` again and reapply the option. With many points, `large: true` and `sampling: "lttb"` (lines).
@@ -0,0 +1,71 @@
1
+ ## 1. Phase 0 — Explore the project
2
+
3
+ Before writing code, find out what already exists. The precedence rule is:
4
+
5
+ **explicit user direction > the project's design system > libraries/patterns already used in the project > this skill's patterns.**
6
+
7
+ ### 1.1 Stack and where the dashboard lives
8
+
9
+ Search and read (when they exist):
10
+
11
+ - **JS/TS**: `package.json` (React, Next.js, Vue, Svelte, Angular; chart libs: `recharts`, `echarts`, `@nivo/*`, `chart.js`, `highcharts`, `@visx/*`, `vega-lite`, `plotly.js`), `components.json` (shadcn/ui), `tsconfig.json` (aliases such as `@/`), the `components/ui/` folder (is there a `chart.tsx`? a `card.tsx`?).
12
+ - **Python**: `pyproject.toml`, `requirements*.txt`, notebooks (`plotly`, `matplotlib`, `seaborn`, `altair`, `streamlit`, `dash`, `panel`), `.streamlit/config.toml`, `matplotlibrc`.
13
+ - **R**: `DESCRIPTION`, `renv.lock`, `*.Rmd`/`*.qmd` (`ggplot2`, `shiny`, `plotly`).
14
+ - **BI**: `.pbix`/`.pbip`/TMDL files (Power BI), exported dashboards from Metabase, Superset, Looker Studio, Grafana (JSON).
15
+ - **Data**: where it comes from (SQL directly, ORM, API, CSV/Parquet, warehouse), SQL dialect, granularity, time zone, currency.
16
+ - **Conventions**: folder structure, naming, lint/format, existing tests, i18n (`next-intl`, `i18next`, `locale` in config).
17
+
18
+ Use the chart library **the project already uses**. Introduce a new one only if there is none or the user asks; in that case, choose by stack ([`references/capability-map.md`](capability-map.md)) and justify it in one line.
19
+
20
+ ### 1.2 Design system → token map
21
+
22
+ Find the visual sources of truth:
23
+
24
+ | Ecosystem | Where to look |
25
+ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
26
+ | shadcn/ui + Tailwind v4 | `globals.css`/`app.css`: `:root` and `.dark` with `--background`, `--foreground`, `--card`, `--muted`, `--muted-foreground`, `--border`, `--primary`, `--chart-1…5`, `--radius`; `@theme inline` |
27
+ | Tailwind v3 | `tailwind.config.*` (`theme.extend.colors`, `fontFamily`, `borderRadius`) + HSL variables in CSS (`hsl(var(--chart-1))`) |
28
+ | MUI / Chakra / Mantine / Ant | theme object (`createTheme`, `extendTheme`, `MantineProvider`, `ConfigProvider`) |
29
+ | Dedicated tokens | `tokens.json`, Style Dictionary, Figma tokens, `design-tokens/` |
30
+ | Python/R | Plotly theme (`pio.templates`), `matplotlibrc`/styles, ggplot `theme_*()`, Streamlit `config.toml` |
31
+ | BI | Power BI JSON theme, Metabase/Superset theme, corporate palette |
32
+
33
+ Also identify: the dark-theme mechanism (`.dark` class, `data-theme`, `prefers-color-scheme`), the interface font and whether it has tabular numerals, the default radius and spacing, and existing card/table/filter components.
34
+
35
+ Translate what you found into **roles**. Chart code uses the roles, never the raw colors:
36
+
37
+ | Role | What it is | How to infer |
38
+ | ----------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
39
+ | `ink` | titles and values | project foreground |
40
+ | `muted` | subtitles, axes, context labels | muted-foreground / secondary text |
41
+ | `context` | marks that serve as comparison | a system gray (or `muted-foreground` at 30–45% opacity) |
42
+ | `reference` | median/target lines | `muted` with a dashed stroke |
43
+ | `highlight` | the protagonist mark | a **saturated** brand color: `--chart-1` or `--primary` if it is chromatic; if the primary is neutral (black/gray), use the first saturated chart color |
44
+ | `highlight-text` | annotation text in the highlight color | `highlight` if it has contrast ≥ 4.5:1 against the card background; otherwise a darker variant (light theme) or a lighter one (dark theme) |
45
+ | `highlight-soft` | shaded bands, areas | `highlight` at 10–15% opacity |
46
+ | `positive` / `negative` | unambiguous good/bad signal | system semantic tokens (success/destructive) |
47
+ | `surface` / `page` | card and page background | card / background |
48
+
49
+ Map rules:
50
+
51
+ - `highlight` **must not** be the semantic error color (red) unless the highlight is something bad. If the brand is red, say so and prefer another system color or the less semantic brand tone.
52
+ - Validate contrast: text ≥ 4.5:1 (≥ 3:1 if large/bold), essential graphical marks ≥ 3:1 against the background. `context` may have low contrast on purpose, as long as the essential information is in the highlight and the annotations.
53
+ - Do not assume `--chart-1` (or the first chart token) is the best highlight color: in several themes it is a light, low-contrast tone. Choose the saturated chart token that passes contrast.
54
+ - Validate both themes if the project has a dark theme.
55
+ - **Fallback** (only if there is no design system and no user direction): `ink #1A1A1A`, `muted #6B6B6B`, `context #C4C4C4`, `reference #8A8A8A`, `highlight #E8590C`, `highlight-text #C2410C`, `surface #FFFFFF`, `page #F4F4F2`; dark: `ink #F2F2F2`, `muted #A0A3A8`, `context #4A4D52`, `highlight #FF7A1A`, `surface #1C1E22`, `page #0F1115`. Font: the system sans-serif (`Inter`, `system-ui`).
56
+
57
+ ### 1.3 Exploration output
58
+
59
+ Record a few lines (in the chat or in the PR) before implementing:
60
+
61
+ ```
62
+ Stack: Next.js 15 + shadcn/ui (Tailwind v4) + Recharts 3 — use components/ui/chart.tsx
63
+ Data: Postgres via Drizzle; daily sales in orders.amount (BRL, America/Sao_Paulo)
64
+ Tokens: highlight=var(--chart-1) · context=var(--muted-foreground)/35% · surface=var(--card) · dark via .dark
65
+ Locale: pt-BR · currency BRL · Geist font (tabular-nums available)
66
+ Decisions: reuse <Card>; filters in components/filters/date-range.tsx
67
+ ```
68
+
69
+ If something essential cannot be discovered (audience, main question, data source), ask **once**, concretely. Assume the rest and state the assumption.
70
+
71
+ ---
@@ -0,0 +1,32 @@
1
+ ## 5. Choose the form from the data and the question
2
+
3
+ | Data / question | Form | How to highlight |
4
+ | ----------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------- |
5
+ | Long time series: "did any period break from normal?" | Thin bars per period | Event bar in `highlight`, dashed median, annotation |
6
+ | Time series: "how did it evolve? who passed whom?" | Line (2–5 series) | Colored protagonist, the rest `context`, label at the end, mark at the crossing |
7
+ | Series with many entities (years, stores, SKUs) | Thin `context` lines + 1 highlighted + dotted average | Direct labels on the highlighted series and on the average |
8
+ | Categories (≤ 12): "which one stood out?" | Sorted bars (horizontal if the names are long) | Protagonist bar and its label in `highlight`, values on the bars |
9
+ | Before × after by category | Bars with a marker for the previous period, slope, or dumbbell | Annotated change (`+203%`) |
10
+ | Part of a whole | Sorted bars or a unit chart (each square = 1%) | Protagonist category colored; avoid a pie with > 3 slices |
11
+ | Distribution | Histogram, density, boxplot, strip | Band or group of interest colored |
12
+ | Relationship between measures | Scatter | Group/quadrant in `highlight-soft` (shaded area), labels on the relevant points |
13
+ | Time × group matrix (cohorts, hour × day) | Single-color sequential heatmap | Relevant cell/row with an outline or annotation |
14
+ | Funnel | Cascading horizontal bars with the pass-through rate | Stage with the largest loss in `highlight` |
15
+ | Geographic | Monochrome choropleth or symbol map | Protagonist region in `highlight`, the rest neutral |
16
+ | Exact values for lookup | Compact table with inline bars and a highlighted row | Column sorted by the story metric |
17
+ | One number matters | KPI card | Delta with an explicit baseline |
18
+
19
+ Avoid: pie/donut with many slices, 3D, dual axes (use two aligned charts), radar, gauges, stacked area with many layers, a giant table as the main visual.
20
+
21
+ ---
22
+
23
+ ## 6. Perception principles in practice
24
+
25
+ - **Closure**: the reader completes incomplete shapes; that is why the axis, frame, separators, and grid can go without hurting reading.
26
+ - **Common region**: what sits inside the same area is one group. Group related KPIs on the same background (instead of a border per number); shade the part of the chart that matters (forecast period, campaign window, quadrant). Borders on every element fragment the dashboard.
27
+ - **Continuity**: the eye follows lines and alignments. Align charts that share time on the same axis and scale; let the reference line run across the chart.
28
+ - **Proximity**: the label sits against the mark; the title sits against the chart; more space **between** groups than **inside** them.
29
+ - **Similarity**: same color/shape = the same thing across the whole dashboard.
30
+ - **Preattentive processing (figure and ground)**: one element that differs in color, size, or weight pops out before conscious reading — and the effect disappears when everything is different.
31
+
32
+ ---
@@ -0,0 +1,37 @@
1
+ ### 9.5 R — ggplot2
2
+
3
+ ```r
4
+ library(ggplot2)
5
+ library(dplyr)
6
+
7
+ roles <- c(ink = "#1A1A1A", muted = "#6B6B6B", context = "#C4C4C4", highlight = "#E8590C", highlight_text = "#C2410C")
8
+ months <- c("JAN","FEV","MAR","ABR","MAI","JUN","JUL","AGO","SET","OUT","NOV","DEZ")
9
+ money <- function(v) paste0("R$ ", format(round(v / 1e3, 1), big.mark = ".", decimal.mark = ",", nsmall = 1), " mil")
10
+
11
+ baseline <- median(df$value)
12
+ peak <- slice_max(df, value, n = 1, with_ties = FALSE)
13
+ factor <- peak$value / baseline
14
+
15
+ ggplot(df, aes(date, value)) +
16
+ geom_col(aes(fill = date == peak$date), width = 0.7) +
17
+ scale_fill_manual(values = c(`FALSE` = roles[["context"]], `TRUE` = roles[["highlight"]]), guide = "none") +
18
+ geom_hline(yintercept = baseline, linetype = "dashed", linewidth = 0.3, colour = roles[["muted"]]) +
19
+ annotate("label", x = min(df$date), y = baseline, label = paste("normal day (median) ·", money(baseline)),
20
+ hjust = 0, vjust = -0.3, size = 3, colour = roles[["muted"]], label.size = 0, fill = "white") +
21
+ annotate("text", x = peak$date - 3, y = peak$value, hjust = 1, vjust = 1, lineheight = 0.95,
22
+ label = paste0(toupper(money(peak$value)), "\n", round(factor), " times a normal day"),
23
+ colour = roles[["highlight_text"]], fontface = "bold", size = 4.5) +
24
+ scale_x_date(date_breaks = "1 month", labels = function(d) months[as.integer(format(d, "%m"))],
25
+ expand = expansion(mult = 0.01)) +
26
+ labs(title = "Black Friday sold 5 times more than a normal day",
27
+ subtitle = "Daily sales in 2025 · each bar is one day", caption = "SOURCE: SALES.CSV", x = NULL, y = NULL) +
28
+ theme_minimal(base_size = 11) +
29
+ theme(panel.grid = element_blank(), axis.text.y = element_blank(),
30
+ axis.text.x = element_text(colour = roles[["muted"]]),
31
+ plot.title.position = "plot", plot.caption.position = "plot",
32
+ plot.title = element_text(face = "bold", size = 18, colour = roles[["ink"]]),
33
+ plot.subtitle = element_text(colour = roles[["muted"]]),
34
+ plot.caption = element_text(colour = roles[["muted"]], size = 8, hjust = 0))
35
+ ```
36
+
37
+ In production, generate the title with the same logic as `peak_story` (factor ≥ 1.5, honest rounding). In Shiny, wrap it in `renderPlot` and recompute `baseline`/`peak` from the filtered slice.
@@ -0,0 +1,44 @@
1
+ ## 7. Dashboard layout and components
2
+
3
+ ### 7.1 Page hierarchy
4
+
5
+ 1. **Header**: the dashboard's conclusion title ("Revenue grew 18% in 2025, driven by the app"), subtitle (period, slice, refresh), discreet global filters.
6
+ 2. **KPI band**: 3–5 numbers that answer the main question, grouped by theme.
7
+ 3. **Protagonist chart**: proves the dashboard title; full width or 2/3.
8
+ 4. **Supporting charts**: 2–4, each with its own conclusion title, explaining the why.
9
+ 5. **Detail** (optional): a table for checking.
10
+
11
+ A 12-column grid with the design system's spacing; ~6–9 visuals per screen at most; charts that share a time axis stay aligned; on narrow screens, stack them in hierarchy order.
12
+
13
+ ### 7.2 KPI card
14
+
15
+ ```
16
+ REVENUE 2025 ← label (muted, small)
17
+ R$ 18,4 mi ← value (ink, large, tabular-nums)
18
+ +18% vs. 2024 ← delta WITH an explicit baseline
19
+ ▁▂▂▃▂▃▄▃▄▅▆█ ← optional sparkline: context, last point in highlight
20
+ ```
21
+
22
+ Delta neutral by default; `positive`/`negative` only when good/bad is unambiguous, always with a sign. Optionally, a short sentence in place of the delta ("best month of the year").
23
+
24
+ ### 7.3 Chart card
25
+
26
+ Use the project's card component. Inside: title → subtitle → chart → footer, all on the same left margin. No extra border when the card background already separates it from the page.
27
+
28
+ ### 7.4 Interaction and states
29
+
30
+ - **Dynamic titles and highlights**: recomputed on every filter by the [`references/story.md`](story.md) functions.
31
+ - The **tooltip** carries the exact detail; the essential is already annotated.
32
+ - Hover/selection emphasizes the entity and dims the others (the same highlight logic).
33
+ - Filters with a visible state ("2025 · All channels").
34
+ - Loading (a skeleton in the card's shape), empty (a sentence explaining why), and error (message + action) states.
35
+ - A short animation (≤ 300 ms) only on transitions; no decorative animation on load.
36
+
37
+ ### 7.5 Accessibility
38
+
39
+ - `aria-label`/`<figcaption>` with the title's conclusion and the key value; offer the data table (or a download) as an alternative.
40
+ - Meaning never by color alone: the highlight also uses a label, weight, or position.
41
+ - Keyboard focus on the controls; accessible tooltips when the library allows (e.g. `accessibilityLayer` in Recharts).
42
+ - Respect `prefers-reduced-motion`.
43
+
44
+ ---
@@ -0,0 +1,103 @@
1
+ ### 9.4 Python — Plotly (also Streamlit and Dash) and matplotlib
2
+
3
+ Story layer and formatting (pt-BR, without depending on the system locale):
4
+
5
+ ```python
6
+ import pandas as pd
7
+
8
+ MONTHS = ["JAN", "FEV", "MAR", "ABR", "MAI", "JUN", "JUL", "AGO", "SET", "OUT", "NOV", "DEZ"]
9
+ ROLES = { # fill in from the project design system
10
+ "ink": "#1A1A1A", "muted": "#6B6B6B", "context": "#C4C4C4",
11
+ "highlight": "#E8590C", "highlight_text": "#C2410C", "surface": "#FFFFFF",
12
+ }
13
+
14
+ def num_br(v: float, digits: int = 1) -> str:
15
+ return f"{v:,.{digits}f}".replace(",", "X").replace(".", ",").replace("X", ".")
16
+
17
+ def money(v: float, digits: int = 1) -> str:
18
+ if abs(v) >= 1e6:
19
+ return f"R$ {num_br(v / 1e6, digits)} mi"
20
+ if abs(v) >= 1e3:
21
+ return f"R$ {num_br(v / 1e3, digits)} mil"
22
+ return f"R$ {num_br(v, 0)}"
23
+
24
+ def times(f: float) -> str:
25
+ return f"{round(f)} times" if abs(f - round(f)) < 0.15 else f"{num_br(f, 1)} times"
26
+
27
+ def short_day(d: pd.Timestamp) -> str:
28
+ return f"{d.day} {MONTHS[d.month - 1].lower()}"
29
+
30
+ def peak_story(df: pd.DataFrame, event: str | None = None) -> dict:
31
+ """df: columns 'date' (datetime) and 'value'; complete series, no gaps."""
32
+ baseline = df["value"].median()
33
+ peak = df.loc[df["value"].idxmax()]
34
+ factor = peak["value"] / baseline
35
+ title = (f"{event or 'The peak'} sold {times(factor)} more than a normal day"
36
+ if factor >= 1.5 else "Daily sales stayed flat over the period")
37
+ return {"baseline": baseline, "peak": peak, "factor": factor, "title": title,
38
+ "colors": [ROLES["highlight"] if d == peak["date"] else ROLES["context"] for d in df["date"]]}
39
+ ```
40
+
41
+ Plotly:
42
+
43
+ ```python
44
+ import plotly.graph_objects as go
45
+
46
+ def peak_bars_plotly(df: pd.DataFrame, s: dict, subtitle: str, source: str) -> go.Figure:
47
+ months = pd.date_range(df["date"].min().replace(day=1), df["date"].max(), freq="MS")
48
+ p = s["peak"]
49
+ fig = go.Figure(go.Bar(x=df["date"], y=df["value"], marker_color=s["colors"], marker_line_width=0,
50
+ hovertemplate="%{x|%d/%m/%Y}<br>R$ %{y:,.0f}<extra></extra>"))
51
+ fig.update_layout(
52
+ title=dict(x=0, xref="paper", xanchor="left", text=f"<b>{s['title']}</b><br><span style='font-size:13px;color:{ROLES['muted']}'>{subtitle}</span>"),
53
+ font=dict(family="Inter, system-ui, sans-serif", color=ROLES["ink"], size=12),
54
+ plot_bgcolor=ROLES["surface"], paper_bgcolor=ROLES["surface"], separators=",.",
55
+ bargap=0.35, showlegend=False, margin=dict(l=24, r=24, t=96, b=56))
56
+ fig.update_yaxes(visible=False)
57
+ fig.update_xaxes(showgrid=False, showline=False, ticks="", tickvals=months,
58
+ ticktext=[MONTHS[m.month - 1] for m in months], tickfont=dict(color=ROLES["muted"], size=11))
59
+ fig.add_hline(y=s["baseline"], line_dash="dot", line_width=1, line_color=ROLES["muted"],
60
+ annotation_text=f"normal day (median) · {money(s['baseline'])}", annotation_position="top left",
61
+ annotation_font=dict(size=11, color=ROLES["muted"]))
62
+ fig.add_annotation(x=p["date"], y=p["value"], showarrow=False, xanchor="right", yanchor="top", xshift=-6, align="right",
63
+ text=f"<b><span style='font-size:22px'>{money(p['value']).upper()}</span></b><br>"
64
+ f"{short_day(p['date'])} · {times(s['factor'])} a normal day",
65
+ font=dict(color=ROLES["highlight_text"], size=12))
66
+ fig.add_annotation(x=0, y=-0.16, xref="paper", yref="paper", showarrow=False, xanchor="left",
67
+ text=source, font=dict(size=10, color=ROLES["muted"]))
68
+ return fig
69
+ ```
70
+
71
+ In Streamlit: `st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})`. In Dash: `dcc.Graph(figure=fig, config={"displayModeBar": False})`. For a dark theme, swap `ROLES` for the project's dark tokens.
72
+
73
+ matplotlib (static reports, PDFs, emails):
74
+
75
+ ```python
76
+ import matplotlib.pyplot as plt
77
+ import matplotlib.dates as mdates
78
+ from matplotlib.ticker import FuncFormatter
79
+
80
+ def peak_bars_mpl(df: pd.DataFrame, s: dict, subtitle: str, source: str):
81
+ p = s["peak"]
82
+ fig, ax = plt.subplots(figsize=(10, 4.6), dpi=200)
83
+ fig.patch.set_facecolor(ROLES["surface"]); ax.set_facecolor(ROLES["surface"])
84
+ ax.bar(df["date"], df["value"], width=0.7, color=s["colors"], linewidth=0)
85
+ ax.axhline(s["baseline"], ls=(0, (2, 3)), lw=1, color=ROLES["muted"])
86
+ ax.text(df["date"].iloc[0], s["baseline"], f"normal day (median) · {money(s['baseline'])}", va="bottom",
87
+ fontsize=9, color=ROLES["muted"], bbox=dict(facecolor=ROLES["surface"], edgecolor="none", pad=1.5))
88
+ ax.annotate(f"{money(p['value']).upper()}\n{short_day(p['date'])} · {times(s['factor'])} a normal day",
89
+ xy=(p["date"], p["value"]), xytext=(-8, 0), textcoords="offset points",
90
+ ha="right", va="top", color=ROLES["highlight_text"], fontsize=13, fontweight="bold")
91
+ for spine in ax.spines.values():
92
+ spine.set_visible(False)
93
+ ax.yaxis.set_visible(False)
94
+ ax.tick_params(axis="x", length=0, colors=ROLES["muted"], labelsize=9)
95
+ ax.set_xlim(df["date"].min() - pd.Timedelta(days=2), df["date"].max() + pd.Timedelta(days=2))
96
+ ax.xaxis.set_major_locator(mdates.MonthLocator())
97
+ ax.xaxis.set_major_formatter(FuncFormatter(lambda x, _: MONTHS[mdates.num2date(x).month - 1]))
98
+ fig.text(0.02, 0.97, s["title"], fontsize=17, fontweight="bold", color=ROLES["ink"], va="top")
99
+ fig.text(0.02, 0.89, subtitle, fontsize=10, color=ROLES["muted"], va="top")
100
+ fig.text(0.02, 0.01, source, fontsize=8, color=ROLES["muted"])
101
+ fig.subplots_adjust(top=0.80, left=0.03, right=0.98, bottom=0.10)
102
+ return fig
103
+ ```