Charts

LineChart

Lines for one or more series over the same labels, with an area fill or points, a hover crosshair with every value, and a legend.

#chart#data#no-js

Demo

Preview
Visitors per day
Value
Oct 15820
Oct 16932
Oct 17901
Oct 181,034
Oct 191,290
Oct 201,102
Oct 21980
Oct 221,130
Oct 231,245
Oct 241,198
Oct 251,402
Oct 261,530
Oct 271,288
Oct 281,615
Response time
Response time
p50p95p99
00:00118 ms310 ms540 ms
04:00112 ms288 ms502 ms
08:00131 ms362 ms690 ms
12:00146 ms421 ms812 ms
16:00139 ms398 ms760 ms
20:00124 ms334 ms610 ms
Monthly revenue
Monthly revenue
20252026
Jan$41,200$48,900
Feb$39,800$51,200
Mar$44,500$55,800
Apr$47,100—
May$46,200$60,400
Jun$50,300$63,100
Jul$52,800$66,900

April 2026 is missing, so its line has 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/line_chart.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/line_chart.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/line_chart.qwe
let line_chart_demo_days = for i in range(14) => `Oct ${i + 15}`view LineChartDemo() =    <div.demo-stack>        <LineChart label="Visitors per day" labels={line_chart_demo_days} area={true}                   values={[820, 932, 901, 1034, 1290, 1102, 980, 1130, 1245, 1198, 1402, 1530, 1288, 1615]}/>        <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 32px 40px">            <LineChart label="Response time" show_label={true} labels={["00:00", "04:00", "08:00", "12:00", "16:00", "20:00"]}                       suffix=" ms" points={true} height={180}                       series={[{ name: "p50", values: [118, 112, 131, 146, 139, 124] },                                { name: "p95", values: [310, 288, 362, 421, 398, 334] },                                { name: "p99", values: [540, 502, 690, 812, 760, 610] }]}/>            <LineChart label="Monthly revenue" show_label={true} labels={["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"]} prefix="$"                       zero={true} height={180}                       series={[{ name: "2025", values: [41200, 39800, 44500, 47100, 46200, 50300, 52800] },                                { name: "2026", values: [48900, 51200, 55800, null, 60400, 63100, 66900] }]}/>        </div>        <p.demo-note>April 2026 is missing, so its line has a gap.</p>    </div>

Source

