Charts

PortfolioAllocation

A donut of a portfolio's holdings beside a table of each one's weight, value and gain, with the totals.

#chart#data#trading#table#no-js

Demo

Preview
Retirement account
Retirement account: 6 holdings
HoldingWeightValueGain
NOVANova Systems28.1%$24,738.00+$5,838.00+30.89%
BONDTotal Bond Fund24.4%$21,500.00-$900.00-4.02%
WRLDWorld Index Fund17.9%$15,800.00+$2,700.00+20.61%
ACMEAcme Corp.16.5%$14,592.00+$2,382.00+19.51%
GLBXGlobex8.3%$7,291.50-$728.50-9.08%
CASHCash4.8%$4,250.00$0.000.00%
Total100%$88,171.50+$9,291.50+11.78%
Eleven coins, no costs
Eleven coins, no costs: 11 holdings
HoldingWeightValue
BTC55.8%$30,625
ETH22%$12,050
SOL8.3%$4,560
BNB5.3%$2,900
XRP2.8%$1,560
ADA1.8%$980
DOT1.3%$740
Other4 more2.7%$1,480
Total100%$54,895

Hover a holding, on the ring or in the table, to pick it out.

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

Example

The code of the preview above.

demos/portfolio_allocation.qwe
view PortfolioAllocationDemo() =    <div.demo-stack>        <div id="portfolio_allocation-demo-main">            <PortfolioAllocation label="Retirement account" show_label={true} prefix="$" holdings={[                { symbol: "NOVA", name: "Nova Systems", shares: 60, price: 412.3, cost: 18900 },                { symbol: "BOND", name: "Total Bond Fund", value: 21500, cost: 22400 },                { symbol: "ACME", name: "Acme Corp.", shares: 80, price: 182.4, cost: 12210 },                { symbol: "WRLD", name: "World Index Fund", value: 15800, cost: 13100 },                { symbol: "GLBX", name: "Globex", shares: 150, price: 48.61, cost: 8020 },                { symbol: "CASH", name: "Cash", value: 4250, cost: 4250 }]}/>        </div>        <div id="portfolio_allocation-demo-many">            <PortfolioAllocation label="Eleven coins, no costs" show_label={true} prefix="$" decimals={0} size={140} holdings={[                { symbol: "BTC", value: 30625 }, { symbol: "ETH", value: 12050 }, { symbol: "SOL", value: 4560 },                { symbol: "BNB", value: 2900 }, { symbol: "XRP", value: 1560 }, { symbol: "ADA", value: 980 },                { symbol: "DOT", value: 740 }, { symbol: "LINK", value: 610 }, { symbol: "AVAX", value: 420 },                { symbol: "ATOM", value: 300 }, { symbol: "DOGE", value: 150 }]}/>        </div>        <p.demo-note>Hover a holding, on the ring or in the table, to pick it out.</p>    </div>

Source

