Charts

StepChart

Lines that hold each value flat and move in steps, for one or more series, with an optional fill and every value on hover.

#chart#data#no-js

Demo

Preview
Policy rate
Policy rate
Value
Jan4.50%
Feb4.50%
Mar4.25%
Apr4.25%
May4.25%
Jun4.00%
Jul4.00%
Aug3.75%
Sep3.75%
Oct3.75%
Nov3.50%
Dec3.50%
Seats in use
Seats in use
TeamGuests
Mon123
Tue143
Wed146
Thu186
Fri184
Sat91
Sun90
Plan price
Plan price
Value
2019$8
2020$8
2021$10
2022—
2023$12
2024$12
2025$15

Each value holds until the next label; 2022's price is unknown, so the line breaks.

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/step_chart.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/step_chart.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/step_chart.qwe
let step_chart_demo_grid = "display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 32px 40px"view StepChartDemo() =    <div.demo-stack>        <StepChart label="Policy rate" show_label={true} suffix="%" decimals={2}                   labels={["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]}                   values={[4.5, 4.5, 4.25, 4.25, 4.25, 4, 4, 3.75, 3.75, 3.75, 3.5, 3.5]}/>        <div style={step_chart_demo_grid}>            <StepChart label="Seats in use" show_label={true} area={true} height={170}                       labels={["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]}                       series={[{ name: "Team", values: [12, 14, 14, 18, 18, 9, 9] },                                { name: "Guests", values: [3, 3, 6, 6, 4, 1, 0] }]}/>            <StepChart label="Plan price" show_label={true} prefix="$" zero={true} height={170}                       labels={["2019", "2020", "2021", "2022", "2023", "2024", "2025"]}                       values={[8, 8, 10, null, 12, 12, 15]}/>        </div>        <p.demo-note>Each value holds until the next label; 2022's price is unknown, so the line breaks.</p>    </div>

Source

