Skip to content

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.

ts
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:

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);
}