ui/portfolio_allocation.qwe
// PortfolioAllocation: what a portfolio holds: a donut of each holding's weight, the// total value in its middle, and beside it a table of each holding's weight, value// and gain (in money and percent, green or red), with the totals. CSS only: hovering// a holding, on the ring or in the table, picks it out on both and puts its weight in// the middle.////   <PortfolioAllocation label="My portfolio" prefix="$" holdings={[//       { symbol: "ACME", name: "Acme Corp.", shares: 40, price: 182.4, cost: 6120 },//       { symbol: "CASH", name: "Cash", value: 2500 }]}/>//// holdings: a list of { symbol, name, value, cost }, or with shares and price in place// of value. cost: what was paid; the gain is value less cost (none shown without it).// They are listed by value, largest first; past eight, the eighth and the rest are// counted as "Other". prefix and suffix: around every amount ("$"); decimals: digits// after the point (2). size: the donut's width in pixels (180). label: what it shows,// for screen readers and as the table's caption; show_label also writes it above.import ui/theme.qwe use ui_clslet portfolio_allocation_css = css`.ui-palloc { --ui-palloc-1: light-dark(#2a78d6, #3987e5); --ui-palloc-2: light-dark(#eb6834, #d95926);  --ui-palloc-3: light-dark(#1baf7a, #199e70); --ui-palloc-4: light-dark(#eda100, #c98500);  --ui-palloc-5: light-dark(#e87ba4, #d55181); --ui-palloc-6: light-dark(#008300, #008300);  --ui-palloc-7: light-dark(#6250d6, #9085e9); --ui-palloc-8: var(--ui-strong);  display: flex; flex-wrap: wrap; align-items: center; gap: 20px 32px; min-width: 0; margin: 0; font: 13px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-palloc-title { flex-basis: 100%; margin: 0; font-size: 14px; font-weight: 600; }.ui-palloc-s1 { --c: var(--ui-palloc-1); } .ui-palloc-s2 { --c: var(--ui-palloc-2); } .ui-palloc-s3 { --c: var(--ui-palloc-3); }.ui-palloc-s4 { --c: var(--ui-palloc-4); } .ui-palloc-s5 { --c: var(--ui-palloc-5); } .ui-palloc-s6 { --c: var(--ui-palloc-6); }.ui-palloc-s7 { --c: var(--ui-palloc-7); } .ui-palloc-s8 { --c: var(--ui-palloc-8); }.ui-palloc-up { --k: var(--ui-success); } .ui-palloc-down { --k: var(--ui-danger); }.ui-palloc-ring { position: relative; flex: none; max-width: 100%; aspect-ratio: 1; }.ui-palloc-ring svg { display: block; width: 100%; height: 100%; overflow: visible; transform: rotate(-90deg); }.ui-palloc-track { fill: none; stroke: var(--ui-subtle); stroke-width: 16; }.ui-palloc-seg { fill: none; stroke: var(--c); stroke-width: 16; transition: stroke-width .15s ease, opacity .15s ease; }.ui-palloc-center { position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none; text-align: center; }.ui-palloc-center > span { grid-area: 1 / 1; display: grid; max-width: 66%; }.ui-palloc-center > span:not(.ui-palloc-sum) { visibility: hidden; }.ui-palloc-center b { font-size: 19px; font-weight: 650; line-height: 1.15; font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-palloc-center small { overflow: hidden; color: var(--ui-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.ui-palloc-center .ui-palloc-up, .ui-palloc-center .ui-palloc-down { color: var(--k); }.ui-palloc-table { flex: 1 1 340px; min-width: 0; border-collapse: collapse; font-variant-numeric: tabular-nums; }.ui-palloc-table caption { padding-bottom: 6px; color: var(--ui-muted); font-size: 12px; text-align: left; }.ui-palloc-table th, .ui-palloc-table td { padding: 7px 8px; border-bottom: 1px solid var(--ui-border); text-align: right; white-space: nowrap; }.ui-palloc-table thead th { color: var(--ui-muted); font-size: 12px; font-weight: 500; }.ui-palloc-table th:first-child { text-align: left; }.ui-palloc-table tbody th { font-weight: 400; }.ui-palloc-table tfoot th, .ui-palloc-table tfoot td { border-bottom: 0; font-weight: 650; }.ui-palloc-row > * { transition: opacity .15s ease; }.ui-palloc-hold { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }.ui-palloc-key { flex: none; width: 10px; height: 10px; border-radius: 3px; background: var(--c); }.ui-palloc-hold b { font-weight: 650; }.ui-palloc-hold small { overflow: hidden; color: var(--ui-muted); font-size: 12px; text-overflow: ellipsis; }.ui-palloc-weight { display: inline-flex; align-items: center; justify-content: flex-end; gap: 8px; }.ui-palloc-bar { width: 56px; height: 6px; border-radius: 3px; background: var(--ui-subtle); overflow: hidden; }.ui-palloc-bar span { display: block; height: 100%; background: var(--c); }.ui-palloc-gain { color: var(--k); }.ui-palloc-gain small { margin-left: 6px; font-size: 12px; opacity: .85; }.ui-palloc:has(.ui-palloc-p1:hover) :is(.ui-palloc-seg, .ui-palloc-row > *):not(.ui-palloc-p1, .ui-palloc-p1 > *),.ui-palloc:has(.ui-palloc-p2:hover) :is(.ui-palloc-seg, .ui-palloc-row > *):not(.ui-palloc-p2, .ui-palloc-p2 > *),.ui-palloc:has(.ui-palloc-p3:hover) :is(.ui-palloc-seg, .ui-palloc-row > *):not(.ui-palloc-p3, .ui-palloc-p3 > *),.ui-palloc:has(.ui-palloc-p4:hover) :is(.ui-palloc-seg, .ui-palloc-row > *):not(.ui-palloc-p4, .ui-palloc-p4 > *),.ui-palloc:has(.ui-palloc-p5:hover) :is(.ui-palloc-seg, .ui-palloc-row > *):not(.ui-palloc-p5, .ui-palloc-p5 > *),.ui-palloc:has(.ui-palloc-p6:hover) :is(.ui-palloc-seg, .ui-palloc-row > *):not(.ui-palloc-p6, .ui-palloc-p6 > *),.ui-palloc:has(.ui-palloc-p7:hover) :is(.ui-palloc-seg, .ui-palloc-row > *):not(.ui-palloc-p7, .ui-palloc-p7 > *),.ui-palloc:has(.ui-palloc-p8:hover) :is(.ui-palloc-seg, .ui-palloc-row > *):not(.ui-palloc-p8, .ui-palloc-p8 > *) { opacity: .3; }.ui-palloc:has(.ui-palloc-p:hover) .ui-palloc-sum { visibility: hidden; }.ui-palloc:has(.ui-palloc-p1:hover) .ui-palloc-c1, .ui-palloc:has(.ui-palloc-p2:hover) .ui-palloc-c2,.ui-palloc:has(.ui-palloc-p3:hover) .ui-palloc-c3, .ui-palloc:has(.ui-palloc-p4:hover) .ui-palloc-c4,.ui-palloc:has(.ui-palloc-p5:hover) .ui-palloc-c5, .ui-palloc:has(.ui-palloc-p6:hover) .ui-palloc-c6,.ui-palloc:has(.ui-palloc-p7:hover) .ui-palloc-c7, .ui-palloc:has(.ui-palloc-p8:hover) .ui-palloc-c8 { visibility: visible; }.ui-palloc:has(.ui-palloc-p1:hover) .ui-palloc-seg.ui-palloc-p1, .ui-palloc:has(.ui-palloc-p2:hover) .ui-palloc-seg.ui-palloc-p2,.ui-palloc:has(.ui-palloc-p3:hover) .ui-palloc-seg.ui-palloc-p3, .ui-palloc:has(.ui-palloc-p4:hover) .ui-palloc-seg.ui-palloc-p4,.ui-palloc:has(.ui-palloc-p5:hover) .ui-palloc-seg.ui-palloc-p5, .ui-palloc:has(.ui-palloc-p6:hover) .ui-palloc-seg.ui-palloc-p6,.ui-palloc:has(.ui-palloc-p7:hover) .ui-palloc-seg.ui-palloc-p7, .ui-palloc:has(.ui-palloc-p8:hover) .ui-palloc-seg.ui-palloc-p8 { stroke-width: 20; }@media (prefers-reduced-motion: reduce) { .ui-palloc-seg, .ui-palloc-row > * { transition: none; } }`// n (a whole number, 0 or more) with at least `width` digits.fn portfolio_allocation_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 portfolio_allocation_group(n: int) -> str =    n < 1000 ? str(n) : portfolio_allocation_group(floor(n / 1000)) + "," + portfolio_allocation_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 portfolio_allocation_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)}` : `.${portfolio_allocation_pad(frac, digits)}`)    `${v < 0 && units > 0 ? "-" : ""}${portfolio_allocation_group(floor(units / scale))}${tail}`}// A large number, short: 1500 is "1.5k", 2000000 is "2M", 0.25 is "0.25".fn portfolio_allocation_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 ? portfolio_allocation_num(v)        : `${v < 0 ? "-" : ""}${portfolio_allocation_group(floor(tenths / 10))}${tenths % 10 != 0 ? `.${tenths % 10}` : ""}${mark}`}// A change in percent, signed, to two decimals: "+1.25%", "-0.40%".fn portfolio_allocation_signed(p: num) -> str = `${p > 0 ? "+" : (p < 0 && round(abs(p) * 100) > 0 ? "-" : "")}${portfolio_allocation_num(abs(p), 2)}%`// A coordinate out of 100 (or a length), to two decimals.fn portfolio_allocation_pct(n: num) -> str {    let r = round(n * 100) / 100    r == floor(r) ? str(floor(r)) : str(r)}// A share out of 100, to one decimal: "42%", "12.5%".fn portfolio_allocation_share(p: num) -> str {    let tenths = round(p * 10)    `${floor(tenths / 10)}${tenths % 10 != 0 ? `.${tenths % 10}` : ""}%`}view PortfolioAllocation(holdings: list, prefix: str = "", suffix: str = "", decimals: int = 2, size: int = 180,                         label: str = "Portfolio", show_label: bool = false, class: str? = null) {    style global portfolio_allocation_css    let valued = holdings | map((h) => { ...h, value: h.value ?? (h.shares ?? 0) * (h.price ?? 0) })        | filter((h) => h.value > 0) | sort_by((h) => 0 - h.value)    let rest = drop(valued, 7)    let rest_cost = all(rest, (h) => h.cost != null) ? sum(rest | map((h) => h.cost)) : null    let shown = len(valued) > 8        ? take(valued, 7) + [{ symbol: "Other", name: `${len(rest)} more`, value: sum(rest | map((h) => h.value)), cost: rest_cost }]        : valued    let total = sum(shown | map((h) => h.value))    let costed = all(shown, (h) => h.cost != null)    let total_cost = costed ? sum(shown | map((h) => h.cost)) : null    let money = (v) => `${v < 0 ? "-" : ""}${prefix}${portfolio_allocation_num(abs(v), decimals)}${suffix}`    let gain = (v) => `${v > 0 ? "+" : ""}${money(v)}`    let share = (v) => total > 0 ? v * 100 / total : 0    let side = (v) => v >= 0 ? "ui-palloc-up" : "ui-palloc-down"    let r = 42    let gap = len(shown) > 1 ? 0.6 : 0    let starts = reduce(shown, [], (acc, h) => acc + [len(acc) == 0 ? 0 : acc[-1] + share(shown[len(acc) - 1].value)])    let len3 = (v) => portfolio_allocation_pct(v)    let pct_gain = (g, c) => c != 0 ? portfolio_allocation_signed(g * 100 / c) : "—"    <figure class={ui_cls(["ui-palloc", class])} aria-label={`${label}, ${money(total)}`}>        {show_label && <figcaption.ui-palloc-title>{label}</figcaption>}        <div.ui-palloc-ring style={`width: ${size}px`} aria-hidden="true">            <svg viewBox="0 0 100 100">                <circle.ui-palloc-track cx="50" cy="50" r={str(r)}/>                {for [i, h] in enumerate(shown) => <circle class={`ui-palloc-seg ui-palloc-p ui-palloc-p${i + 1} ui-palloc-s${i + 1}`}                    cx="50" cy="50" r={str(r)} pathLength="100"                    stroke-dasharray={`${len3(share(h.value) - gap)} ${len3(100 - share(h.value) + gap)}`}                    stroke-dashoffset={len3(0 - starts[i] - gap / 2)}>                    <title>{h.symbol}: {money(h.value)} ({portfolio_allocation_share(share(h.value))})</title>                </circle>}            </svg>            <div.ui-palloc-center>                <span.ui-palloc-sum>                    <b>{money(total)}</b>                    {costed ? <small class={side(total - total_cost)}>{gain(total - total_cost)} ({pct_gain(total - total_cost, total_cost)})</small>                            : <small>Total</small>}                </span>                {for [i, h] in enumerate(shown) => <span class={`ui-palloc-c${i + 1}`}>                    <b>{portfolio_allocation_share(share(h.value))}</b><small>{h.symbol}</small>                </span>}            </div>        </div>        <table.ui-palloc-table>            <caption><span.ui-sr>{label}: </span>{len(valued)} {len(valued) == 1 ? "holding" : "holdings"}</caption>            <thead><tr><th scope="col">Holding</th><th scope="col">Weight</th><th scope="col">Value</th>                {costed && <th scope="col">Gain</th>}</tr></thead>            <tbody>                {for [i, h] in enumerate(shown) => <tr class={`ui-palloc-row ui-palloc-p ui-palloc-p${i + 1} ui-palloc-s${i + 1}`}>                    <th scope="row"><span.ui-palloc-hold>                        <span.ui-palloc-key aria-hidden="true"></span><b>{h.symbol}</b>{h.name != null && <small>{h.name}</small>}                    </span></th>                    <td><span.ui-palloc-weight>                        <span.ui-palloc-bar aria-hidden="true"><span style={`width: ${len3(share(h.value))}%`}></span></span>                        {portfolio_allocation_share(share(h.value))}                    </span></td>                    <td>{money(h.value)}</td>                    {costed && <td class={`ui-palloc-gain ${side(h.value - h.cost)}`}>{gain(h.value - h.cost)}<small>{pct_gain(h.value - h.cost, h.cost)}</small></td>}                </tr>}            </tbody>            <tfoot><tr>                <th scope="row">Total</th><td>100%</td><td>{money(total)}</td>                {costed && <td class={`ui-palloc-gain ${side(total - total_cost)}`}>{gain(total - total_cost)}<small>{pct_gain(total - total_cost, total_cost)}</small></td>}            </tr></tfoot>        </table>    </figure>}
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.