RangeChart
A low and a high per label as floating bars or a band, with an optional middle value, and each range on hover.
Demo
| Low | Value | High | |
|---|---|---|---|
| Jan | -6° | -2° | 2° |
| Feb | -5° | -1° | 4° |
| Mar | -1° | 4° | 9° |
| Apr | 4° | 9° | 15° |
| May | 9° | 15° | 21° |
| Jun | 13° | 19° | 25° |
| Jul | 15° | 21° | 28° |
| Aug | 14° | 20° | 27° |
| Sep | 10° | 16° | 22° |
| Oct | 5° | 10° | 15° |
| Nov | 0° | 4° | 8° |
| Dec | -4° | -1° | 3° |
| Low | Value | High | |
|---|---|---|---|
| Jul | $41,000 | $41,000 | $41,000 |
| Aug | $42,500 | $44,200 | $46,100 |
| Sep | $43,100 | $46,300 | $49,800 |
| Oct | $43,900 | $48,500 | $53,600 |
| Nov | $44,200 | $50,800 | $57,900 |
| Dec | $44,800 | $53,100 | $62,500 |
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/range_chart.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/range_chart.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/range_chart.qwe
let range_chart_demo_months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]let range_chart_demo_temps = [[-6, 2, -2], [-5, 4, -1], [-1, 9, 4], [4, 15, 9], [9, 21, 15], [13, 25, 19], [15, 28, 21], [14, 27, 20], [10, 22, 16], [5, 15, 10], [0, 8, 4], [-4, 3, -1]]view RangeChartDemo() = <div.demo-stack> <RangeChart label="Temperature in Montreal, low to high" show_label={true} suffix="°" value_labels={true} items={for [i, m] in enumerate(range_chart_demo_months) => { label: m, low: range_chart_demo_temps[i][0], high: range_chart_demo_temps[i][1], value: range_chart_demo_temps[i][2] }}/> <RangeChart label="Monthly revenue forecast" show_label={true} band={true} prefix="$" color={7} height={200} items={[{ label: "Jul", low: 41000, high: 41000, value: 41000 }, { label: "Aug", low: 42500, high: 46100, value: 44200 }, { label: "Sep", low: 43100, high: 49800, value: 46300 }, { label: "Oct", low: 43900, high: 53600, value: 48500 }, { label: "Nov", low: 44200, high: 57900, value: 50800 }, { label: "Dec", low: 44800, high: 62500, value: 53100 }]}/> </div>Source
ui/range_chart.qwe
// RangeChart: a low and a high for each label, as floating bars or as a band across// them (temperatures by month, estimates with their margin, a price's daily range),// with an optional middle value marked in each. CSS only: hovering a label shows its// range, and screen readers get the numbers as a table.//// <RangeChart label="Temperature" suffix="°" items={[// { label: "Jan", low: 8, high: 15, value: 11 }, { label: "Feb", low: 9, high: 16, value: 12 }]}/>// <RangeChart label="Forecast" band={true} items={forecast} prefix="$" decimals={0}/>//// items: a list of { label, low, high, value }, in order; value (optional) is a// middle figure (an average, the estimate) marked across the bar, or drawn as a line// through the band; an item without low or high leaves a gap. band: a filled band// from each low to each high instead of bars. value_labels: the high written over// each bar and the low under it. color: 1 to 8, from the chart palette (1, blue).// zero: the axis starts at 0; otherwise it spans the values. prefix and suffix:// around every value shown ("$", "°"; the axis takes the prefix); 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 range_chart_css = css`.ui-rangec { --ui-rangec-1: light-dark(#2a78d6, #3987e5); --ui-rangec-2: light-dark(#eb6834, #d95926); --ui-rangec-3: light-dark(#1baf7a, #199e70); --ui-rangec-4: light-dark(#eda100, #c98500); --ui-rangec-5: light-dark(#e87ba4, #d55181); --ui-rangec-6: light-dark(#008300, #008300); --ui-rangec-7: light-dark(#6250d6, #9085e9); --ui-rangec-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); }.ui-rangec-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-rangec-s1 { --c: var(--ui-rangec-1); } .ui-rangec-s2 { --c: var(--ui-rangec-2); } .ui-rangec-s3 { --c: var(--ui-rangec-3); }.ui-rangec-s4 { --c: var(--ui-rangec-4); } .ui-rangec-s5 { --c: var(--ui-rangec-5); } .ui-rangec-s6 { --c: var(--ui-rangec-6); }.ui-rangec-s7 { --c: var(--ui-rangec-7); } .ui-rangec-s8 { --c: var(--ui-rangec-8); }.ui-rangec-frame { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 8px; padding-top: 8px; }.ui-rangec-y { position: relative; min-width: 24px; }.ui-rangec-y span { position: absolute; right: 0; transform: translateY(50%); color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-rangec-plot { position: relative; }.ui-rangec-grid { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-rangec-grid.ui-rangec-zero { background: var(--ui-strong); }.ui-rangec-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }.ui-rangec-area { fill: var(--c); opacity: .22; stroke: none; }.ui-rangec-edge { fill: none; stroke: var(--c); stroke-width: 1.5; opacity: .7; stroke-linejoin: round; }.ui-rangec-line { fill: none; stroke: var(--c); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }.ui-rangec-cols { position: absolute; inset: 0; display: flex; }.ui-rangec-col { position: relative; flex: 1; min-width: 0; border-radius: 4px; }.ui-rangec-col:hover { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-rangec-bar { position: absolute; left: 50%; bottom: var(--a); height: var(--b); min-height: 2px; width: min(46%, 22px); border-radius: 999px; background: var(--c); transform: translateX(-50%); }.ui-rangec-mark { position: absolute; left: 50%; bottom: var(--a); width: min(46%, 22px); height: 0; border-top: 2px solid var(--ui-bg); transform: translate(-50%, 50%); }.ui-rangec-dot { position: absolute; left: 50%; bottom: var(--a); display: none; width: 8px; height: 8px; margin: 0 0 -4px -4px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 2px var(--ui-bg); }.ui-rangec-col:hover .ui-rangec-dot { display: block; }.ui-rangec-col:hover .ui-rangec-bar { filter: brightness(1.1); }.ui-rangec-val { position: absolute; left: 50%; bottom: calc(var(--a) + 4px); transform: translateX(-50%); color: var(--ui-muted); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-rangec-val.ui-rangec-under { bottom: auto; top: calc(100% - var(--a) + 4px); }.ui-rangec-x { display: flex; margin-top: 6px; }.ui-rangec-x span { flex: 1; min-width: 0; padding: 0 2px; overflow: hidden; color: var(--ui-muted); text-align: center; text-overflow: ellipsis; white-space: nowrap; }.ui-rangec-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-rangec-tip strong { display: block; margin-bottom: 3px; font-weight: 600; white-space: nowrap; }.ui-rangec-tip p { display: flex; align-items: center; gap: 6px; margin: 2px 0 0; color: var(--ui-muted); white-space: nowrap; }.ui-rangec-tip b { margin-left: auto; padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-rangec-tip { top: 0; left: 50%; transform: translate(-50%, -100%); }.ui-rangec-col:first-child .ui-rangec-tip { left: 0; transform: translateY(-100%); }.ui-rangec-col:last-child .ui-rangec-tip { left: auto; right: 0; transform: translateY(-100%); }.ui-rangec-col:hover .ui-rangec-tip { display: block; }`// n (a whole number, 0 or more) with at least `width` digits.fn range_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 range_chart_group(n: int) -> str = n < 1000 ? str(n) : range_chart_group(floor(n / 1000)) + "," + range_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 range_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)}` : `.${range_chart_pad(frac, digits)}`) `${v < 0 && units > 0 ? "-" : ""}${range_chart_group(floor(units / scale))}${tail}`}// A coordinate or a percentage for CSS and SVG, to two decimals.fn range_chart_pct(n: num) -> str { let r = round(n * 100) / 100 r == floor(r) ? str(floor(r)) : str(r)}// An axis tick, short: 1500 is "1.5k", 2000000 is "2M", 0.25 is "0.25".fn range_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 ? range_chart_num(v) : `${v < 0 ? "-" : ""}${range_chart_group(floor(tenths / 10))}${tenths % 10 != 0 ? `.${tenths % 10}` : ""}${mark}`}// The axis from lo to hi, widened to round ticks: about `steps` steps of 1, 2, 2.5 or 5 x 10^n.fn range_chart_scale(lo: num, hi: num, steps: int = 4) -> map { let span = hi - lo == 0 ? (lo == 0 ? 1 : abs(lo)) : hi - lo let raw = span / steps 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 }}view RangeChart(items: list, band: bool = false, value_labels: bool = false, color: int = 1, zero: bool = false, prefix: str = "", suffix: str = "", decimals: int? = null, height: int = 220, label: str = "Range chart", show_label: bool = false, class: str? = null) { style global range_chart_css let n = len(items) let fmt = (v) => v == null ? "—" : `${v < 0 ? "-" : ""}${prefix}${range_chart_num(abs(v), decimals)}${suffix}` let whole = (it) => it.low != null && it.high != null let known = flatten(for it in items => [it.low, it.high, it.value]) | filter((v) => v != null) let lo0 = len(known) == 0 ? 0 : min(known) let hi0 = len(known) == 0 ? 1 : max(known) let sc = range_chart_scale(zero && lo0 > 0 ? 0 : lo0, zero && hi0 < 0 ? 0 : hi0) let pos = (v) => (v - sc.lo) * 100 / (sc.hi - sc.lo) let tone = `ui-rangec-s${color < 1 || color > 8 ? 1 : color}` let xs = for i in range(n) => (i + 0.5) * 100 / n // The runs of items with both ends, as lists of indexes, for the band. let runs = (pick) => { let starts = range(n) | filter((i) => pick(items[i]) && (i == 0 || !pick(items[i - 1]))) for st in starts => range(st, (find(range(st, n), (k) => k == n - 1 || !pick(items[k + 1])) ?? st) + 1) } let pt = (i, v) => `${range_chart_pct(xs[i])},${range_chart_pct(100 - pos(v))}` <figure class={ui_cls(["ui-rangec", tone, class])} aria-label={label}> {show_label && <figcaption.ui-rangec-title>{label}</figcaption>} <div.ui-rangec-frame aria-hidden="true"> <div.ui-rangec-y style={`height: ${height}px`}> {for t in sc.ticks => <span style={`bottom: ${range_chart_pct(pos(t))}%`}>{t < 0 ? "-" : ""}{prefix}{range_chart_short(abs(t))}</span>} </div> <div.ui-rangec-plot style={`height: ${height}px`}> {for t in sc.ticks => <span class={ui_cls(["ui-rangec-grid", t == 0 && "ui-rangec-zero"])} style={`bottom: ${range_chart_pct(pos(t))}%`}></span>} {band && <svg.ui-rangec-svg viewBox="0 0 100 100" preserveAspectRatio="none"> {for r in runs(whole) => [ <path.ui-rangec-area d={`M${join(map(r, (i) => pt(i, items[i].high)), " L")} L${join(map(reverse(r), (i) => pt(i, items[i].low)), " L")} Z`}/>, <path.ui-rangec-edge d={`M${join(map(r, (i) => pt(i, items[i].high)), " L")}`} vector-effect="non-scaling-stroke"/>, <path.ui-rangec-edge d={`M${join(map(r, (i) => pt(i, items[i].low)), " L")}`} vector-effect="non-scaling-stroke"/> ]} {for r in runs((it) => it.value != null) => <path.ui-rangec-line d={`M${join(map(r, (i) => pt(i, items[i].value)), " L")}`} vector-effect="non-scaling-stroke"/>} </svg>} <div.ui-rangec-cols> {for [i, it] in enumerate(items) => <div.ui-rangec-col> {!band && whole(it) && <span.ui-rangec-bar style={`--a: ${range_chart_pct(pos(it.low))}%; --b: ${range_chart_pct(pos(it.high) - pos(it.low))}%`}></span>} {!band && whole(it) && it.value != null && <span.ui-rangec-mark style={`--a: ${range_chart_pct(pos(it.value))}%`}></span>} {band && (for v in [it.high, it.value, it.low] => v != null && <span.ui-rangec-dot style={`--a: ${range_chart_pct(pos(v))}%`}></span>)} {value_labels && whole(it) && [ <span.ui-rangec-val style={`--a: ${range_chart_pct(pos(it.high))}%`}>{fmt(it.high)}</span>, <span class="ui-rangec-val ui-rangec-under" style={`--a: ${range_chart_pct(pos(it.low))}%`}>{fmt(it.low)}</span> ]} <span.ui-rangec-tip> <strong>{it.label}</strong> <p>High<b>{fmt(it.high)}</b></p> {it.value != null && <p>Value<b>{fmt(it.value)}</b></p>} <p>Low<b>{fmt(it.low)}</b></p> {whole(it) && <p>Range<b>{fmt(it.high - it.low)}</b></p>} </span> </div>} </div> </div> <span></span> <div.ui-rangec-x>{for it in items => <span title={str(it.label)}>{it.label}</span>}</div> </div> <table.ui-sr> <caption>{label}</caption> <thead><tr> <th scope="col"></th><th scope="col">Low</th> {any(items, (it) => it.value != null) && <th scope="col">Value</th>}<th scope="col">High</th> </tr></thead> <tbody> {for it in items => <tr> <th scope="row">{it.label}</th><td>{fmt(it.low)}</td> {any(items, (x) => x.value != null) && <td>{fmt(it.value)}</td>}<td>{fmt(it.high)}</td> </tr>} </tbody> </table> </figure>}Related
AreaChartFilled areas for one or more series, overlapping or stacked into a total, with a hover crosshair listing every value.6 views · 8 downloadsBarChartBars up or across, one series or several grouped or stacked, on round ticks, with values on hover and a table for screen readers.7 views · 7 downloadsBollingerChartPrices with Bollinger bands of a period and a number of deviations, closes outside them marked, as a line or candles.7 views · 5 downloadsBoxPlotQuartiles, median, whiskers and outliers for each group, worked out from the raw values, with the five numbers on hover.5 views · 7 downloadsBrushChartA large chart of a range with an overview below, where a window dragged or moved by keys picks the range shown.6 views · 5 downloadsBubbleChartPoints placed by two numbers and sized by a third, in coloured groups, labelled when big enough, with all three numbers on hover.7 views · 8 downloads
Thread
Thread 0 posts
On RangeChart: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.