Session shading — a custom draw command with a fallback
A decoration paints a gradient band over the after-hours stretch through a drawing command the core does not know (examples/gradient-band) — the renderer that does is injected with browserDeps({ createRenderer }); the headless model records the command whole, fallback included, and a playback renderer that does not know the name draws that flat fallback. Two approximations, on purpose: the hours are read in UTC from the axis ticks, and each band runs from a tick for one tick spacing, so its edges move with the zoom. It shows how a decoration recovers time from the ticks and how a fallback travels with a command; it is not a market-session calendar.
Source
apps/examples/src/cases/session-shading.ts — the case, type-checked in CI.
import type { OHLC } from "@finchart/core";
import { candleSeries, priceFormat, timeTicks } from "@finchart/core";
import { PlotBuilder, browserDeps } from "@finchart/dom";
import { sessionShading, shadingRenderer } from "../session-shading";
import { FIXTURE_BASE, fixtureCandles } from "./fixture";
import { chartHost } from "./stage";
const HOUR = 60 * 60_000;
export const title = "Session shading — a custom draw command with a fallback";
export const description =
"A decoration paints a gradient band over the after-hours stretch through a drawing command the core does not know (examples/gradient-band) — the renderer that does is injected with browserDeps({ createRenderer }); the headless model records the command whole, fallback included, and a playback renderer that does not know the name draws that flat fallback. Two approximations, on purpose: the hours are read in UTC from the axis ticks, and each band runs from a tick for one tick spacing, so its edges move with the zoom. It shows how a decoration recovers time from the ticks and how a fallback travels with a command; it is not a market-session calendar.";
export function mount(container: HTMLElement): () => void {
const host = chartHost(container, 480);
// The shared fixture starts at 09:00 UTC in one-minute bars; 960 of them
// reach past midnight, so the 20–24 UTC band has bars under it.
const bars: OHLC[] = fixtureCandles(960);
const plot = PlotBuilder.create<OHLC>(
browserDeps({
autoSize: true,
// The renderer that knows the band's command — zero core changes.
createRenderer: shadingRenderer,
}),
)
.setSize(container.clientWidth || 900, 480)
.setAxis({
x: { ticks: timeTicks({ timeZone: "UTC", locale: "en-US" }) },
y: { position: "right", format: priceFormat({ compact: true, locale: "en-US" }) },
})
.build(host);
plot.mainPane.addSeries({ series: candleSeries(), data: bars, name: "Price" });
plot.addDecoration(sessionShading({ fromHour: 20, toHour: 24 }));
// Open on the evening so the band is in view without scrolling.
plot.setVisibleRange(FIXTURE_BASE + 10 * HOUR, FIXTURE_BASE + 15.5 * HOUR);
return () => plot.destroy();
}The decoration and the renderer it needs — apps/examples/src/session-shading.ts:
/**
* One more drawing primitive, with no change to the core — the custom command
* demonstrated.
*
* A gradient was the case `fill: string` couldn't express: covering the
* after-hours stretch with a band that fades from top to bottom. Widening the
* command union for this one shape would break every consumer that switches
* over it exhaustively.
*
* So three things get wired instead.
*
* 1. Emit the command — the decoration calls
* drawCustom(target, { name, params, fallback })
* 2. Build a renderer that knows how to paint it —
* createCanvasRenderer(surface, { painters })
* 3. Ship a fallback with the command — the headless model records the whole
* command, fallback included, and a playback renderer that does not know
* the name draws the fallback instead
*
* Zero lines change in the core. That is the point of this file.
*/
import { applyColor, type CanvasBrush } from "@finchart/core";
import { createCanvasRenderer, drawCustom, type CustomPainter, type FallbackCommand, type PlotDecoration, type Renderer, type RendererFactory } from "@finchart/core";
/** Namespaced — with no global registry, the name is the only thing preventing a collision. */
const GRADIENT_BAND = "examples/gradient-band";
interface GradientBandParams {
left: number;
right: number;
top: number;
bottom: number;
/** Top to bottom. This is exactly the part a single `fill: string` can't say. */
from: string;
to: string;
}
const isNumber = (value: unknown): value is number => typeof value === "number";
const isString = (value: unknown): value is string => typeof value === "string";
/** The params come back as `unknown` — the painter is the one that knows their shape, so it checks. */
function isGradientBandParams(value: unknown): value is GradientBandParams {
if (typeof value !== "object" || value === null) return false;
const record: Record<string, unknown> = { ...value };
return (
isNumber(record.left) &&
isNumber(record.right) &&
isNumber(record.top) &&
isNumber(record.bottom) &&
isString(record.from) &&
isString(record.to)
);
}
/** How the canvas paints this name. The wiring loads it into the renderer. */
const paintGradientBand: CustomPainter = (context, params) => {
// Params of the wrong shape paint nothing — the fallback is for a renderer
// that does not know the name, not for a painter that declined.
if (!isGradientBandParams(params)) return;
const { left, right, top, bottom, from, to } = params;
// The narrowed 2D context already has everything a gradient needs —
// createLinearGradient, addColorStop, fillRect — so no widening.
const full = context;
/**
* A consumer's colors reach this far — `from` and `to` arrive through
* SessionShadingOptions, and `addColorStop` throws a DOMException on an
* invalid color (the exact opposite of assigning to `fillStyle`, which is
* silently ignored). There is no try/catch on the render() path, so that
* frame would die and the error would leak out past rAF.
*
* The demotion is the flat first color, the same as the fallback, so the
* evidence stays on screen. That demotion goes through applyColor too —
* without it, an invalid color becomes the neighbor's color.
*/
let brush: CanvasBrush;
try {
const gradient = full.createLinearGradient(0, top, 0, bottom);
gradient.addColorStop(0, from);
gradient.addColorStop(1, to);
brush = gradient;
} catch {
brush = from;
}
applyColor(full, "fillStyle", brush);
full.fillRect(left, top, right - left, bottom - top);
};
/** A renderer that knows this painter. Goes straight into `browserDeps({ createRenderer })`. */
export const shadingRenderer: RendererFactory = (surface): Renderer =>
createCanvasRenderer(surface, { painters: { [GRADIENT_BAND]: paintGradientBand } });
export interface SessionShadingOptions {
/** From this hour of the day (UTC). */
fromHour: number;
/** Up to this hour. */
toHour: number;
from?: string;
to?: string;
}
/**
* A decoration that covers the after-hours stretch with a band.
*
* A decoration, not a plugin — it is one drawing description with no wiring.
* Mount it with plot.addDecoration.
*/
export function sessionShading(
options: SessionShadingOptions,
): PlotDecoration {
const from = options.from ?? "rgba(148, 163, 184, 0.28)";
const to = options.to ?? "rgba(148, 163, 184, 0)";
return {
draw(target, { area, x, ticks }) {
/**
* The ticks arrive already computed — compute your own and they drift
* away from the labels.
*
* `Tick.value` is a domain value, not the data's x: under bar-index
* coordinates it is a bar index, so passing it straight to `new Date()`
* lands you in 1970. Recover the time with `fromDomain`, and use the
* `position` the axis has already filled in for the pixel.
*/
const spacing = bandWidth(ticks.x);
for (const tick of ticks.x) {
const hour = new Date(x.fromDomain(tick.value)).getUTCHours();
if (hour < options.fromHour || hour >= options.toHour) continue;
const left = tick.position;
const right = Math.min(left + spacing, area.right);
if (right <= area.left || left >= area.right) continue;
const box = {
left: Math.max(left, area.left),
right,
top: area.top,
bottom: area.bottom,
};
/**
* Ship a fallback with it — the headless model records the whole
* command, fallback and all; a playback renderer that does not know
* this name (server rendering, a different canvas wiring) draws the
* flat color instead of the gradient. Less pretty, but no hole.
*/
const flat: FallbackCommand[] = [
{
type: "drawShape",
shape: {
shape: "rect",
x: box.left,
y: box.top,
width: box.right - box.left,
height: box.bottom - box.top,
fill: from,
},
},
];
drawCustom(target, {
name: GRADIENT_BAND,
params: { ...box, from, to } satisfies GradientBandParams,
fallback: flat,
});
}
},
};
}
/** How wide one tick covers. The axis has already filled the pixel into `position`. */
function bandWidth(ticks: readonly { position: number }[]): number {
if (ticks.length < 2) return 0;
return Math.abs(ticks[1].position - ticks[0].position);
}