Charts

AreaChart

Filled areas for one or more series, overlapping or stacked into a total, with a hover crosshair listing every value.

#chart#data#dashboard#no-js

Demo

Preview
Sessions per day
Value
Oct 11,240
Oct 21,380
Oct 31,310
Oct 41,520
Oct 51,890
Oct 61,720
Oct 71,460
Oct 81,610
Oct 91,830
Oct 102,040
Oct 111,980
Oct 122,310
Traffic by device
Traffic by device
DesktopMobileTabletTotal
Jan5,2003,9008009,900
Feb5,4004,30076010,460
Mar5,1004,80082010,720
Apr5,6005,20079011,590
May5,9005,80085012,550
Jun6,1006,40090013,400
Storage used
Storage used
This weekLast week
Mon41.5 GB36.1 GB
Tue42.2 GB37.4 GB
Wed43.8 GB38 GB
Thu44.1 GB38.9 GB
Fri—39.6 GB
Sat46.9 GB40.2 GB
Sun47.3 GB41.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.qwe
main.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>}
Thread
68Useful? Like it, or add to its thread below.

Thread 0 posts

On AreaChart: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.