Charts

StreamGraph

Series stacked as flowing layers around a centre line, or wiggled flat, with every layer's value at a moment on hover.

#chart#data#no-js

Demo

Preview
Plays by genre, thousands
Plays by genre, thousands
PopRockHip-hopJazzElectronicTotal
W165k50k44k25k24k208k
W266k50k37k20k31k204k
W379k42k34k19k29k203k
W496k40k40k25k21k222k
W5104k45k47k30k19k245k
W6104k52k47k26k26k255k
W7104k51k46k17k31k249k
W8107k45k54k14k28k248k
W9106k46k75k19k21k267k
W1093k55k97k22k20k287k
W1176k63k107k17k27k290k
W1267k62k105k10k31k275k
W1368k58k100k11k28k265k
W1469k60k93k18k26k266k
W1563k67k80k21k35k266k
W1656k70k61k15k55k257k
W1756k64k44k9k71k244k
W1862k55k40k12k70k239k
W1966k53k45k19k59k242k
W2061k57k46k20k49k233k
Visits by source
Visits by source
SearchDirectSocialEmailTotal
Mon4,2002,1009001,8009,000
Tue4,5002,3001,1003008,200
Wed4,4002,2001,5002508,350
Thu4,7002,5002,6001,70011,500
Fri4,1002,0003,1002009,400
Sat2,6001,5003,6001007,800
Sun2,4001,4002,8001206,720

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

Example

The code of the preview above.

demos/stream_graph.qwe
let stream_graph_demo_weeks = for i in range(20) => `W${i + 1}`// A genre's weekly plays: a level, a swell that peaks at week `peak`, and a small ripple.fn stream_graph_demo_plays(level: num, swell: num, peak: num, width: num) -> list =    for i in range(20) => round(level + swell * math.exp(-(i - peak) * (i - peak) / (2 * width * width)) + 6 * math.sin(i * 1.3 + level))view StreamGraphDemo() =    <div.demo-stack>        <StreamGraph label="Plays by genre, thousands" show_label={true} labels={stream_graph_demo_weeks} suffix="k"                     series={[{ name: "Pop", values: stream_graph_demo_plays(60, 50, 6, 3) },                              { name: "Rock", values: stream_graph_demo_plays(45, 20, 14, 4) },                              { name: "Hip-hop", values: stream_graph_demo_plays(40, 70, 11, 2.5) },                              { name: "Jazz", values: stream_graph_demo_plays(15, 10, 3, 3) },                              { name: "Electronic", values: stream_graph_demo_plays(25, 45, 17, 2) }]}/>        <StreamGraph label="Visits by source" show_label={true} offset="wiggle" height={180}                     labels={["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]}                     series={[{ name: "Search", values: [4200, 4500, 4400, 4700, 4100, 2600, 2400] },                              { name: "Direct", values: [2100, 2300, 2200, 2500, 2000, 1500, 1400] },                              { name: "Social", values: [900, 1100, 1500, 2600, 3100, 3600, 2800] },                              { name: "Email", values: [1800, 300, 250, 1700, 200, 100, 120] }]}/>    </div>

Source

