Skip to content
Chromatic Coherence

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.