ui/step_chart.qwe
// StepChart: one or more series as lines that hold each value flat across its label's// span and move straight up or down to the next: for prices, plans, stock levels,// anything that changes at a moment and then stays. CSS only: hovering a span shows// every series' value there, and screen readers get the numbers as a table.////   <StepChart label="Price" labels={months} values={[9, 9, 12, 12, 15, 12]} prefix="$"/>//   <StepChart label="Seats in use" labels={days} area={true}//              series={[{ name: "Team A", values: a }, { name: "Team B", values: b }]}/>//// labels: the x axis, in order, each the start of a span; 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). A null leaves a gap. area: fills under each line (and// starts the axis at 0); zero: starts the axis at 0 without the fill; otherwise it// spans the values. 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 step_chart_css = css`.ui-stepc {  --ui-stepc-1: light-dark(#2a78d6, #3987e5); --ui-stepc-2: light-dark(#eb6834, #d95926);  --ui-stepc-3: light-dark(#1baf7a, #199e70); --ui-stepc-4: light-dark(#eda100, #c98500);  --ui-stepc-5: light-dark(#e87ba4, #d55181); --ui-stepc-6: light-dark(#008300, #008300);  --ui-stepc-7: light-dark(#6250d6, #9085e9); --ui-stepc-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-stepc-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-stepc-s1 { --c: var(--ui-stepc-1); } .ui-stepc-s2 { --c: var(--ui-stepc-2); } .ui-stepc-s3 { --c: var(--ui-stepc-3); }.ui-stepc-s4 { --c: var(--ui-stepc-4); } .ui-stepc-s5 { --c: var(--ui-stepc-5); } .ui-stepc-s6 { --c: var(--ui-stepc-6); }.ui-stepc-s7 { --c: var(--ui-stepc-7); } .ui-stepc-s8 { --c: var(--ui-stepc-8); }.ui-stepc-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 13px; }.ui-stepc-legend li { display: inline-flex; align-items: center; gap: 6px; }.ui-stepc-key { width: 14px; height: 3px; border-radius: 2px; background: var(--c); flex: none; }.ui-stepc-frame { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 8px; padding-top: 8px; }.ui-stepc-y { position: relative; min-width: 24px; }.ui-stepc-y span { position: absolute; right: 0; transform: translateY(50%); color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-stepc-plot { position: relative; }.ui-stepc-grid { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-stepc-grid.ui-stepc-zero { background: var(--ui-strong); }.ui-stepc-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }.ui-stepc-line { fill: none; stroke: var(--c); stroke-width: 2; stroke-linejoin: round; }.ui-stepc-area { fill: var(--c); opacity: .14; stroke: none; }.ui-stepc-cols { position: absolute; inset: 0; display: flex; }.ui-stepc-col { position: relative; flex: 1; min-width: 0; }.ui-stepc-col:hover { background: color-mix(in srgb, var(--ui-subtle) 60%, transparent); }.ui-stepc-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-stepc-col:hover .ui-stepc-dot, .ui-stepc-col:hover .ui-stepc-tip { display: block; }.ui-stepc-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-stepc-flip .ui-stepc-tip { left: auto; right: calc(50% + 10px); }.ui-stepc-tip strong { display: block; margin-bottom: 3px; font-weight: 600; white-space: nowrap; }.ui-stepc-tip p { display: flex; align-items: center; gap: 6px; margin: 2px 0 0; color: var(--ui-muted); white-space: nowrap; }.ui-stepc-tip b { margin-left: auto; padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-stepc-x { display: flex; margin-top: 6px; }.ui-stepc-x span { flex: 1; min-width: 0; overflow: visible; color: var(--ui-muted); text-align: center; white-space: nowrap; }@container (max-width: 520px) { .ui-stepc-x .ui-stepc-alt { visibility: hidden; } }`// n (a whole number, 0 or more) with at least `width` digits.fn step_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 step_chart_group(n: int) -> str =    n < 1000 ? str(n) : step_chart_group(floor(n / 1000)) + "," + step_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 step_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)}` : `.${step_chart_pad(frac, digits)}`)    `${v < 0 && units > 0 ? "-" : ""}${step_chart_group(floor(units / scale))}${tail}`}// An axis tick, short: 1500 is "1.5k", 2000000 is "2M", 0.25 is "0.25".fn step_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 ? step_chart_num(v)        : `${v < 0 ? "-" : ""}${step_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 step_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 step_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 step_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 StepChart(labels: list, values: list? = null, series: list? = null, area: bool = false,               zero: bool = false, prefix: str = "", suffix: str = "", decimals: int? = null,               height: int = 220, label: str = "Step chart", show_label: bool = false, class: str? = null) {    style global step_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}${step_chart_num(abs(v), decimals)}${suffix}`    let known = flatten(for s in sets => for i in range(n) => s.values[i]) | filter((v) => v != null)    let lo0 = len(known) == 0 ? 0 : min(known)    let hi0 = len(known) == 0 ? 0 : max(known)    let from_zero = zero || area    let sc = step_chart_scale(from_zero && lo0 > 0 ? 0 : lo0, from_zero && hi0 < 0 ? 0 : hi0)    let top = (v) => 100 - (v - sc.lo) * 100 / (sc.hi - sc.lo)      // from the top, in %    let x0 = (i) => step_chart_pct(i * 100 / n)    let x1 = (i) => step_chart_pct((i + 1) * 100 / n)    let base = step_chart_pct(sc.lo <= 0 && sc.hi >= 0 ? top(0) : 100)    // A run as a step line: flat across each span, straight up or down between them.    let steps = (s, r) => `M${x0(r[0])},${step_chart_pct(top(s.values[r[0]]))} H${x1(r[0])}`        + join(for i in drop(r, 1) => ` V${step_chart_pct(top(s.values[i]))} H${x1(i)}`, "")    let every_nth = ceil(n / 10)    <figure class={ui_cls(["ui-stepc", class])} aria-label={label}>        {show_label && <figcaption.ui-stepc-title>{label}</figcaption>}        {m > 1 && <ul.ui-stepc-legend aria-hidden="true">            {for [j, s] in enumerate(sets) => <li><span class={`ui-stepc-key ui-stepc-s${j % 8 + 1}`}></span>{s.name}</li>}        </ul>}        <div.ui-stepc-frame aria-hidden="true">            <div.ui-stepc-y style={`height: ${height}px`}>                {for t in sc.ticks => <span style={`bottom: ${step_chart_pct(100 - top(t))}%`}>{t < 0 ? "-" : ""}{prefix}{step_chart_short(abs(t))}</span>}            </div>            <div.ui-stepc-plot style={`height: ${height}px`}>                {for t in sc.ticks => <span class={ui_cls(["ui-stepc-grid", t == 0 && "ui-stepc-zero"])}                                            style={`top: ${step_chart_pct(top(t))}%`}></span>}                <div.ui-stepc-cols>                    {for i in range(n) => <div class={ui_cls(["ui-stepc-col", i >= n / 2 && n > 1 && "ui-stepc-flip"])}>                        {for [j, s] in enumerate(sets) => s.values[i] != null && <span class={`ui-stepc-dot ui-stepc-s${j % 8 + 1}`}                            style={`top: ${step_chart_pct(top(s.values[i]))}%`}></span>}                        <span.ui-stepc-tip>                            <strong>{labels[i]}</strong>                            {for [j, s] in enumerate(sets) => <p>                                {m > 1 && <span class={`ui-stepc-key ui-stepc-s${j % 8 + 1}`}></span>}                                {m > 1 ? s.name : label}<b>{fmt(s.values[i])}</b>                            </p>}                        </span>                    </div>}                </div>                <svg.ui-stepc-svg viewBox="0 0 100 100" preserveAspectRatio="none" style="pointer-events: none">                    {for [j, s] in enumerate(sets) => {                        let runs = step_chart_runs(s.values, n)                        <g class={`ui-stepc-s${j % 8 + 1}`}>                            {area && (for r in runs => <path.ui-stepc-area d={`${steps(s, r)} V${base} H${x0(r[0])} Z`}/>)}                            <path.ui-stepc-line d={join(for r in runs => steps(s, r), " ")} vector-effect="non-scaling-stroke"/>                        </g>                    }}                </svg>            </div>            <span></span>            <div.ui-stepc-x>                {for i in range(n) => <span class={ui_cls([floor(i / every_nth) % 2 == 1 && "ui-stepc-alt"])}                    style={i % every_nth == 0 ? null : "visibility: hidden"}>{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>}</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>}                </tr>}            </tbody>        </table>    </figure>}
Thread
64Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.