ui/line_chart.qwe
// LineChart: one or more series over the same labels, as lines on an axis with// round ticks. CSS only: hovering shows a crosshair and every series' value there,// and screen readers get the numbers as a table.////   <LineChart label="Visitors" labels={days} values={[820, 932, 901, 1034, 1290]} area={true}/>//   <LineChart label="Response time" labels={hours} suffix=" ms" points={true}//              series={[{ name: "p50", values: p50 }, { name: "p95", values: p95 }]}/>//// 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). A null value leaves a gap in its line. 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. points: a dot on every value. prefix and suffix: around every// value shown ("$", " ms"; 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 line_chart_css = css`.ui-lc { --ui-lc-1: light-dark(#2a78d6, #3987e5); --ui-lc-2: light-dark(#eb6834, #d95926);  --ui-lc-3: light-dark(#1baf7a, #199e70); --ui-lc-4: light-dark(#eda100, #c98500);  --ui-lc-5: light-dark(#e87ba4, #d55181); --ui-lc-6: light-dark(#008300, #008300);  --ui-lc-7: light-dark(#6250d6, #9085e9); --ui-lc-8: light-dark(#e34948, #e66767);  display: grid; gap: 10px; min-width: 0; margin: 0; font: 12px/1.3 var(--ui-font); color: var(--ui-fg);  container-type: inline-size; }.ui-lc-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-lc-s1 { --c: var(--ui-lc-1); } .ui-lc-s2 { --c: var(--ui-lc-2); } .ui-lc-s3 { --c: var(--ui-lc-3); }.ui-lc-s4 { --c: var(--ui-lc-4); } .ui-lc-s5 { --c: var(--ui-lc-5); } .ui-lc-s6 { --c: var(--ui-lc-6); }.ui-lc-s7 { --c: var(--ui-lc-7); } .ui-lc-s8 { --c: var(--ui-lc-8); }.ui-lc-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 13px; }.ui-lc-legend li { display: inline-flex; align-items: center; gap: 6px; }.ui-lc-key { width: 14px; height: 3px; border-radius: 2px; background: var(--c); flex: none; }.ui-lc-frame { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 8px; padding-top: 8px; }.ui-lc-y { position: relative; min-width: 24px; }.ui-lc-y span { position: absolute; right: 0; transform: translateY(50%); color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-lc-plot { position: relative; }.ui-lc-grid { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-lc-grid.ui-lc-zero { background: var(--ui-strong); }.ui-lc-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }.ui-lc-line { fill: none; stroke: var(--c); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }.ui-lc-area { fill: var(--c); opacity: .12; stroke: none; }.ui-lc-pt, .ui-lc-dot { 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-lc-pt { width: 6px; height: 6px; margin: -3px 0 0 -3px; }.ui-lc-cols { position: absolute; top: 0; bottom: 0; display: flex; }.ui-lc-col { position: relative; flex: 1; min-width: 0; }.ui-lc-col::before { content: ""; position: absolute; top: 0; bottom: 0; left: 50%; width: 1px; background: var(--ui-strong); opacity: 0; }.ui-lc-col .ui-lc-dot { left: 50%; display: none; }.ui-lc-col:hover::before { opacity: 1; }.ui-lc-col:hover .ui-lc-dot, .ui-lc-col:hover .ui-lc-tip { display: block; }.ui-lc-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-lc-flip .ui-lc-tip { left: auto; right: calc(50% + 10px); }.ui-lc-tip strong { display: block; margin-bottom: 3px; font-weight: 600; white-space: nowrap; }.ui-lc-tip p { display: flex; align-items: center; gap: 6px; margin: 2px 0 0; color: var(--ui-muted); white-space: nowrap; }.ui-lc-tip b { margin-left: auto; padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-lc-x { position: relative; height: 16px; margin-top: 6px; }.ui-lc-x span { position: absolute; top: 0; transform: translateX(-50%); color: var(--ui-muted); white-space: nowrap; }.ui-lc-x .ui-lc-first { transform: none; }.ui-lc-x .ui-lc-last { transform: translateX(-100%); }@container (max-width: 520px) { .ui-lc-x .ui-lc-alt { display: none; } }`// n (a whole number, 0 or more) with at least `width` digits.fn line_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 line_chart_group(n: int) -> str =    n < 1000 ? str(n) : line_chart_group(floor(n / 1000)) + "," + line_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 line_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)}` : `.${line_chart_pad(frac, digits)}`)    `${v < 0 && units > 0 ? "-" : ""}${line_chart_group(floor(units / scale))}${tail}`}// An axis tick, short: 1500 is "1.5k", 2000000 is "2M", 0.25 is "0.25".fn line_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 ? line_chart_num(v)        : `${v < 0 ? "-" : ""}${line_chart_group(floor(tenths / 10))}${tenths % 10 != 0 ? `.${tenths % 10}` : ""}${mark}`}// A percentage (or a coordinate out of 100), to two decimals.fn line_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 line_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 line_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 LineChart(labels: list, values: list? = null, series: list? = null, area: bool = false,               zero: bool = false, points: bool = false, prefix: str = "", suffix: str = "",               decimals: int? = null, height: int = 220, label: str = "Line chart", show_label: bool = false,               class: str? = null) {    style global line_chart_css    let sets = series ?? [{ name: label, values: values ?? [] }]    let n = len(labels)    let fmt = (v) => v == null ? "—" : `${v < 0 ? "-" : ""}${prefix}${line_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 = line_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 xs = for i in range(n) => n == 1 ? 50 : i * 100 / (n - 1)    let base = line_chart_pct(sc.lo <= 0 && sc.hi >= 0 ? top(0) : 100)    let every_nth = ceil(n / 8)    let half = n <= 1 ? 0 : 50 / (n - 1)    <figure class={ui_cls(["ui-lc", class])} aria-label={label}>        {show_label && <figcaption.ui-lc-title>{label}</figcaption>}        {len(sets) > 1 && <ul.ui-lc-legend aria-hidden="true">            {for [j, s] in enumerate(sets) => <li><span class={`ui-lc-key ui-lc-s${j % 8 + 1}`}></span>{s.name}</li>}        </ul>}        <div.ui-lc-frame aria-hidden="true">            <div.ui-lc-y style={`height: ${height}px`}>                {for t in sc.ticks => <span style={`bottom: ${line_chart_pct(100 - top(t))}%`}>{t < 0 ? "-" : ""}{prefix}{line_chart_short(abs(t))}</span>}            </div>            <div.ui-lc-plot style={`height: ${height}px`}>                {for t in sc.ticks => <span class={ui_cls(["ui-lc-grid", t == 0 && "ui-lc-zero"])}                                            style={`top: ${line_chart_pct(top(t))}%`}></span>}                <svg.ui-lc-svg viewBox="0 0 100 100" preserveAspectRatio="none">                    {for [j, s] in enumerate(sets) => {                        let pt = (i) => `${line_chart_pct(xs[i])},${line_chart_pct(top(s.values[i]))}`                        let runs = line_chart_runs(s.values, n)                        <g class={`ui-lc-s${j % 8 + 1}`}>                            {area && (for r in runs => <path.ui-lc-area                                d={`M${join(map(r, pt), " L")} L${line_chart_pct(xs[r[-1]])},${base} L${line_chart_pct(xs[r[0]])},${base} Z`}/>)}                            <path.ui-lc-line d={join(for r in runs => "M" + join(map(r, pt), " L"), " ")}                                             vector-effect="non-scaling-stroke"/>                        </g>                    }}                </svg>                {points && (for [j, s] in enumerate(sets) => for i in range(n) =>                    s.values[i] != null && <span class={`ui-lc-pt ui-lc-s${j % 8 + 1}`}                        style={`left: ${line_chart_pct(xs[i])}%; top: ${line_chart_pct(top(s.values[i]))}%`}></span>)}                <div.ui-lc-cols style={`left: -${line_chart_pct(half)}%; right: -${line_chart_pct(half)}%`}>                    {for i in range(n) => <div class={ui_cls(["ui-lc-col", n > 1 && i > (n - 1) / 2 && "ui-lc-flip"])}>                        {for [j, s] in enumerate(sets) => s.values[i] != null && <span class={`ui-lc-dot ui-lc-s${j % 8 + 1}`}                            style={`top: ${line_chart_pct(top(s.values[i]))}%`}></span>}                        <span.ui-lc-tip>                            <strong>{labels[i]}</strong>                            {for [j, s] in enumerate(sets) => <p>                                {len(sets) > 1 && <span class={`ui-lc-key ui-lc-s${j % 8 + 1}`}></span>}                                {len(sets) > 1 ? s.name : label}<b>{fmt(s.values[i])}</b>                            </p>}                        </span>                    </div>}                </div>            </div>            <span></span>            <div.ui-lc-x>                {for i in range(n) => i % every_nth == 0 && <span                    class={ui_cls([n > 1 && i == 0 && "ui-lc-first", n > 1 && i == n - 1 && "ui-lc-last",                                   floor(i / every_nth) % 2 == 1 && "ui-lc-alt"])}                    style={`left: ${line_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">{len(sets) > 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
56Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.