Hue draws · Charts · Recipes
Every chart here is a function you can read.
Each one is a short function against the same small API. Pick a recipe and it grows on whichever renderer your machine can give it — then read the code that drew it, in full, right below the chart.
Candlestick
a live market
Twenty-six sessions, and the last one is still forming — its close walks and its wick stretches to contain it, while everything behind it is settled history that never moves.
Renderer
Output
Theme
Renderer got
—
Output
idle
Fit
off
Theme
dark
The code that drew it
recCandles · the exact code that drew this chart
import { v3, scale, norm, easeInOut } from "@hue/charts";
import type { ChartWorld, V3 } from "@hue/charts";
/** The plot's model-space box. Every recipe draws inside it, so cameras are comparable. */
const SPAN = 220, HEIGHT = 120, BASE = -SPAN / 2;
const col = (hue: number, l = 60) => `hsl(${hue} 85% ${l}%)`;
const xAt = (i: number, n: number) => BASE + (i / (n - 1)) * SPAN;
const yAt = (v: number, max: number) => (v / max) * HEIGHT;
/** A label is annotation, not prose — short and upper case, so it stops competing with the copy. */
const tag = (s: string) => s.toUpperCase();
const INK_H = 45, INK_S = 25, INK_L = 84;
const inkHi = (w: ChartWorld) =>
w.theme === "light" ? "rgb(8 9 11 / 0.92)" : "rgb(232 230 225 / 0.92)";
const inkMid = (w: ChartWorld) =>
w.theme === "light" ? "rgb(8 9 11 / 0.72)" : "rgb(232 230 225 / 0.62)";
const inkDim = (w: ChartWorld) =>
w.theme === "light" ? "rgb(8 9 11 / 0.5)" : "rgb(232 230 225 / 0.42)";
/** The six categorical slots, as `[hue, darkL, lightL]`. */
const SLOTS: readonly (readonly [number, number, number])[] = [
[180, 32, 31], // 1 · cyan — the store's hue, and the brand's own first
[49, 34, 30], // 2 · gold
[115, 25, 21], // 3 · green
[213, 40, 34], // 4 · blue
[311, 37, 29], // 5 · magenta
[344, 62, 59], // 6 · rose
];
const SERIES_MAX = SLOTS.length;
const series = (w: ChartWorld, i: number, l?: number): string => {
const [hue, dark, light] = SLOTS[Math.min(Math.max(i, 0), SERIES_MAX - 1)]!;
return `hsl(${hue} 85% ${l ?? (w.theme === "light" ? light : dark)}%)`;
};
/** The hue of a slot, for the verbs that take a number (`glow`, `column`, `bar`, `LineOpts`). */
const seriesHue = (i: number): number => SLOTS[Math.min(Math.max(i, 0), SERIES_MAX - 1)]![0];
const seqTone = (w: ChartWorld, t: number): { hue: number; sat: number; light: number } => {
const u = Math.min(Math.max(t, 0), 1);
const [lo, hi] = w.theme === "light" ? [35, 8] : [52, 16];
return { hue: 180, sat: 85, light: lo + (hi - lo) * u };
};
const seq = (w: ChartWorld, t: number): string => {
const o = seqTone(w, t);
return `hsl(${o.hue} ${o.sat}% ${o.light}%)`;
};
const div = (w: ChartWorld, t: number): string => {
const u = Math.min(Math.max(t, -1), 1);
const light = w.theme === "light";
if (Math.abs(u) < 0.04) return light ? "hsl(0 0% 85%)" : "hsl(0 0% 18%)";
const [hue, near, far] = u < 0 ? [213, 34, 46] : [344, 34, 46];
return `hsl(${hue} 85% ${near + (far - near) * Math.abs(u)}%)`;
};
const status = (w: ChartWorld, kind: "refused" | "live" | "absent"): string => {
const l = w.theme === "light" ? 42 : 58;
if (kind === "refused") return `hsl(42 85% ${l}%)`;
if (kind === "live") return `hsl(115 85% ${l}%)`;
return `hsl(${INK_H} ${INK_S}% ${w.theme === "light" ? 30 : 62}%)`;
};
/**
* The stage: lights and a raking key, and deliberately NO decorative grid.
* Gridlines are not decoration — each one is a VALUE, and lines at arbitrary spacing tell a reader
* the spacing means something when it does not. The real grid is in `axis()`, at tick values only.
*/
function setup(w: ChartWorld) {
w.cam.target = v3(0, HEIGHT * 0.42, 0);
w.light.signed = false;
w.light.dir = norm(v3(-0.4, 1, 0.35));
w.light.ambient = 0.56;
w.light.diffuse = 0.5;
w.lights.push({ p: v3(80, 260, 140), intensity: 0.5, falloff: 700 });
}
/**
* "Nice" ticks — 1, 2 or 5 × a power of ten. Not aesthetic: dividing a max of 118 into five equal
* parts gives ticks at 23.6, 47.2, 70.8 — perfect arithmetic and unreadable, because nobody holds
* 23.6 in their head to compare two points against. 25/50/75/100 costs a little headroom and buys
* the reader an axis they can actually use.
*/
function niceTicks(max: number, target = 4): { ticks: number[]; top: number } {
if (!(max > 0)) return { ticks: [0], top: 1 };
const raw = max / target;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const n = raw / mag;
const step = (n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10) * mag;
const top = Math.ceil(max / step) * step;
const ticks: number[] = [];
for (let v = 0; v <= top + 1e-9; v += step) ticks.push(Number(v.toPrecision(12)));
return { ticks, top };
}
/** Compact axis numbers — 1.2k rather than 1200. An axis is read at a glance or not at all. */
const tickText = (v: number) =>
Math.abs(v) >= 1_000_000
? `${(v / 1_000_000).toFixed(v % 1_000_000 ? 1 : 0)}M`
: Math.abs(v) >= 1000
? `${(v / 1000).toFixed(v % 1000 ? 1 : 0)}k`
: `${Math.round(v * 100) / 100}`;
/**
* The value axis. A gridline at every tick, its number beside it, and the zero line drawn heavier
* because zero is the only position on a chart that is a fact rather than a choice.
*
* Returns the ROUNDED TOP it actually drew — scale everything against that, never the raw data max,
* or the tallest point sits exactly on the ceiling and the top gridline means nothing.
*/
function axis(w: ChartWorld, max: number, unit?: string): number {
const { ticks, top } = niceTicks(max);
const x0 = BASE - 16, x1 = -BASE + 16;
const yOf = (t: number) => (t / top) * HEIGHT;
for (const t of ticks) {
const y = yOf(t);
if (t !== 0) {
w.line(v3(x0, y, 0), v3(x1, y, 0), { hue: INK_H, sat: INK_S, light: INK_L, alpha: 0.14 });
}
// A tick stub OUTSIDE the plot. These charts are drawn in perspective, so a line at the top of
// the scale is nearer the camera and projects WIDER — four gridlines of visibly different
// length look like a mistake. A stub at a fixed offset gives the eye a vertical reference to
// read that divergence against, and it becomes what it actually is: depth.
w.line(v3(x0 - 7, y, 0), v3(x0, y, 0), { hue: INK_H, sat: INK_S, light: INK_L, alpha: 0.42 });
}
// The spine and the baseline. Without them the plot is a set of floating horizontals with no
// edge to measure from.
w.line(v3(x0, 0, 0), v3(x0, HEIGHT, 0), { hue: INK_H, sat: INK_S, light: INK_L, alpha: 0.40 });
w.line(v3(x0, 0, 0), v3(x1, 0, 0), { hue: INK_H, sat: INK_S, light: INK_L, alpha: 0.46 });
// Gridlines are static — a tick does not move. The LABELS ride a grow hook, because text is
// immediate-mode on this API. That asymmetry is the library's, and it shows up here.
w.grow(() => {
for (const t of ticks) w.drawLabel(v3(x0 - 13, yOf(t), 0), tickText(t), inkDim(w), 0.92, 0);
// The unit sits on the axis it describes. Without it a chart shows numbers with nothing
// saying what they are numbers OF.
if (unit) w.drawLabel(v3(x0 - 13, HEIGHT + 14, 0), tag(unit), inkMid(w), 0.95, 0);
});
return top;
}
function xLabels(w: ChartWorld, categories: readonly string[]) {
const n = categories.length;
if (!n) return;
const step = n > 14 ? 2 : 1;
for (let i = 0; i < n; i += step) {
w.drawLabel(v3(xAt(i, n), -11, 0), tag(categories[i]!), inkDim(w), 0.9, 0);
}
}
function legend(w: ChartWorld, items: readonly LegendKey[]) {
if (items.length < 2) return;
w.grow(() => {
const y = HEIGHT + 14;
const chars = items.reduce((n, s) => n + s.label.length, 0);
const row = chars * 2.9 + items.length * 20;
let x = -row / 2;
for (const s of items) {
w.drawGlow(v3(x, y, 0), s.hue, 4.5, 0.95);
w.drawLabel(v3(x + s.label.length * 1.45 + 6, y, 0), tag(s.label), inkMid(w), 0.95, 0);
x += s.label.length * 2.9 + 20;
}
});
}
function column(w: ChartWorld, cx: number, cz: number, h: number, r: number, hue: number) {
if (h < 0.5) return;
const c = (sx: number, sz: number, y: number) => v3(cx + sx * r, y, cz + sz * r);
const cn = [[1, 1], [-1, 1], [-1, -1], [1, -1]];
for (let k = 0; k < 4; k++) {
const a = cn[k]!, b = cn[(k + 1) % 4]!;
w.drawFace(c(a[0]!, a[1]!, 0), c(b[0]!, b[1]!, 0), c(b[0]!, b[1]!, h), { hue, sat: 68, light: 24 });
w.drawFace(c(a[0]!, a[1]!, 0), c(b[0]!, b[1]!, h), c(a[0]!, a[1]!, h), { hue, sat: 68, light: 24 });
}
w.drawFace(c(1, 1, h), c(-1, 1, h), c(-1, -1, h), { hue, sat: 76, light: 42 });
w.drawFace(c(1, 1, h), c(-1, -1, h), c(1, -1, h), { hue, sat: 76, light: 42 });
w.drawGlow(v3(cx, h + 1.4, cz), hue, 5, 0.7);
}
/** The same solid between two arbitrary heights — a waterfall step, a quartile box. */
function bar(w: ChartWorld, cx: number, cz: number, y0: number, y1: number, r: number, hue: number) {
const lo = Math.min(y0, y1), hi = Math.max(y0, y1);
if (hi - lo < 0.5) return;
const c = (sx: number, sz: number, y: number) => v3(cx + sx * r, y, cz + sz * r);
const cn = [[1, 1], [-1, 1], [-1, -1], [1, -1]];
for (let k = 0; k < 4; k++) {
const a = cn[k]!, b = cn[(k + 1) % 4]!;
w.drawFace(c(a[0]!, a[1]!, lo), c(b[0]!, b[1]!, lo), c(b[0]!, b[1]!, hi), { hue, sat: 68, light: 26 });
w.drawFace(c(a[0]!, a[1]!, lo), c(b[0]!, b[1]!, hi), c(a[0]!, a[1]!, hi), { hue, sat: 68, light: 26 });
}
w.drawFace(c(1, 1, hi), c(-1, 1, hi), c(-1, -1, hi), { hue, sat: 76, light: 42 });
w.drawFace(c(1, 1, hi), c(-1, -1, hi), c(1, -1, hi), { hue, sat: 76, light: 42 });
w.drawGlow(v3(cx, hi, cz), hue, 4, 0.6);
}
const polar = (cx: number, cy: number, ang: number, r: number): V3 =>
v3(cx + Math.cos(ang) * r, cy + Math.sin(ang) * r, 0);
function ringGrid(w: ChartWorld, cx: number, cy: number, R: number, rings = 3, segs = 48) {
for (let ring = 1; ring <= rings; ring++) {
const rr = (R * ring) / rings;
for (let a = 0; a < segs; a++) {
w.line(
polar(cx, cy, (a / segs) * Math.PI * 2, rr),
polar(cx, cy, ((a + 1) / segs) * Math.PI * 2, rr),
{ hue: INK_H, sat: INK_S, light: INK_L, alpha: 0.13 },
);
}
}
}
function rule(w: ChartWorld, y: number, alpha = 0.46) {
w.line(v3(BASE - 16, y, 0), v3(-BASE + 16, y, 0), { hue: INK_H, sat: INK_S, light: INK_L, alpha });
}
function footLabels(w: ChartWorld, labels: readonly string[]) {
const n = labels.length;
for (let i = 0; i < n; i++) {
w.drawLabel(v3(xAt(i, n), -11, 0), tag(labels[i]!), inkDim(w), 0.9, 0);
}
}
/** Bodies, wicks, a volume row, and a live last price against a right-hand scale. */
export const recCandles: Recipe = (w: ChartWorld, d: ChartData) => {
setup(w);
const cs = d.candles ?? [];
const n = cs.length;
if (!n) return;
const lo = Math.min(...cs.map((c) => c.l)), hi = Math.max(...cs.map((c) => c.h));
const pad = (hi - lo) * 0.08, top = hi + pad, bot = lo - pad;
const vmax = Math.max(...cs.map((c) => c.v)) || 1;
// The price band takes the upper 72%; volume gets a row at the foot, with a gap between.
const PH = HEIGHT * 0.72, VH = HEIGHT * 0.18, VY = 0;
const py = (p: number) => HEIGHT - PH + ((p - bot) / (top - bot)) * PH;
const cx = (i: number) => BASE + ((i + 0.5) / n) * SPAN;
const half = (SPAN / n) * 0.32;
// The price gridlines, at nice round prices. Furniture is ink, like every other scale here.
const { ticks } = niceTicks(top - bot, 4);
const step = ticks.length > 1 ? ticks[1]! : (top - bot) / 4;
const first = Math.ceil(bot / step) * step;
for (let p = first; p <= top; p += step) {
w.line(v3(BASE, py(p), 0), v3(-BASE + 10, py(p), 0), { hue: INK_H, sat: INK_S, light: INK_L, alpha: 0.14 });
}
// The live close, as a function of the clock — one home, because three marks read it.
const liveAt = (i: number, t: number) =>
cs[i]!.c + (i === n - 1 ? Math.sin(t * 1.7) * (cs[i]!.h - cs[i]!.l) * 0.28 : 0);
w.grow((t: number) => {
const p = easeInOut(Math.min(1, t / 1.5));
const shown = Math.max(1, Math.floor(n * p));
for (let i = 0; i < shown; i++) {
const c = cs[i]!;
const last = i === shown - 1 && shown === n;
const close = last ? liveAt(i, t) : c.c;
const up = close >= c.o;
const hue = up ? seriesHue(2) : seriesHue(5);
const o = py(c.o), cl = py(close);
const h = py(Math.max(c.h, close)), l = py(Math.min(c.l, close));
w.drawLine(v3(cx(i), l, 0), v3(cx(i), h, 0), col(hue, 52), 0.8, 1.5);
// The body is a real box, so a candle has depth a flat rectangle never will.
bar(w, cx(i), 0, Math.min(o, cl), Math.max(o, cl) + 0.6, half, hue);
// The volume row beneath, sharing the candle's own direction.
bar(w, cx(i), 0, VY, VY + (c.v / vmax) * VH, half, up ? seriesHue(2) : seriesHue(5));
if (last) w.drawGlow(v3(cx(i), cl, 0), hue, 6, 0.9);
}
// A scale tick is SKIPPED where the last price already occupies that line: both are drawn
// against the right-hand axis, and on a rising chart the live price passes a round number most
// of the time. The live number wins — it is the one a reader came for.
const live = liveAt(shown - 1, t);
const ly = py(live);
for (let pr = first; pr <= top; pr += step) {
if (Math.abs(py(pr) - ly) < 9) continue;
w.drawLabel(v3(-BASE + 26, py(pr), 0), tickText(pr), inkDim(w), 0.9, 0);
}
// The last price: a rule across the plate and a tag against the scale — the two marks a reader
// looks for before anything else on the chart.
const rising = live >= cs[0]!.o;
w.drawLine(v3(BASE, ly, 1), v3(-BASE + 8, ly, 1), col(rising ? seriesHue(2) : seriesHue(5), 62), 0.55, 1);
w.drawLabel(v3(-BASE + 26, ly, 1), tickText(Math.round(live * 100) / 100), inkHi(w), 1, 0);
w.drawLabel(v3(BASE - 8, VY + VH * 0.5, 0), tag("volume"), inkDim(w), 0.9, 0);
xLabels(w, d.categories);
});
};
What you install
One package. Nothing else.
No dependencies, no model files, no asset pipeline, no build step to configure. The browser is the only runtime it asks for — which is also why it cannot be broken by something else upgrading underneath it.
What you get for it
Every chart here is a short function you can copy whole and change. It picks 2D or 3D for the machine it lands on, so one recipe covers both — and you start from a working chart instead of rebuilding a demo.