AreaChart
Filled areas for one or more series, overlapping or stacked into a total, with a hover crosshair listing every value.
Demo
| Value | |
|---|---|
| Oct 1 | 1,240 |
| Oct 2 | 1,380 |
| Oct 3 | 1,310 |
| Oct 4 | 1,520 |
| Oct 5 | 1,890 |
| Oct 6 | 1,720 |
| Oct 7 | 1,460 |
| Oct 8 | 1,610 |
| Oct 9 | 1,830 |
| Oct 10 | 2,040 |
| Oct 11 | 1,980 |
| Oct 12 | 2,310 |
| Desktop | Mobile | Tablet | Total | |
|---|---|---|---|---|
| Jan | 5,200 | 3,900 | 800 | 9,900 |
| Feb | 5,400 | 4,300 | 760 | 10,460 |
| Mar | 5,100 | 4,800 | 820 | 10,720 |
| Apr | 5,600 | 5,200 | 790 | 11,590 |
| May | 5,900 | 5,800 | 850 | 12,550 |
| Jun | 6,100 | 6,400 | 900 | 13,400 |
| This week | Last week | |
|---|---|---|
| Mon | 41.5 GB | 36.1 GB |
| Tue | 42.2 GB | 37.4 GB |
| Wed | 43.8 GB | 38 GB |
| Thu | 44.1 GB | 38.9 GB |
| Fri | — | 39.6 GB |
| Sat | 46.9 GB | 40.2 GB |
| Sun | 47.3 GB | 41.1 GB |
Stacked, the top edge is the total; overlapping, Friday's missing value leaves a gap.
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/area_chart.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/area_chart.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/area_chart.qwe
let area_chart_demo_grid = "display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 32px 40px"view AreaChartDemo() = <div.demo-stack> <AreaChart label="Sessions per day" labels={for i in range(12) => `Oct ${i + 1}`} values={[1240, 1380, 1310, 1520, 1890, 1720, 1460, 1610, 1830, 2040, 1980, 2310]}/> <div style={area_chart_demo_grid}> <AreaChart label="Traffic by device" show_label={true} stacked={true} height={180} labels={["Jan", "Feb", "Mar", "Apr", "May", "Jun"]} series={[{ name: "Desktop", values: [5200, 5400, 5100, 5600, 5900, 6100] }, { name: "Mobile", values: [3900, 4300, 4800, 5200, 5800, 6400] }, { name: "Tablet", values: [800, 760, 820, 790, 850, 900] }]}/> <AreaChart label="Storage used" show_label={true} suffix=" GB" height={180} labels={["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]} series={[{ name: "This week", values: [41.5, 42.2, 43.8, 44.1, null, 46.9, 47.3] }, { name: "Last week", values: [36.1, 37.4, 38.0, 38.9, 39.6, 40.2, 41.1] }]}/> </div> <p.demo-note>Stacked, the top edge is the total; overlapping, Friday's missing value leaves a gap.</p> </div>Source
ui/area_chart.qwe
// AreaChart: one or more series over the same labels as filled areas, overlapping// (each from the zero line, see-through) or stacked (each on the one before, so the// top edge is the total). CSS only: hovering shows a crosshair and every series'// value there, and screen readers get the numbers as a table.//// <AreaChart label="Sessions" labels={days} values={[820, 932, 901, 1034, 1290]}/>// <AreaChart label="Traffic by device" labels={months} stacked={true}// series={[{ name: "Desktop", values: desk }, { name: "Mobile", values: mobile }]}/>//// labels: the x axis, in order (dates, months, ...); when there are many, or the chart// is narrow, only some are written under it. values: one series' numbers, one per// label; or series: a list of { name, values } (up to eight, each in a colour of its// own, with a legend). stacked: each series sits on the ones before it, and the tooltip// adds a total; in a stack a null or a value below 0 counts as 0. Overlapping (the// default), a null leaves a gap and values below 0 hang under the zero line. prefix// and suffix: around every value shown ("$", " GB"; the axis takes the prefix);// decimals: digits after the point, always (2 for money), instead of up to two as// needed. height: the plot's height in pixels (220). 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 area_chart_css = css`.ui-areac { --ui-areac-1: light-dark(#2a78d6, #3987e5); --ui-areac-2: light-dark(#eb6834, #d95926); --ui-areac-3: light-dark(#1baf7a, #199e70); --ui-areac-4: light-dark(#eda100, #c98500); --ui-areac-5: light-dark(#e87ba4, #d55181); --ui-areac-6: light-dark(#008300, #008300); --ui-areac-7: light-dark(#6250d6, #9085e9); --ui-areac-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); container-type: inline-size; }.ui-areac-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-areac-s1 { --c: var(--ui-areac-1); } .ui-areac-s2 { --c: var(--ui-areac-2); } .ui-areac-s3 { --c: var(--ui-areac-3); }.ui-areac-s4 { --c: var(--ui-areac-4); } .ui-areac-s5 { --c: var(--ui-areac-5); } .ui-areac-s6 { --c: var(--ui-areac-6); }.ui-areac-s7 { --c: var(--ui-areac-7); } .ui-areac-s8 { --c: var(--ui-areac-8); }.ui-areac-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 13px; }.ui-areac-legend li { display: inline-flex; align-items: center; gap: 6px; }.ui-areac-key { width: 10px; height: 10px; border-radius: 3px; background: var(--c); flex: none; }.ui-areac-frame { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 8px; padding-top: 8px; }.ui-areac-y { position: relative; min-width: 24px; }.ui-areac-y span { position: absolute; right: 0; transform: translateY(50%); color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-areac-plot { position: relative; }.ui-areac-grid { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-areac-grid.ui-areac-zero { background: var(--ui-strong); }.ui-areac-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }.ui-areac-fill { fill: var(--c); opacity: .16; stroke: none; }.ui-areac-stacked .ui-areac-fill { opacity: .55; }.ui-areac-line { fill: none; stroke: var(--c); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }.ui-areac-cols { position: absolute; top: 0; bottom: 0; display: flex; }.ui-areac-col { position: relative; flex: 1; min-width: 0; }.ui-areac-col::before { content: ""; position: absolute; top: 0; bottom: 0; left: 50%; width: 1px; background: var(--ui-strong); opacity: 0; }.ui-areac-dot { position: absolute; left: 50%; display: none; 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-areac-col:hover::before { opacity: 1; }.ui-areac-col:hover .ui-areac-dot, .ui-areac-col:hover .ui-areac-tip { display: block; }.ui-areac-tip { position: absolute; z-index: 20; top: 0; left: calc(50% + 10px); display: none; min-width: 120px; 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; pointer-events: none; }.ui-areac-flip .ui-areac-tip { left: auto; right: calc(50% + 10px); }.ui-areac-tip strong { display: block; margin-bottom: 3px; font-weight: 600; white-space: nowrap; }.ui-areac-tip p { display: flex; align-items: center; gap: 6px; margin: 2px 0 0; color: var(--ui-muted); white-space: nowrap; }.ui-areac-tip b { margin-left: auto; padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-areac-tip .ui-areac-total { margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--ui-border); }.ui-areac-x { position: relative; height: 16px; margin-top: 6px; }.ui-areac-x span { position: absolute; top: 0; transform: translateX(-50%); color: var(--ui-muted); white-space: nowrap; }.ui-areac-x .ui-areac-first { transform: none; }.ui-areac-x .ui-areac-last { transform: translateX(-100%); }@container (max-width: 520px) { .ui-areac-x .ui-areac-alt { display: none; } }`// n (a whole number, 0 or more) with at least `width` digits.fn area_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 area_chart_group(n: int) -> str = n < 1000 ? str(n) : area_chart_group(floor(n / 1000)) + "," + area_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 area_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)}` : `.${area_chart_pad(frac, digits)}`) `${v < 0 && units > 0 ? "-" : ""}${area_chart_group(floor(units / scale))}${tail}`}// An axis tick, short: 1500 is "1.5k", 2000000 is "2M", 0.25 is "0.25".fn area_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 ? area_chart_num(v) : `${v < 0 ? "-" : ""}${area_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 area_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 area_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 area_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)}view AreaChart(labels: list, values: list? = null, series: list? = null, stacked: bool = false, prefix: str = "", suffix: str = "", decimals: int? = null, height: int = 220, label: str = "Area chart", show_label: bool = false, class: str? = null) { style global area_chart_css let sets = series ?? [{ name: label, values: values ?? [] }] let m = len(sets) let n = len(labels) let fmt = (v) => v == null ? "—" : `${v < 0 ? "-" : ""}${prefix}${area_chart_num(abs(v), decimals)}${suffix}` let part = (s, i) => s.values[i] != null && s.values[i] > 0 ? s.values[i] : 0 // In a stack, acc[i][j] is where series j starts at label i, and acc[i][j + 1] where it ends. let acc = for i in range(n) => reduce(sets, [0], (a, s) => a + [a[-1] + part(s, i)]) let known = flatten(for s in sets => for i in range(n) => s.values[i]) | filter((v) => v != null) let hi0 = stacked ? (n == 0 ? 0 : max(for i in range(n) => acc[i][m])) : (len(known) == 0 ? 0 : max(known)) let lo0 = stacked || len(known) == 0 ? 0 : min(known) let sc = area_chart_scale(lo0 > 0 ? 0 : lo0, hi0 < 0 ? 0 : hi0) let top = (v) => 100 - (v - sc.lo) * 100 / (sc.hi - sc.lo) // from the top, in % let xs = for i in range(n) => n == 1 ? 50 : i * 100 / (n - 1) let base = area_chart_pct(top(0)) let pt = (i, v) => `${area_chart_pct(xs[i])},${area_chart_pct(top(v))}` let edge = (j, i) => stacked ? acc[i][j + 1] : sets[j].values[i] // the top of series j at label i let every_nth = ceil(n / 8) let half = n <= 1 ? 0 : 50 / (n - 1) <figure class={ui_cls(["ui-areac", stacked && "ui-areac-stacked", class])} aria-label={label}> {show_label && <figcaption.ui-areac-title>{label}</figcaption>} {m > 1 && <ul.ui-areac-legend aria-hidden="true"> {for [j, s] in enumerate(sets) => <li><span class={`ui-areac-key ui-areac-s${j % 8 + 1}`}></span>{s.name}</li>} </ul>} <div.ui-areac-frame aria-hidden="true"> <div.ui-areac-y style={`height: ${height}px`}> {for t in sc.ticks => <span style={`bottom: ${area_chart_pct(100 - top(t))}%`}>{t < 0 ? "-" : ""}{prefix}{area_chart_short(abs(t))}</span>} </div> <div.ui-areac-plot style={`height: ${height}px`}> {for t in sc.ticks => <span class={ui_cls(["ui-areac-grid", t == 0 && "ui-areac-zero"])} style={`top: ${area_chart_pct(top(t))}%`}></span>} <svg.ui-areac-svg viewBox="0 0 100 100" preserveAspectRatio="none"> {for [j, s] in enumerate(sets) => { let cls = `ui-areac-s${j % 8 + 1}` stacked ? <g class={cls}> {n > 0 && <path.ui-areac-fill d={`M${join(for i in range(n) => pt(i, acc[i][j + 1]), " L")} L${join(for i in reverse(range(n)) => pt(i, acc[i][j]), " L")} Z`}/>} {n > 0 && <path.ui-areac-line d={"M" + join(for i in range(n) => pt(i, acc[i][j + 1]), " L")} vector-effect="non-scaling-stroke"/>} </g> : <g class={cls}> {for r in area_chart_runs(s.values, n) => <path.ui-areac-fill d={`M${join(for i in r => pt(i, s.values[i]), " L")} L${area_chart_pct(xs[r[-1]])},${base} L${area_chart_pct(xs[r[0]])},${base} Z`}/>} <path.ui-areac-line d={join(for r in area_chart_runs(s.values, n) => "M" + join(for i in r => pt(i, s.values[i]), " L"), " ")} vector-effect="non-scaling-stroke"/> </g> }} </svg> <div.ui-areac-cols style={`left: -${area_chart_pct(half)}%; right: -${area_chart_pct(half)}%`}> {for i in range(n) => <div class={ui_cls(["ui-areac-col", n > 1 && i > (n - 1) / 2 && "ui-areac-flip"])}> {for j in range(m) => edge(j, i) != null && <span class={`ui-areac-dot ui-areac-s${j % 8 + 1}`} style={`top: ${area_chart_pct(top(edge(j, i)))}%`}></span>} <span.ui-areac-tip> <strong>{labels[i]}</strong> {for [j, s] in enumerate(sets) => <p> {m > 1 && <span class={`ui-areac-key ui-areac-s${j % 8 + 1}`}></span>} {m > 1 ? s.name : label}<b>{fmt(s.values[i])}</b> </p>} {stacked && m > 1 && <p.ui-areac-total>Total<b>{fmt(acc[i][m])}</b></p>} </span> </div>} </div> </div> <span></span> <div.ui-areac-x> {for i in range(n) => i % every_nth == 0 && <span class={ui_cls([n > 1 && i == 0 && "ui-areac-first", n > 1 && i == n - 1 && "ui-areac-last", floor(i / every_nth) % 2 == 1 && "ui-areac-alt"])} style={`left: ${area_chart_pct(xs[i])}%`}>{labels[i]}</span>} </div> </div> <table.ui-sr> <caption>{label}</caption> <thead><tr> <th scope="col"></th> {for s in sets => <th scope="col">{m > 1 ? s.name : "Value"}</th>} {stacked && m > 1 && <th scope="col">Total</th>} </tr></thead> <tbody> {for i in range(n) => <tr> <th scope="row">{labels[i]}</th> {for s in sets => <td>{fmt(s.values[i])}</td>} {stacked && m > 1 && <td>{fmt(acc[i][m])}</td>} </tr>} </tbody> </table> </figure>}Related
BulletChartMeasures 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 downloadsComboChartBars 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.6 views · 6 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 AreaChart: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.