Watchlist
Symbols with last price, change, percent and a sparkline, in columns that sort, with prices that flash as they change.
Demo
| Trend | ||||
|---|---|---|---|---|
| ACMEAcme Corp. | $181.97 | +$1.82 | +1.01% | |
| GLBXGlobex | $48.61 | -$0.41 | -0.84% | |
| INITInitech | $114.60 | +$0.21 | +0.18% | |
| UMBRUmbrella | $57.72 | -$1.42 | -2.40% | |
| STRKStark Industries | $123.52 | +$3.67 | +3.06% | |
| HOOLHooli | $88.10 | $0.00 | 0.00% |
Picked ACME. Prices move every 1.1 s (0 so far); click a heading to sort.
| STRKStark Industries | 123.52 | +3.67 | +3.06% |
|---|---|---|---|
| ACMEAcme Corp. | 182.40 | +2.25 | +1.25% |
| INITInitech | 114.74 | +0.35 | +0.31% |
| HOOLHooli | 88.10 | 0.00 | 0.00% |
| GLBXGlobex | 48.61 | -0.41 | -0.84% |
| UMBRUmbrella | 57.72 | -1.42 | -2.40% |
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/watchlist.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/watchlist.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/watchlist.qwe
let watchlist_demo_start = [ { symbol: "ACME", name: "Acme Corp.", price: 182.4, prev: 180.15 }, { symbol: "GLBX", name: "Globex", price: 48.61, prev: 49.02 }, { symbol: "INIT", name: "Initech", price: 114.74, prev: 114.39 }, { symbol: "UMBR", name: "Umbrella", price: 57.72, prev: 59.14 }, { symbol: "STRK", name: "Stark Industries", price: 123.52, prev: 119.85 }, { symbol: "HOOL", name: "Hooli", price: 88.1, prev: 88.1 },]// Each tick of a seeded random walk (Park–Miller) moves two of the prices by a little.fn watchlist_demo_tick(items: list, n: int) -> list { let s = (n * n * 7919 + n * 104729 + 3) % 2147483647 let rnd = () => { s = s * 16807 % 2147483647 s / 2147483647 } let a = floor(rnd() * len(items)) let b = floor(rnd() * len(items)) for [k, it] in enumerate(items) => { let moved = k == a || k == b ? round(it.price * (1 + (rnd() - 0.5) * 0.01) * 100) / 100 : it.price { ...it, price: moved, values: take(reverse([moved] + reverse(get(it, "values") ?? [it.prev, it.price])), 30) } }}component WatchlistDemoLive() { state items = watchlist_demo_tick(watchlist_demo_start, 0) state n = 0 state live = true state picked = "ACME" if live { every(1100, () => { n += 1 items = watchlist_demo_tick(items, n) }) } <div.demo-stack id="watchlist-demo-live"> <Watchlist items={items} prefix="$" selected="ACME" label="My watchlist" on_select={(s) => { picked = s }}/> <p.demo-note>Picked {picked}. Prices move every 1.1 s ({n} so far); click a heading to sort.</p> <div.demo-row> <Button size="sm" variant="outline" id="watchlist-demo-toggle" on_click={() => { live = !live }}>{live ? "Stop the feed" : "Start the feed"}</Button> </div> </div>}view WatchlistDemo() = <div.demo-stack> <WatchlistDemoLive/> <div id="watchlist-demo-sorted" class="demo-narrow" style="max-width: 520px"> <Watchlist label="Sorted by % change" sort="percent" desc={true} items={watchlist_demo_start}/> </div> </div>Source
ui/watchlist.qwe
// Watchlist: symbols to keep an eye on, each with its last price, the change and// percent change on the previous close (green or red) and a sparkline, in a table// whose columns sort with a click (again to reverse). When a price changes its cell// flashes, green for up and red for down. A row can be picked, with a click or Enter.//// <Watchlist items={quotes} prefix="$" on_select={(s) => { symbol = s }}/>//// items: a list of { symbol, name, price, prev, values }: price the last price, prev// the previous close (the change is worked out from it), values the prices for the// sparkline, oldest first (optional). Give a new list as prices change. sort: the// column to start sorted by ("symbol", "price", "change" or "percent"; none keeps the// order given) and desc: largest first. selected: the symbol picked to start with.// prefix and suffix: around prices ("$"); decimals: digits after the point (2).// label: what screen readers call the table ("Watchlist"). on_select(symbol): called// with the symbol of the row picked.import ui/theme.qwe use ui_clslet watchlist_css = css`.ui-wlist { min-width: 0; overflow-x: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); font: 13px/1.3 var(--ui-font); color: var(--ui-fg); }.ui-wlist-table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }.ui-wlist-table th, .ui-wlist-table td { padding: 8px 12px; text-align: right; white-space: nowrap; }.ui-wlist-table thead th { padding: 0; border-bottom: 1px solid var(--ui-border); background: var(--ui-subtle); }.ui-wlist-table th:first-child, .ui-wlist-table td:first-child { text-align: left; }.ui-wlist-sort { display: inline-flex; align-items: center; justify-content: inherit; gap: 4px; width: 100%; padding: 8px 12px; border: 0; background: none; color: var(--ui-muted); font: 600 11px/1 var(--ui-font); letter-spacing: .04em; text-transform: uppercase; cursor: pointer; }th:not(:first-child) > .ui-wlist-sort { justify-content: flex-end; }.ui-wlist-sort:hover { color: var(--ui-fg); }.ui-wlist-sort:focus-visible { outline: none; box-shadow: inset var(--ui-focus); }.ui-wlist-sort svg { width: 8px; height: 8px; fill: currentColor; opacity: .35; }[aria-sort] > .ui-wlist-sort { color: var(--ui-fg); }[aria-sort] > .ui-wlist-sort svg { opacity: 1; }[aria-sort="descending"] > .ui-wlist-sort svg { transform: rotate(180deg); }.ui-wlist-plain { padding: 8px 12px; color: var(--ui-muted); font: 600 11px/1 var(--ui-font); letter-spacing: .04em; text-transform: uppercase; }.ui-wlist-row { cursor: pointer; }.ui-wlist-row:not(:last-child) > * { border-bottom: 1px solid color-mix(in srgb, var(--ui-border) 60%, transparent); }.ui-wlist-row:hover { background: var(--ui-subtle); }.ui-wlist-row:focus-visible { outline: none; box-shadow: inset var(--ui-focus); }.ui-wlist-row[aria-selected="true"] { background: var(--ui-accent-soft); }.ui-wlist-row[aria-selected="true"] > :first-child { box-shadow: inset 3px 0 0 var(--ui-accent); }.ui-wlist-sym b { display: block; font-weight: 650; }.ui-wlist-sym { font-weight: 400; }.ui-wlist-sym small { color: var(--ui-muted); font-size: 12px; }.ui-wlist-up { --k: var(--ui-success); } .ui-wlist-down { --k: var(--ui-danger); } .ui-wlist-flat { --k: var(--ui-muted); }.ui-wlist-chg { color: var(--k); font-weight: 600; }.ui-wlist-pill { display: inline-block; min-width: 64px; padding: 2px 6px; border-radius: 4px; background: color-mix(in srgb, var(--k) 14%, transparent); color: var(--k); font-weight: 600; text-align: right; }.ui-wlist-price { display: inline-block; padding: 2px 4px; border-radius: 3px; font-weight: 600; }.ui-wlist-tick-up { animation: ui-wlist-up 1s ease-out; } .ui-wlist-tick-down { animation: ui-wlist-down 1s ease-out; }@keyframes ui-wlist-up { from { background: color-mix(in srgb, var(--ui-success) 40%, transparent); } }@keyframes ui-wlist-down { from { background: color-mix(in srgb, var(--ui-danger) 40%, transparent); } }.ui-wlist-spark { display: block; width: 88px; height: 26px; margin-left: auto; overflow: visible; }.ui-wlist-spark path { fill: none; stroke: var(--k); stroke-width: 1.5; stroke-linejoin: round; }.ui-wlist-spark line { stroke: var(--ui-strong); stroke-dasharray: 2 2; }@media (prefers-reduced-motion: reduce) { .ui-wlist-tick-up, .ui-wlist-tick-down { animation: none; } }`// n (a whole number, 0 or more) with at least `width` digits.fn watchlist_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 watchlist_group(n: int) -> str = n < 1000 ? str(n) : watchlist_group(floor(n / 1000)) + "," + watchlist_pad(n % 1000, 3)// A value as it reads, with commas and `places` digits after the point (up to two as// needed when that is null): 48210.5 is "48,210.50" with 2. Worked out in whole// numbers, so the server and the browser write the same text.fn watchlist_num(v: num, places: int? = null) -> str { let digits = places == null ? 2 : (places < 0 ? 0 : (places > 6 ? 6 : places)) let scale = [1, 10, 100, 1000, 10000, 100000, 1000000][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)}` : `.${watchlist_pad(frac, digits)}`) `${v < 0 && units > 0 ? "-" : ""}${watchlist_group(floor(units / scale))}${tail}`}// A change in percent, signed, to two decimals: "+1.25%", "-0.40%".fn watchlist_signed(p: num) -> str = `${p > 0 ? "+" : (p < 0 && round(abs(p) * 100) > 0 ? "-" : "")}${watchlist_num(abs(p), 2)}%`// A coordinate out of 100 (or a length), to two decimals.fn watchlist_pct(n: num) -> str { let r = round(n * 100) / 100 r == floor(r) ? str(floor(r)) : str(r)}component Watchlist(items: list = [], sort: str? = null, desc: bool = false, selected: str? = null, prefix: str = "", suffix: str = "", decimals: int = 2, label: str = "Watchlist", on_select = null, class: str? = null) { style global watchlist_css state by = sort state down = desc state picked = selected // The price last shown for each symbol, so a change can flash up or down. state seen = {} let rows = items | map((it) => { let change = it.prev != null ? it.price - it.prev : 0 { ...it, change, percent: it.prev != null && it.prev != 0 ? change * 100 / it.prev : 0 } }) let ordered = by == null ? rows : rows | sort_by((r) => get(r, by)) let shown = by != null && down ? reverse(ordered) : ordered let money = (v) => `${v < 0 ? "-" : ""}${prefix}${watchlist_num(abs(v), decimals)}${suffix}` let side = (r) => round(r.change * pow(10, decimals)) == 0 ? "ui-wlist-flat" : (r.change > 0 ? "ui-wlist-up" : "ui-wlist-down") let pick = (s: str) => { picked = s on_select && on_select(s) } let head = (key: str, text: str) => <th scope="col" aria-sort={by == key ? (down ? "descending" : "ascending") : null}> <button.ui-wlist-sort type="button" @click={() => { if by == key { down = !down } else { by = key down = key != "symbol" } }}>{text}<svg viewBox="0 0 8 8" aria-hidden="true"><path d="M0 6 4 1 8 6Z"/></svg></button> </th> let spark = (vals: list, prev) => { let n = len(vals) let lo = min(vals + (prev != null ? [prev] : [])) let hi = max(vals + (prev != null ? [prev] : [])) let y = (v) => hi == lo ? 13 : 2 + (hi - v) * 22 / (hi - lo) let d = "M" + join(range(n) | map((i) => `${watchlist_pct(n == 1 ? 88 : i * 88 / (n - 1))},${watchlist_pct(y(vals[i]))}`), " L") <svg.ui-wlist-spark viewBox="0 0 88 26" preserveAspectRatio="none" aria-hidden="true"> {prev != null && <line x1="0" x2="88" y1={watchlist_pct(y(prev))} y2={watchlist_pct(y(prev))} vector-effect="non-scaling-stroke"/>} <path d={d} vector-effect="non-scaling-stroke"/> </svg> } let trend = any(rows, (r) => len(get(r, "values") ?? []) > 1) <div class={ui_cls(["ui-wlist", class])}> <table.ui-wlist-table aria-label={label}> <thead><tr> {head("symbol", "Symbol")}{head("price", "Last")}{head("change", "Change")}{head("percent", "% Chg")} {trend && <th scope="col"><span.ui-wlist-plain>Trend</span></th>} </tr></thead> <tbody> {for r in shown => <tr key={r.symbol} class={`ui-wlist-row ${side(r)}`} tabindex="0" data-symbol={r.symbol} aria-selected={picked == r.symbol ? "true" : "false"} @click={() => { pick(r.symbol) }} @keydown={(e) => { if e.key == "Enter" || e.key == " " { e.preventDefault() pick(r.symbol) } if e.key == "ArrowDown" || e.key == "ArrowUp" { e.preventDefault() let next = e.key == "ArrowDown" ? e.target.nextElementSibling : e.target.previousElementSibling next && next.focus() } }}> <th scope="row" class="ui-wlist-sym"><b>{r.symbol}</b>{r.name != null && <small>{r.name}</small>}</th> <td><span.ui-wlist-price key={`${r.symbol}|${r.price}`} @mount={(el) => { let before = get(seen, r.symbol) if before != null && before != r.price { el.classList.add(r.price > before ? "ui-wlist-tick-up" : "ui-wlist-tick-down") } seen = set(seen, r.symbol, r.price) }}>{money(r.price)}</span></td> <td.ui-wlist-chg>{r.change > 0 ? "+" : ""}{money(r.change)}</td> <td><span.ui-wlist-pill>{watchlist_signed(r.percent)}</span></td> {trend && <td>{len(get(r, "values") ?? []) > 1 && spark(get(r, "values"), r.prev)}</td>} </tr>} </tbody> </table> </div>}Related
OrderBookAn order book's bid and ask ladders with size bars, the spread and mid price, grouping into coarser steps, and levels that flash as they change.6 views · 6 downloadsPortfolioAllocationA donut of a portfolio's holdings beside a table of each one's weight, value and gain, with the totals.6 views · 6 downloadsTradeFeedTime and sales: trades as they happen, newest first, buys green and sells red, large ones picked out, capped at a number of rows.6 views · 10 downloadsDataGridSort, search, filter by a column's values, pick rows and page through them, in one table.5 views · 8 downloadsDataTableRows of data sorted by their headers, with search, pages, number, money, percent and date columns.5 views · 9 downloadsBollingerChartPrices with Bollinger bands of a period and a number of deviations, closes outside them marked, as a line or candles.7 views · 5 downloads
Thread
Thread 0 posts
On Watchlist: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.