Dumbbell Chart
render_dumbbell_chartVisualize before-and-after comparisons using dumbbell charts with connecting bars, scale labels, and background zones for absolute positioning context.
Instructions
Render a dumbbell chart - 'How big is the gap?' Before/after dots connected by a bar. Supports scale labels and background zones for absolute positioning context.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| title | Yes | Chart title | |
| data | Yes | Array of {label, before, after} items | |
| beforeLabel | No | Label for 'before' column (default: Before) | |
| afterLabel | No | Label for 'after' column (default: After) | |
| unit | No | Unit suffix | |
| scaleLabels | No | Labels at scale positions, e.g. {'40': 'Engineer', '65': 'Sr. Engineer'} | |
| zones | No | Background zone thresholds (same as bullet chart zones) | |
| zoneColors | No | Custom colors per zone band | |
| zoneLabels | No | Labels for each zone band | |
| theme | No | Theme preset: boardroom, corporate, sales-floor, golden-treasury, clinical, startup, ops-control, tokyo-midnight, zen-garden, consultant, black-tron, black-elegance, black-matrix, forest-amber, forest-earth, sky-light, sky-ocean, sky-twilight, gray-hf, gray-copilot | |
| palette | No | Override palette only (mix-and-match) | |
| typography | No | Override typography: professional, luxury, cyberpunk, editorial, mono, bold, system, techno | |
| effects | No | Override effects: none, subtle, shimmer, neon, energetic |
Implementation Reference
- src/charts/dumbbell.ts:55-165 (handler)Main handler function that renders a dumbbell chart. Accepts a container and DumbbellData payload, applies theming, computes percentage positions, renders zones/scale labels/data rows with before/after dots and connecting bars, attaches click handlers for sendClickMessage, and adds export/refresh buttons.
export function renderDumbbellChart(container: HTMLElement, payload: DumbbellData): void { const theme = resolveTheme(payload.theme, { palette: payload.palette, typography: payload.typography, effects: payload.effects, }); if (theme) applyTheme(container, theme); const shimmer = theme?.effects.shimmerTitle ? " shimmer-text" : ""; const allVals = payload.data.flatMap((d) => [d.before, d.after]); const min = Math.min(...allVals); const max = Math.max(...allVals); const range = max - min || 1; const beforeLabel = payload.beforeLabel || "Before"; const afterLabel = payload.afterLabel || "After"; const unit = payload.unit || ""; // Map a value to percentage position (10% - 90% range) const toPct = (v: number) => ((v - min) / range) * 80 + 10; // Build zone HTML for tracks const zones = payload.zones || []; let zonesBgHtml = ""; if (zones.length >= 2) { const zoneCount = zones.length + 1; const colors = payload.zoneColors?.length === zoneCount ? payload.zoneColors : defaultZoneColors(zoneCount); const labels = payload.zoneLabels || []; let prevPct = 0; for (let z = 0; z <= zones.length; z++) { const endPct = z < zones.length ? toPct(zones[z]) : 100; const widthPct = endPct - prevPct; const color = colors[z % colors.length]; const label = labels[z] || ""; const opacity = 0.12 - (z * 0.01); zonesBgHtml += `<div class="dumbbell__zone" style="left:${prevPct}%;width:${widthPct}%;background:${color};opacity:${Math.max(opacity, 0.04)}">`; if (label && widthPct > 6) { zonesBgHtml += `<span class="dumbbell__zone-label">${escapeHtml(label)}</span>`; } zonesBgHtml += `</div>`; prevPct = endPct; } } // Build scale labels let scaleHtml = ""; if (payload.scaleLabels && Object.keys(payload.scaleLabels).length > 0) { const markers = Object.entries(payload.scaleLabels).map(([valStr, label]) => { const val = parseFloat(valStr); const pct = toPct(val); return `<div class="dumbbell__scale-mark" style="left:${pct}%"><span class="dumbbell__scale-label">${escapeHtml(label)}</span></div>`; }).join(""); scaleHtml = `<div class="dumbbell__row dumbbell__row--scale"><div class="dumbbell__label-wrap"></div><div class="dumbbell__track dumbbell__track--scale">${markers}</div><div></div></div>`; } const rows = payload.data.map((item, i) => { const bPct = toPct(item.before); const aPct = toPct(item.after); const leftPct = Math.min(bPct, aPct); const widthPct = Math.abs(aPct - bPct); const tooltipAttr = item.tooltip ? ` title="${escapeHtml(item.tooltip)}"` : ""; return ` <div class="dumbbell__row" data-idx="${i}"${tooltipAttr}> <div class="dumbbell__label-wrap"> <div class="dumbbell__label">${escapeHtml(item.label)}</div> </div> <div class="dumbbell__track"> ${zonesBgHtml} <div class="dumbbell__bar" style="left:${leftPct}%;width:${widthPct}%"></div> <div class="dumbbell__dot dumbbell__dot--before" style="left:${bPct}%" title="${beforeLabel}: ${item.before}${unit}"></div> <div class="dumbbell__dot dumbbell__dot--after" style="left:${aPct}%" title="${afterLabel}: ${item.after}${unit}"></div> </div> <div class="dumbbell__gap">${item.after > item.before ? "+" : ""}${(item.after - item.before).toLocaleString()}${unit}</div> </div> `; }).join(""); container.className = "chart-view"; container.innerHTML = ` <div class="card chart-card"> <div class="chart-card__header"> <div><div class="chart-card__title${shimmer}">${escapeHtml(payload.title)}</div></div> </div> <div class="chart-card__body chart-card__body--css"> <div class="dumbbell"> <div class="dumbbell__legend"> <span class="dumbbell__legend-item"><span class="dumbbell__dot-sm dumbbell__dot--before"></span>${escapeHtml(beforeLabel)}</span> <span class="dumbbell__legend-item"><span class="dumbbell__dot-sm dumbbell__dot--after"></span>${escapeHtml(afterLabel)}</span> </div> ${rows} ${scaleHtml} </div> </div> </div> `; container.querySelectorAll<HTMLElement>(".dumbbell__row[data-idx]").forEach((el) => { el.style.cursor = "pointer"; el.addEventListener("click", () => { const idx = parseInt(el.dataset.idx ?? "0", 10); const item = payload.data[idx]; sendClickMessage(`[Dumbbell] "${payload.title}" - ${item.label}: ${item.before}${unit} -> ${item.after}${unit}`); }); }); const card = container.querySelector<HTMLElement>(".chart-card")!; addHtmlExportButton(card, payload.title); addRefreshButton(card, () => (window as any).__mcpRefresh?.()); } - src/charts/dumbbell.ts:4-26 (schema)TypeScript interfaces defining the input schema for the dumbbell chart: DumbbellItem (label, before, after values) and DumbbellData (title, data array, optional labels, zones, colors, scale labels, and theme options).
interface DumbbellItem { label: string; before: number; after: number; tooltip?: string; } interface DumbbellData { type: "dumbbell"; title: string; data: DumbbellItem[]; beforeLabel?: string; afterLabel?: string; unit?: string; scaleLabels?: Record<string, string>; zones?: number[]; zoneColors?: string[]; zoneLabels?: string[]; theme?: string; palette?: string; typography?: string; effects?: string; } - src/charts/dumbbell.ts:167-167 (registration)Registers the dumbbell chart tool with the chart registry, mapping internal type 'dumbbell' to tool name 'render_dumbbell_chart' and binding the renderDumbbellChart handler.
registerChart("dumbbell", "render_dumbbell_chart", renderDumbbellChart); - src/charts/dumbbell.ts:29-41 (helper)Helper function that linearly interpolates between two hex colors, used by defaultZoneColors to generate zone color gradients.
function lerpColor(a: string, b: string, t: number): string { const parse = (hex: string) => [ parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16), ]; const [r1, g1, b1] = parse(a); const [r2, g2, b2] = parse(b); const r = Math.round(r1 + (r2 - r1) * t); const g = Math.round(g1 + (g2 - g1) * t); const bl = Math.round(b1 + (b2 - b1) * t); return `#${((1 << 24) | (r << 16) | (g << 8) | bl).toString(16).slice(1)}`; } - src/charts/dumbbell.ts:43-53 (helper)Helper function that generates default zone colors (red-amber-green gradient) using lerpColor, used when zoneColors are not explicitly provided.
function defaultZoneColors(count: number): string[] { if (count === 1) return ["#ef4444"]; const colors: string[] = []; const stops = ["#ef4444", "#f59e0b", "#22c55e"]; for (let i = 0; i < count; i++) { const t = i / (count - 1); if (t <= 0.5) colors.push(lerpColor(stops[0], stops[1], t * 2)); else colors.push(lerpColor(stops[1], stops[2], (t - 0.5) * 2)); } return colors; }