ComboChart
Bars on a left axis and lines on a right axis over the same labels, for two measures of different size, with every value on hover.
Demo
| Revenue | Margin | |
|---|---|---|
| Jan | $42,100 | 18.2% |
| Feb | $45,800 | 19.5% |
| Mar | $51,200 | 22.1% |
| Apr | $49,900 | 20.4% |
| May | $56,300 | 23.8% |
| Jun | $61,750 | 26.1% |
| Jul | $58,200 | 24.9% |
| Aug | $64,900 | 27.3% |
| Desktop | Mobile | Conversion | |
|---|---|---|---|
| Mon | 3,200.0 | 4,100.0 | 2.4% |
| Tue | 3,540.0 | 4,380.0 | 2.7% |
| Wed | 3,310.0 | 4,560.0 | 2.5% |
| Thu | 3,620.0 | 4,720.0 | — |
| Fri | 2,980.0 | 5,210.0 | 3.1% |
| Rainfall | High | Low | |
|---|---|---|---|
| Jan | 78 mm | 6° | -2° |
| Feb | 61 mm | 8° | -1° |
| Mar | 55 mm | 12° | 3° |
| Apr | 42 mm | 16° | 6° |
| May | 38 mm | 20° | 10° |
| Jun | 21 mm | 24° | 14° |
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/combo_chart.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/combo_chart.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/combo_chart.qwe
let combo_chart_demo_grid = "display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 32px 40px"view ComboChartDemo() = <div.demo-stack> <ComboChart label="Revenue and margin" show_label={true} bar_prefix="$" line_suffix="%" labels={["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"]} bars={[{ name: "Revenue", values: [42100, 45800, 51200, 49900, 56300, 61750, 58200, 64900] }]} lines={[{ name: "Margin", values: [18.2, 19.5, 22.1, 20.4, 23.8, 26.1, 24.9, 27.3] }]}/> <div style={combo_chart_demo_grid}> <ComboChart label="Visits and conversion" show_label={true} line_suffix="%" line_zero={false} height={180} bar_axis="Visits" line_axis="Conversion" decimals={1} labels={["Mon", "Tue", "Wed", "Thu", "Fri"]} bars={[{ name: "Desktop", values: [3200, 3540, 3310, 3620, 2980] }, { name: "Mobile", values: [4100, 4380, 4560, 4720, 5210] }]} lines={[{ name: "Conversion", values: [2.4, 2.7, 2.5, null, 3.1] }]}/> <ComboChart label="Rainfall and temperature" show_label={true} bar_suffix=" mm" line_suffix="°" height={180} labels={["Jan", "Feb", "Mar", "Apr", "May", "Jun"]} bars={[{ name: "Rainfall", values: [78, 61, 55, 42, 38, 21] }]} lines={[{ name: "High", values: [6, 8, 12, 16, 20, 24] }, { name: "Low", values: [-2, -1, 3, 6, 10, 14] }]}/> </div> </div>Source
ui/combo_chart.qwe
// ComboChart: bars and lines over the same labels, each on an axis of its own, the// bars' on the left and the lines' on the right, for two measures of different size// (revenue and margin, visits and conversion). CSS only: hovering a label shows every// value there, and screen readers get the numbers as a table.//// <ComboChart label="Revenue and margin" labels={months} bar_prefix="$" line_suffix="%"// bars={[{ name: "Revenue", values: revenue }]} lines={[{ name: "Margin", values: margin }]}/>//// labels: the categories, in order. bars: a list of { name, values }, drawn side by// side per label against the left axis (a null counts as 0; below 0 hangs under the// zero line). lines: a list of { name, values }, drawn as lines with a dot on each// value against the right axis (a null leaves a gap). Up to eight series in all, the// lines' colours after the bars'. bar_prefix, bar_suffix, line_prefix, line_suffix:// around the values of each kind ("$", "%"; each axis takes its prefix and suffix);// decimals: digits after the point, always, instead of up to two as needed.// line_zero: starts the right axis at 0 (true); false spans the lines' values.// bar_axis and line_axis: the names written above the two axes (the bars' names and// the lines' names). height: the plot's height in pixels (240). label: what the// chart shows; it names it for screen readers and captions their table. show_label// also writes it above the chart.import ui/theme.qwe use ui_clslet combo_chart_css = css`.ui-comboc { --ui-comboc-1: light-dark(#2a78d6, #3987e5); --ui-comboc-2: light-dark(#eb6834, #d95926); --ui-comboc-3: light-dark(#1baf7a, #199e70); --ui-comboc-4: light-dark(#eda100, #c98500); --ui-comboc-5: light-dark(#e87ba4, #d55181); --ui-comboc-6: light-dark(#008300, #008300); --ui-comboc-7: light-dark(#6250d6, #9085e9); --ui-comboc-8: light-dark(#e34948, #e66767); display: grid; gap: 10px; align-content: start; min-width: 0; margin: 0; font: 12px/1.3 var(--ui-font); color: var(--ui-fg); }.ui-comboc-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-comboc-s1 { --c: var(--ui-comboc-1); } .ui-comboc-s2 { --c: var(--ui-comboc-2); } .ui-comboc-s3 { --c: var(--ui-comboc-3); }.ui-comboc-s4 { --c: var(--ui-comboc-4); } .ui-comboc-s5 { --c: var(--ui-comboc-5); } .ui-comboc-s6 { --c: var(--ui-comboc-6); }.ui-comboc-s7 { --c: var(--ui-comboc-7); } .ui-comboc-s8 { --c: var(--ui-comboc-8); }.ui-comboc-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 13px; }.ui-comboc-legend li { display: inline-flex; align-items: center; gap: 6px; }.ui-comboc-key { width: 10px; height: 10px; border-radius: 3px; background: var(--c); flex: none; }.ui-comboc-lkey { width: 14px; height: 3px; border-radius: 2px; }.ui-comboc-frame { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; column-gap: 8px; }.ui-comboc-axis { text-align: right; color: var(--ui-muted); font-size: 11px; font-weight: 600; white-space: nowrap; }.ui-comboc-raxis { text-align: left; }.ui-comboc-y { position: relative; min-width: 24px; margin-top: 8px; }.ui-comboc-y span { position: absolute; transform: translateY(50%); color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-comboc-left span { right: 0; }.ui-comboc-right span { left: 0; }.ui-comboc-plot { position: relative; margin-top: 8px; }.ui-comboc-grid { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-comboc-grid.ui-comboc-zero { background: var(--ui-strong); }.ui-comboc-cols { position: absolute; inset: 0; display: flex; }.ui-comboc-col { position: relative; flex: 1; min-width: 0; border-radius: 4px; }.ui-comboc-col:hover { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-comboc-bars { position: absolute; inset: 0 18%; display: flex; justify-content: center; gap: 2px; }.ui-comboc-slot { position: relative; flex: 1; max-width: 40px; }.ui-comboc-bar { position: absolute; left: 0; right: 0; bottom: var(--a); height: var(--b); min-height: 1px; background: var(--c); border-radius: 4px 4px 0 0; }.ui-comboc-neg { border-radius: 0 0 4px 4px; }.ui-comboc-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }.ui-comboc-line { fill: none; stroke: var(--c); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }.ui-comboc-pt { position: absolute; width: 8px; height: 8px; margin: -4px 0 0 -4px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 2px var(--ui-bg); pointer-events: none; }.ui-comboc-tip { position: absolute; z-index: 20; top: 0; left: 50%; display: none; min-width: 130px; padding: 7px 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); box-shadow: var(--ui-shadow); color: var(--ui-fg); font-size: 12px; transform: translate(-50%, -100%); pointer-events: none; }.ui-comboc-col:first-child .ui-comboc-tip { left: 0; transform: translateY(-100%); }.ui-comboc-col:last-child .ui-comboc-tip { left: auto; right: 0; transform: translateY(-100%); }.ui-comboc-col:hover .ui-comboc-tip { display: block; }.ui-comboc-tip strong { display: block; margin-bottom: 3px; font-weight: 600; white-space: nowrap; }.ui-comboc-tip p { display: flex; align-items: center; gap: 6px; margin: 2px 0 0; color: var(--ui-muted); white-space: nowrap; }.ui-comboc-tip b { margin-left: auto; padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-comboc-x { display: flex; margin-top: 6px; }.ui-comboc-x span { flex: 1; min-width: 0; padding: 0 2px; overflow: hidden; color: var(--ui-muted); text-align: center; text-overflow: ellipsis; white-space: nowrap; }`// n (a whole number, 0 or more) with at least `width` digits.fn combo_chart_pad(n: int, width: int) -> str { let s = str(n) len(s) < width ? repeat("0", width - len(s)) + s : s}// A whole number with commas: 1234567 is "1,234,567".fn combo_chart_group(n: int) -> str = n < 1000 ? str(n) : combo_chart_group(floor(n / 1000)) + "," + combo_chart_pad(n % 1000, 3)// A value as it reads, with commas: `places` digits after the point, or up to two as// needed when that is null: 48210 is "48,210", 6350.5 is "6,350.5" ("6,350.50" with 2).// Worked out in whole numbers, so the server and the browser write the same text.fn combo_chart_num(v: num, places: int? = null) -> str { let digits = places == null ? 2 : (places < 0 ? 0 : (places > 4 ? 4 : places)) let scale = [1, 10, 100, 1000, 10000][digits] let units = round(abs(v) * scale) let frac = units % scale let tail = digits == 0 || (places == null && frac == 0) ? "" : (places == null && frac % 10 == 0 ? `.${floor(frac / 10)}` : `.${combo_chart_pad(frac, digits)}`) `${v < 0 && units > 0 ? "-" : ""}${combo_chart_group(floor(units / scale))}${tail}`}// An axis tick, short: 1500 is "1.5k", 2000000 is "2M", 0.25 is "0.25".fn combo_chart_short(v: num) -> str { let a = abs(v) let unit = a >= 1000000000 ? 1000000000 : (a >= 1000000 ? 1000000 : (a >= 1000 ? 1000 : 1)) let tenths = round(a * 10 / unit) let mark = unit == 1000000000 ? "B" : (unit == 1000000 ? "M" : "k") unit == 1 ? combo_chart_num(v) : `${v < 0 ? "-" : ""}${combo_chart_group(floor(tenths / 10))}${tenths % 10 != 0 ? `.${tenths % 10}` : ""}${mark}`}// A percentage for CSS (or a coordinate out of 100), to two decimals.fn combo_chart_pct(n: num) -> str { let r = round(n * 100) / 100 r == floor(r) ? str(floor(r)) : str(r)}// The axis from lo to hi, widened to round ticks: about four steps of 1, 2, 2.5 or 5 x 10^n.fn combo_chart_scale(lo: num, hi: num) -> map { let span = hi - lo == 0 ? (lo == 0 ? 1 : abs(lo)) : hi - lo let raw = span / 4 let mag = 1 while mag * 10 <= raw { mag = mag * 10 } while mag > raw { mag = mag / 10 } let r = raw / mag let step = (r <= 1 ? 1 : (r <= 2 ? 2 : (r <= 2.5 ? 2.5 : (r <= 5 ? 5 : 10)))) * mag let a = floor(lo / step + 0.000001) let b0 = ceil(hi / step - 0.000001) let b = b0 == a ? a + 1 : b0 { lo: a * step, hi: b * step, ticks: for i in range(b - a + 1) => (a + i) * step }}// The runs of a series without a null in them, as lists of indexes: [3, null, 4, 5] is [[0], [2, 3]].fn combo_chart_runs(vals: list, n: int) -> list { let starts = range(n) | filter((i) => vals[i] != null && (i == 0 || vals[i - 1] == null)) for st in starts => range(st, (find(range(st, n), (k) => k == n - 1 || vals[k + 1] == null) ?? st) + 1)}// The highest of a list of numbers.fn combo_chart_top(xs: list) -> num = max(xs)view ComboChart(labels: list, bars: list = [], lines: list = [], bar_prefix: str = "", bar_suffix: str = "", line_prefix: str = "", line_suffix: str = "", decimals: int? = null, line_zero: bool = true, bar_axis: str? = null, line_axis: str? = null, height: int = 240, label: str = "Combo chart", show_label: bool = false, class: str? = null) { style global combo_chart_css let n = len(labels) let nb = len(bars) let at = (s, i) => s.values[i] ?? 0 let bfmt = (v) => `${v < 0 ? "-" : ""}${bar_prefix}${combo_chart_num(abs(v), decimals)}${bar_suffix}` let lfmt = (v) => v == null ? "—" : `${v < 0 ? "-" : ""}${line_prefix}${combo_chart_num(abs(v), decimals)}${line_suffix}` let bvals = [0] + flatten(for s in bars => for i in range(n) => at(s, i)) let bsc = combo_chart_scale(min(bvals), combo_chart_top(bvals)) let bpos = (v) => (v - bsc.lo) * 100 / (bsc.hi - bsc.lo) let lvals = flatten(for s in lines => for i in range(n) => s.values[i]) | filter((v) => v != null) let llo = len(lvals) == 0 ? 0 : min(lvals) let lhi = len(lvals) == 0 ? 0 : combo_chart_top(lvals) let lsc = combo_chart_scale(line_zero && llo > 0 ? 0 : llo, line_zero && lhi < 0 ? 0 : lhi) let lpos = (v) => (v - lsc.lo) * 100 / (lsc.hi - lsc.lo) let cx = (i) => combo_chart_pct((i + 0.5) * 100 / n) let zero = bpos(0) let lcls = (j) => `ui-comboc-s${(nb + j) % 8 + 1}` <figure class={ui_cls(["ui-comboc", class])} aria-label={label}> {show_label && <figcaption.ui-comboc-title>{label}</figcaption>} <ul.ui-comboc-legend aria-hidden="true"> {for [j, s] in enumerate(bars) => <li><span class={`ui-comboc-key ui-comboc-s${j % 8 + 1}`}></span>{s.name}</li>} {for [j, s] in enumerate(lines) => <li><span class={`ui-comboc-key ui-comboc-lkey ${lcls(j)}`}></span>{s.name}</li>} </ul> <div.ui-comboc-frame aria-hidden="true"> <span.ui-comboc-axis>{bar_axis ?? join(bars | map((s) => s.name), ", ")}</span> <span></span> <span class="ui-comboc-axis ui-comboc-raxis">{line_axis ?? join(lines | map((s) => s.name), ", ")}</span> <div class="ui-comboc-y ui-comboc-left" style={`height: ${height}px`}> {for t in bsc.ticks => <span style={`bottom: ${combo_chart_pct(bpos(t))}%`}>{t < 0 ? "-" : ""}{bar_prefix}{combo_chart_short(abs(t))}{bar_suffix}</span>} </div> <div.ui-comboc-plot style={`height: ${height}px`}> {for t in bsc.ticks => <span class={ui_cls(["ui-comboc-grid", t == 0 && "ui-comboc-zero"])} style={`bottom: ${combo_chart_pct(bpos(t))}%`}></span>} <div.ui-comboc-cols> {for i in range(n) => <div.ui-comboc-col> <span.ui-comboc-bars> {for [j, s] in enumerate(bars) => { let v = at(s, i) let from = v < 0 ? bpos(v) : zero let to = v < 0 ? zero : bpos(v) <span.ui-comboc-slot> {v != 0 && <span class={ui_cls(["ui-comboc-bar", `ui-comboc-s${j % 8 + 1}`, v < 0 && "ui-comboc-neg"])} style={`--a: ${combo_chart_pct(from)}%; --b: ${combo_chart_pct(to - from)}%`}></span>} </span> }} </span> <span.ui-comboc-tip> <strong>{labels[i]}</strong> {for [j, s] in enumerate(bars) => <p> <span class={`ui-comboc-key ui-comboc-s${j % 8 + 1}`}></span>{s.name}<b>{bfmt(at(s, i))}</b> </p>} {for [j, s] in enumerate(lines) => <p> <span class={`ui-comboc-key ui-comboc-lkey ${lcls(j)}`}></span>{s.name}<b>{lfmt(s.values[i])}</b> </p>} </span> </div>} </div> <svg.ui-comboc-svg viewBox="0 0 100 100" preserveAspectRatio="none"> {for [j, s] in enumerate(lines) => <path class={`ui-comboc-line ${lcls(j)}`} vector-effect="non-scaling-stroke" d={join(for r in combo_chart_runs(s.values, n) => "M" + join(for i in r => `${cx(i)},${combo_chart_pct(100 - lpos(s.values[i]))}`, " L"), " ")}/>} </svg> {for [j, s] in enumerate(lines) => for i in range(n) => s.values[i] != null && <span class={`ui-comboc-pt ${lcls(j)}`} style={`left: ${cx(i)}%; top: ${combo_chart_pct(100 - lpos(s.values[i]))}%`}></span>} </div> <div class="ui-comboc-y ui-comboc-right" style={`height: ${height}px`}> {for t in lsc.ticks => <span style={`bottom: ${combo_chart_pct(lpos(t))}%`}>{t < 0 ? "-" : ""}{line_prefix}{combo_chart_short(abs(t))}{line_suffix}</span>} </div> <span></span> <div.ui-comboc-x>{for l in labels => <span title={str(l)}>{l}</span>}</div> </div> <table.ui-sr> <caption>{label}</caption> <thead><tr> <th scope="col"></th> {for s in bars => <th scope="col">{s.name}</th>} {for s in lines => <th scope="col">{s.name}</th>} </tr></thead> <tbody> {for i in range(n) => <tr> <th scope="row">{labels[i]}</th> {for s in bars => <td>{bfmt(at(s, i))}</td>} {for s in lines => <td>{lfmt(s.values[i])}</td>} </tr>} </tbody> </table> </figure>}Related
AreaChartFilled areas for one or more series, overlapping or stacked into a total, with a hover crosshair listing every value.6 views · 8 downloadsBulletChartMeasures as thin bars against a target mark over grey bands from poor to good, a row each, with the band named on hover.5 views · 7 downloadsFunnelChartStages of a process narrowing like a funnel, each with its count and share of the first, and the share that went on between them.5 views · 7 downloadsGaugeChartA half-circle dial of good, warning and bad bands with a needle at the value, and the value and its band's name under it.6 views · 7 downloadsGroupedBarChartA bar per series side by side for each label, below zero too, with the group's values on hover and the bar under the pointer picked out.6 views · 4 downloadsHorizontalBarChartBars across with the labels down the side, sorted if asked, one picked out, values at their ends and on hover.6 views · 6 downloads
Thread
Thread 0 posts
On ComboChart: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.