ui/stream_graph.qwe
// StreamGraph: several series stacked as flowing layers around a centre line, so// the whole's size and each part's share can be followed over time (listening by// genre, traffic by source). CSS only: hovering a moment shows every layer's value// there, and screen readers get the numbers as a table.////   <StreamGraph label="Plays by genre" labels={weeks} series={[//       { name: "Pop", values: pop }, { name: "Rock", values: rock }, { name: "Jazz", values: jazz }]}/>//   <StreamGraph label="Visits by source" labels={days} series={sources} offset="wiggle" height={180}/>//// labels: the moments, in order (when there are many, only some are written under// it). series: a list of { name, values }, one value per label (up to eight, each// in a colour of its own, with a legend); a null or negative value counts as 0.// offset: "center" (default), the layers piled evenly either side of the centre// line; "wiggle", moved up and down to keep the layers as flat as can be (Byron and// Wattenberg's streamgraph), with no centre line; or "zero", stacked up from a// baseline. prefix and suffix: around every value shown ("$", " GB"); decimals:// digits after the point, always, 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.import ui/theme.qwe use ui_clslet stream_graph_css = css`.ui-stream {  --ui-stream-1: light-dark(#2a78d6, #3987e5); --ui-stream-2: light-dark(#eb6834, #d95926);  --ui-stream-3: light-dark(#1baf7a, #199e70); --ui-stream-4: light-dark(#eda100, #c98500);  --ui-stream-5: light-dark(#e87ba4, #d55181); --ui-stream-6: light-dark(#008300, #008300);  --ui-stream-7: light-dark(#6250d6, #9085e9); --ui-stream-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-stream-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-stream-s1 { --c: var(--ui-stream-1); } .ui-stream-s2 { --c: var(--ui-stream-2); } .ui-stream-s3 { --c: var(--ui-stream-3); }.ui-stream-s4 { --c: var(--ui-stream-4); } .ui-stream-s5 { --c: var(--ui-stream-5); } .ui-stream-s6 { --c: var(--ui-stream-6); }.ui-stream-s7 { --c: var(--ui-stream-7); } .ui-stream-s8 { --c: var(--ui-stream-8); }.ui-stream-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 13px; }.ui-stream-legend li { display: inline-flex; align-items: center; gap: 6px; }.ui-stream-key { width: 10px; height: 10px; border-radius: 3px; background: var(--c); flex: none; }.ui-stream-plot { position: relative; margin-top: 4px; }.ui-stream-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }.ui-stream-layer { fill: var(--c); fill-opacity: .85; stroke: var(--ui-bg); stroke-width: .75; transition: fill-opacity .15s ease; }.ui-stream-centre { position: absolute; left: 0; right: 0; height: 0; border-top: 1px dashed var(--ui-bg); opacity: .7; }.ui-stream-cols { position: absolute; inset: 0; display: flex; }.ui-stream-col { position: relative; flex: 1; min-width: 0; }.ui-stream-col::before { content: ""; position: absolute; top: 0; bottom: 0; left: var(--x, 50%); width: 1px; background: var(--ui-fg); opacity: 0; }.ui-stream-col:first-child:not(:last-child) { flex: .5; --x: 0%; }.ui-stream-col:last-child:not(:first-child) { flex: .5; --x: 100%; }.ui-stream-col:hover::before { opacity: .5; }.ui-stream-col:hover .ui-stream-tip { display: block; }.ui-stream-tip { position: absolute; z-index: 20; 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-stream-tip strong { display: block; margin-bottom: 3px; font-weight: 600; white-space: nowrap; }.ui-stream-tip p { display: flex; align-items: center; gap: 6px; margin: 2px 0 0; color: var(--ui-muted); white-space: nowrap; }.ui-stream-tip b { margin-left: auto; padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-stream-tip { top: 0; left: calc(var(--x, 50%) + 10px); }.ui-stream-flip .ui-stream-tip { left: auto; right: calc(100% - var(--x, 50%) + 10px); }.ui-stream-tip .ui-stream-key { width: 8px; height: 8px; }.ui-stream-tip .ui-stream-sum { margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--ui-border); }.ui-stream-x { position: relative; height: 16px; margin-top: 6px; }.ui-stream-x span { position: absolute; top: 0; transform: translateX(-50%); color: var(--ui-muted); white-space: nowrap; }.ui-stream-x .ui-stream-first { transform: none; }.ui-stream-x .ui-stream-last { transform: translateX(-100%); }@container (max-width: 520px) { .ui-stream-x .ui-stream-alt { display: none; } }@media (prefers-reduced-motion: reduce) { .ui-stream-layer { transition: none; } }`// n (a whole number, 0 or more) with at least `width` digits.fn stream_graph_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 stream_graph_group(n: int) -> str =    n < 1000 ? str(n) : stream_graph_group(floor(n / 1000)) + "," + stream_graph_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 stream_graph_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)}` : `.${stream_graph_pad(frac, digits)}`)    `${v < 0 && units > 0 ? "-" : ""}${stream_graph_group(floor(units / scale))}${tail}`}// A coordinate or a percentage for CSS and SVG, to two decimals.fn stream_graph_pct(n: num) -> str {    let r = round(n * 100) / 100    r == floor(r) ? str(floor(r)) : str(r)}// The bottom of the pile at each moment, for values[j][i] (series j, moment i):// "center" puts the middle of the pile on 0; "wiggle" moves it so the layers'// slopes are least (d3's stackOffsetWiggle); "zero" starts every pile at 0.fn stream_graph_base(values: list, m: int, offset: str) -> list {    let k = len(values)    let totals = for i in range(m) => sum(for j in range(k) => values[j][i])    if offset == "zero" { return for i in range(m) => 0 }    if offset != "wiggle" { return for t in totals => -t / 2 }    let base = [0]    let y = 0    for i in range(1, m) {        let s1 = 0        let s2 = 0        let below = 0        for j in range(k) {            let d = values[j][i] - values[j][i - 1]            s1 += values[j][i]            s2 += values[j][i] * (d / 2 + below)            below += d        }        if s1 > 0 { y -= s2 / s1 }        base = push(base, y)    }    base}view StreamGraph(labels: list, series: list, offset: str = "center", prefix: str = "", suffix: str = "",                 decimals: int? = null, height: int = 220, label: str = "Stream graph", show_label: bool = false,                 class: str? = null) {    style global stream_graph_css    let m = len(labels)    let k = len(series)    let fmt = (v) => v == null ? "—" : `${v < 0 ? "-" : ""}${prefix}${stream_graph_num(abs(v), decimals)}${suffix}`    let vals = for s in series => for i in range(m) => (s.values[i] != null && s.values[i] > 0 ? s.values[i] : 0)    let base = stream_graph_base(vals, m, offset)    // bottoms[j][i], tops[j][i]: series j's layer at moment i.    let bottoms = for j in range(k) => for i in range(m) => base[i] + sum(for q in range(j) => vals[q][i])    let tops = for j in range(k) => for i in range(m) => bottoms[j][i] + vals[j][i]    let hi0 = max((for i in range(m) => base[i] + sum(for j in range(k) => vals[j][i])) + [0])    let lo = offset == "zero" ? 0 : min(base)    let hi = hi0 == lo ? lo + 1 : hi0    let y = (v) => (hi - v) * 100 / (hi - lo)    let x = (i) => m == 1 ? 50 : i * 100 / (m - 1)    let pt = (i, v) => `${stream_graph_pct(x(i))},${stream_graph_pct(y(v))}`    // A smooth edge through the points of `row` at moments `idx`, level at each point.    let edge = (row, idx) => join(for q in range(1, len(idx)) => {        let mid = stream_graph_pct((x(idx[q - 1]) + x(idx[q])) / 2)        ` C${mid},${stream_graph_pct(y(row[idx[q - 1]]))} ${mid},${stream_graph_pct(y(row[idx[q]]))} ${pt(idx[q], row[idx[q]])}`    }, "")    let layer = (j) => m == 0 ? "" : (m == 1        ? `M45,${stream_graph_pct(y(tops[j][0]))} L55,${stream_graph_pct(y(tops[j][0]))} L55,${stream_graph_pct(y(bottoms[j][0]))} L45,${stream_graph_pct(y(bottoms[j][0]))} Z`        : `M${pt(0, tops[j][0])}${edge(tops[j], range(m))} L${pt(m - 1, bottoms[j][m - 1])}${edge(bottoms[j], reverse(range(m)))} Z`)    let every_nth = ceil(m / 8)    <figure class={ui_cls(["ui-stream", class])} aria-label={label}>        {show_label && <figcaption.ui-stream-title>{label}</figcaption>}        <ul.ui-stream-legend aria-hidden="true">            {for [j, s] in enumerate(series) => <li><span class={`ui-stream-key ui-stream-s${j % 8 + 1}`}></span>{s.name}</li>}        </ul>        <div aria-hidden="true">            <div.ui-stream-plot style={`height: ${height}px`}>                <svg.ui-stream-svg viewBox="0 0 100 100" preserveAspectRatio="none">                    {for j in range(k) => <path class={`ui-stream-layer ui-stream-s${j % 8 + 1}`} d={layer(j)} vector-effect="non-scaling-stroke"/>}                </svg>                {offset == "center" && <span.ui-stream-centre style={`top: ${stream_graph_pct(y(0))}%`}></span>}                <div.ui-stream-cols>                    {for i in range(m) => <div class={ui_cls(["ui-stream-col", m > 1 && i > (m - 1) / 2 && "ui-stream-flip"])}>                        <span.ui-stream-tip>                            <strong>{labels[i]}</strong>                            {for j in reverse(range(k)) => <p><span class={`ui-stream-key ui-stream-s${j % 8 + 1}`}></span>{series[j].name}<b>{fmt(series[j].values[i])}</b></p>}                            <p.ui-stream-sum>Total<b>{fmt(sum(for j in range(k) => vals[j][i]))}</b></p>                        </span>                    </div>}                </div>            </div>            <div.ui-stream-x>                {for i in range(m) => i % every_nth == 0 && <span                    class={ui_cls([m > 1 && i == 0 && "ui-stream-first", m > 1 && i == m - 1 && "ui-stream-last",                                   floor(i / every_nth) % 2 == 1 && "ui-stream-alt"])}                    style={`left: ${stream_graph_pct(x(i))}%`}>{labels[i]}</span>}            </div>        </div>        <table.ui-sr>            <caption>{label}</caption>            <thead><tr><th scope="col"></th>{for s in series => <th scope="col">{s.name}</th>}<th scope="col">Total</th></tr></thead>            <tbody>                {for i in range(m) => <tr>                    <th scope="row">{labels[i]}</th>                    {for s in series => <td>{fmt(s.values[i])}</td>}                    <td>{fmt(sum(for j in range(k) => vals[j][i]))}</td>                </tr>}            </tbody>        </table>    </figure>}
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.