Skip to main content
Showcases
Muze Showcase · 2026-09-16

The Watchlist

Sixteen instruments, three microchart columns — a sparkline table whose rows are Muze crosstab facets, not hand-placed cells.

Loading viz…

What you’re looking at

One row per instrument, twenty-five trading sessions across. The first column is the session-on-session change, drawn as bars off a zero baseline — blue above, red below. The second is momentum: the five-session rate of change, filled so the volume of a move reads before its shape does. The third is the closing price in green, anchored with a filled dot at each end of its window so you can see where the series starts and finishes.

The change and price columns scale each row to its own range. A four-dollar instrument and a two-hundred-dollar one both fill their cell, so those columns compare shapes, not levels — that is the bargain a sparkline table makes, and it is why the price column carries no axis. Momentum is the exception: percentages are comparable between instruments, so all sixteen rows share one scale there and a deep row really is deeper than a shallow one. For the actual figures, hover: the strip above the table names the instrument, the session, its close, and both rates of change, and the crossline runs across all three columns at once so the same vertical position means the same date everywhere.

The column headings sort. Clicking one re-ranks the whole table, and clicking it again reverses it; the table opens ranked by the window’s total move, so the strongest instrument is on top.

The data is invented. Sixteen fictional instruments on a fictional exchange, generated from a seeded random walk with one scripted event apiece so the column shows genuinely different shapes — a gap, a slide, a spike that gives itself back, a drop that recovers. No real security is represented and none of these prices ever traded.

How it was built

The claim worth making about this showcase is that the table is Muze. No cell is positioned by hand, no row is a <tr>. Each column is one faceted canvas, and Muze’s crosstab engine lays the rows out.

1. Read the Answer. Studio provides viz and getDataFromSearchQuery(), as shown in the Studio Quick Start. The FIELD block maps your Answer column names, and the adapter reads through getData() so a column resolves under either its name or its display name. It needs only four things per row: a symbol, a name, a date, and a close. Daily change, five-session momentum, and the high/low markers are all derived at runtime, which is why the CSV ships thirty sessions to draw twenty-five — the first five are lead-in for the momentum calculation.

2. Let facets build the rows. Each column calls .rows(["Symbol", "Name", measure]) and .columns(["Session"]). Two dimensions ahead of the measure make a crosstab: Muze splits the data per instrument, draws one band per split, and renders the Symbol and Name values as the label gutter on the first column. The other two columns pass rows.facets.show: false — same splits, same bands, labels drawn once. They stay in step because they share a facet count and a height, not because anything measures anything.

3. Give every row its own scale. axes.y.fields.<measure>.uniformAxisDomains: false is the line that turns a crosstab into a sparkline table. Without it, all sixteen rows share one domain and fifteen of them flatten against whichever instrument has the widest range. It is also why this is three canvases and not one: a single crosstab would put a ±3% change column and a 250-point price column under one shared scale per row.

Per-row scaling has one trap, which is why the middle column opts out of it. A domain taken from the data need not contain zero, and an area filled from an arbitrary minimum overstates every move it draws. The bars are safe — a bar layer anchors itself at zero — but an area is not. Momentum is a percentage, so unlike a price it is already comparable between instruments: the column states one symmetric domain spanning zero for all sixteen rows, which both puts the baseline where it belongs and makes a deep row genuinely deeper than a shallow one.

4. Spend colour only on polarity. The change bars take .color("Direction") with the range stated in legend.color.domainRangeMap, so blue and red come off Muze’s colour axis rather than a stylesheet. Everything else is a single series and reads its paint from CSS through each layer’s className — including the green close line, whose hue is identity rather than polarity and never shares a column with the up/down pair.

The close column layers a line under a point layer that is handed all twenty-five sessions and draws two of them. That narrowing is .size("MarkerSize"), a measure holding 1 at the two ends of the window and 0 in between: size is a retinal channel and takes any measure, whereas a layer’s y encoding only accepts a field already on an axis, and a source transform does not survive faceting at all — each faceted unit re-derives from the canvas data, so the layer just draws the whole series again.

The radius is then a stylesheet problem, because this build ignores the range given to .size() wherever it is stated and always runs the scale from 50px down to 2px. The CSS scales the mark path about its own centre, which needs transform-box: fill-box: without it a CSS transform on an SVG path is measured from the viewBox origin, so the marks move as well as shrink. Measured from each path’s own box, the two radii become a 3.5px anchor and 0.14px of nothing.

5. Wire one pointer to three canvases. The custom side effect’s formalName() returns watchSync, mapped onto the built-in highlight behaviour through ActionModel, so the hovered instrument and session arrive in Muze’s own interaction payload instead of from hand-rolled mouse maths. It repaints the readout strip and echoes a session-only highlight into the other two canvases, which is what carries the crossline across the table. Updates are coalesced through one animation frame so a sweep along a line does not re-dispatch three times per pointer event.

6. Sort without touching the DOM. A header click recomputes the symbol order and hands it to all three canvases as rows.facets.fields.Symbol.ordering: { type: 'custom', values }. The row order is a Muze facet ordering, so the three columns cannot fall out of step with each other. The only measured thing on the page is the label gutter’s width — Muze sizes it to its text, and the header row is told the result so the headings sit over the columns they name.

Take it with you

Load the CSV into a Worksheet and keep one row per instrument per session. Paste these complete artifacts into Muze Studio. Set the FIELD names at the top of the JavaScript to match your Answer columns. This example expects at least seven sessions and two instruments, dates as ISO days or epoch milliseconds, and positive closes; rows missing any of those are dropped, and an Answer with nothing left reports an error.

JavaScript

Answer data bindings, the derived change and momentum figures, three faceted canvases, the hover sync, and the sortable headers.

Preview
const { muze, getDataFromSearchQuery } = viz;
const data = getDataFromSearchQuery();

// THE WATCHLIST — a sparkline table built out of Muze's crosstab facets.
//
// Paste this into the JavaScript panel of Muze Studio, with the CSS and HTML
// artifacts in theirs. Set the FIELD names below to match your Answer columns:
// the table needs only a symbol, a name, a date, and a close per row. Daily
// change, five-session momentum, and the window figures are derived here.
//
// The table itself is Muze. Each column is one faceted canvas whose row bands
// come from `rows(["Symbol", "Name", <measure>])`: Muze splits the data per
// instrument, lays out the bands, and — with `uniformAxisDomains: false` —
// scales every band to its own data, which is what makes a sparkline table
// readable. Nothing here hand-positions a row.
//
// Three canvases rather than one, because the three columns need three
// different marks AND three independent y-scales. A single crosstab would put
// them under one shared scale per row band, which would flatten a ±3% change
// column against a 250-point price column.

// Field names — these are the only data bindings a Studio user normally edits.
const FIELD = {
  symbol: "Symbol",
  name: "Name",
  sector: "Sector",
  date: "Date",
  close: "Close",
};

/* ------------------------------------------------------------------ *
 * Shape of the table
 * ------------------------------------------------------------------ */

// Momentum is a rate of change over MOMENTUM_SPAN sessions, so the first
// MOMENTUM_SPAN sessions of the file are lead-in: they feed the calculation
// but are not drawn. Every column then covers the same WINDOW sessions, which
// is what lets one crossline mean the same date in all three of them.
const MOMENTUM_SPAN = 5;
const WINDOW = 25;

// Row band height in pixels. Muze divides a canvas's height evenly between its
// facets, so the three canvases stay in step by sharing this number and the
// same facet count — not by measuring each other. It also enforces a minimum
// band height: ask for less than this and the bands keep their size while the
// canvas grows past the height it was given and scrolls inside itself.
const ROW_HEIGHT = 64;

// Plot width of one microchart column. The label gutter that Muze renders for
// the Symbol and Name facets is measured after the first paint (it sizes to
// its text) and added to the first canvas on top of this.
const CELL_WIDTH = 300;
const GUTTER_ESTIMATE = 168;

// The close line carries an anchor at each end of its window. Getting two
// dots out of a layer handed twenty-five points takes both halves of this:
//
//   A `size` encoding on a 0/1 measure separates the ends from the middle.
//   Size is retinal, so it takes any measure — unlike a `y` encoding, which
//   only accepts a field already on an axis, and unlike a `source` transform,
//   which does not survive faceting at all. It goes on the POINT LAYER: set on
//   the canvas it is read by every layer, and the line answers it by drawing
//   itself as a ribbon that flares out wherever an anchor sits.
//
//   The stylesheet then fixes the radius, because this build ignores the
//   `range` given to `.size()` wherever it is stated and always runs the scale
//   from 50px down to 2px. Scaling the mark path about its own centre turns
//   those into an anchor and nothing; see `.wl-close-marks` in styles.css,
//   which owns the scale factor.
//
// A null in MarkerSize is worse than a zero: it drops the size scale entirely
// and every point comes back at the default radius.

const FONT_FAMILY =
  '"Inter", "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif';

// Diverging pair: polarity only. Blue is "up", red is "down", and neither hue
// is reused for anything that is not a direction. Validated as a categorical
// pair against the light surface (CVD ΔE 21.6, normal-vision ΔE 32.3).
const INK = {
  up: "#2a78d6",
  down: "#e34948",
};

// How each column ranks the table, keyed by the header's `data-sort`. The
// close column sorts on the whole window's move rather than the last session,
// which is the figure its shape actually shows.
const SORTS = {
  symbol: { numeric: false, key: (instrument) => instrument.symbol },
  change: { numeric: true, key: (instrument) => instrument.change },
  momentum: { numeric: true, key: (instrument) => instrument.momentum },
  close: { numeric: true, key: (instrument) => instrument.period },
};

const DEFAULT_SORT = { id: "close", direction: "desc" };

/* ------------------------------------------------------------------ *
 * Formatting
 * ------------------------------------------------------------------ */

const formatPrice = (value) =>
  Number.isFinite(value)
    ? value.toLocaleString("en-US", {
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
      })
    : "—";

const formatPercent = (value) =>
  Number.isFinite(value) ? `${value >= 0 ? "+" : "−"}${Math.abs(value).toFixed(2)}%` : "—";

const formatDate = (iso) => {
  const date = new Date(`${iso}T00:00:00Z`);
  if (Number.isNaN(date.getTime())) return iso;
  return date.toLocaleDateString("en-GB", {
    day: "numeric",
    month: "short",
    year: "numeric",
    timeZone: "UTC",
  });
};

/** Zero-padded so the session dimension sorts naturally as text. */
const sessionKey = (index) => String(index + 1).padStart(2, "0");

/* ------------------------------------------------------------------ *
 * Reading the search query
 * ------------------------------------------------------------------ */

function rowsFromDataModel(dataModel) {
  // getData() returns { schema, data } as positional rows and is the accessor
  // available in every Muze build; getField() is not, and returns undefined
  // rather than throwing when it cannot resolve a name.
  const { schema, data: records } = dataModel.getData();

  // Studio surfaces a query column under its name or its display name
  // depending on how the search was written, so accept either.
  const columnIndex = (wanted) =>
    schema.findIndex(
      (field) => field.name === wanted || field.displayName === wanted,
    );

  const required = Object.values(FIELD);
  const missing = required.filter((field) => columnIndex(field) === -1);

  if (missing.length) {
    const available = schema.map((field) => field.displayName || field.name);
    throw new Error(
      `The Watchlist requires configured fields ${required
        .map((field) => `"${field}"`)
        .join(", ")}. Missing: ${missing.join(
        ", ",
      )}. Available: ${available.join(", ")}`,
    );
  }

  const at = Object.fromEntries(
    Object.entries(FIELD).map(([key, field]) => [key, columnIndex(field)]),
  );

  // Dates arrive as an ISO string from a CSV worksheet or as epoch
  // milliseconds from a temporal Answer column; both reduce to a day key.
  //
  // The milliseconds are read back through the LOCAL calendar, not UTC,
  // because that is the calendar Muze parsed "2026-09-15" in. Reading them as
  // UTC would land on the previous day for every viewer east of Greenwich.
  const toDayKey = (value) => {
    const fromDate = (date) =>
      `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
        date.getDate(),
      ).padStart(2, "0")}`;

    if (value instanceof Date) return fromDate(value);
    if (typeof value === "number" && Number.isFinite(value)) {
      return fromDate(new Date(value));
    }
    const text = String(value ?? "").trim();
    return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : "";
  };

  const rows = records
    .map((record) => ({
      symbol: String(record[at.symbol] ?? "").trim(),
      name: String(record[at.name] ?? "").trim(),
      sector: String(record[at.sector] ?? "").trim(),
      day: toDayKey(record[at.date]),
      close: Number(record[at.close]),
    }))
    .filter(
      (row) => row.symbol && row.day && Number.isFinite(row.close) && row.close > 0,
    );

  if (!rows.length) {
    throw new Error(
      `The Watchlist found no valid rows in ${records.length} returned by the search query.`,
    );
  }

  return rows;
}

/* ------------------------------------------------------------------ *
 * Folding to instruments
 * ------------------------------------------------------------------ */

/**
 * One entry per instrument, carrying the drawn WINDOW of sessions plus the
 * summary figures the header strip reads. Sessions are the union of every
 * instrument's dates, so a gap in one instrument still lines up with the rest.
 */
function foldToInstruments(rows) {
  const sessions = [...new Set(rows.map((row) => row.day))].sort();

  if (sessions.length < MOMENTUM_SPAN + 2) {
    throw new Error(
      `The Watchlist needs at least ${MOMENTUM_SPAN + 2} sessions and found ${sessions.length}.`,
    );
  }

  const drawn = sessions.slice(-WINDOW);
  const firstDrawn = sessions.length - drawn.length;

  const byInstrument = new Map();
  rows.forEach((row) => {
    let instrument = byInstrument.get(row.symbol);
    if (!instrument) {
      instrument = {
        symbol: row.symbol,
        name: row.name || row.symbol,
        sector: row.sector,
        closes: new Map(),
      };
      byInstrument.set(row.symbol, instrument);
    }
    // One row per instrument per session; a duplicate keeps the later close.
    instrument.closes.set(row.day, row.close);
  });

  const instruments = [...byInstrument.values()].map((instrument) => {
    const closes = sessions.map((day) => instrument.closes.get(day) ?? null);
    const points = drawn.map((day, index) => {
      const at = firstDrawn + index;
      const close = closes[at];
      const previous = closes[at - 1];
      const back = closes[at - MOMENTUM_SPAN];
      return {
        day,
        session: sessionKey(index),
        close,
        change:
          Number.isFinite(close) && Number.isFinite(previous) && previous !== 0
            ? ((close - previous) / previous) * 100
            : null,
        momentum:
          Number.isFinite(close) && Number.isFinite(back) && back !== 0
            ? ((close - back) / back) * 100
            : null,
      };
    });

    const priced = points.filter((point) => Number.isFinite(point.close));
    if (!priced.length) {
      throw new Error(
        `The Watchlist found no closes for "${instrument.symbol}" in the drawn window.`,
      );
    }

    const opening = priced[0];
    const latest = priced[priced.length - 1];
    const high = priced.reduce((a, b) => (b.close > a.close ? b : a));
    const low = priced.reduce((a, b) => (b.close < a.close ? b : a));

    return {
      ...instrument,
      points,
      latest,
      high,
      low,
      // Sort keys, and the figures the header strip shows when nothing is
      // hovered. `period` is the whole drawn window, not the last session.
      change: latest.change,
      momentum: latest.momentum,
      period: ((latest.close - opening.close) / opening.close) * 100,
    };
  });

  if (instruments.length < 2) {
    throw new Error(
      `The Watchlist draws a table of instruments and found ${instruments.length}.`,
    );
  }

  return { instruments, drawn };
}

/** Flat rows for the chart DataModel — one per instrument per drawn session. */
function toChartRows(instruments) {
  const rows = [];

  instruments.forEach((instrument) => {
    instrument.points.forEach((point) => {
      if (!Number.isFinite(point.close)) return;

      rows.push({
        Symbol: instrument.symbol,
        Name: instrument.name,
        Session: point.session,
        Direction: (point.change ?? 0) >= 0 ? "Up" : "Down",
        Change: point.change,
        Momentum: point.momentum,
        Close: point.close,
        // 1 at the two ends of the drawn window, 0 in between — the switch
        // behind the anchors on the close line, explained at the top of this
        // file.
        MarkerSize:
          point === instrument.points[0] || point === instrument.latest ? 1 : 0,
      });
    });
  });

  return rows;
}

const CHART_SCHEMA = [
  { name: "Symbol", type: "dimension" },
  { name: "Name", type: "dimension" },
  { name: "Session", type: "dimension" },
  { name: "Direction", type: "dimension" },
  { name: "Change", type: "measure", defAggFn: "avg" },
  { name: "Momentum", type: "measure", defAggFn: "avg" },
  { name: "Close", type: "measure", defAggFn: "avg" },
  { name: "MarkerSize", type: "measure", defAggFn: "avg" },
];

/* ------------------------------------------------------------------ *
 * Markup
 * ------------------------------------------------------------------ */

const el = (tag, className, text) => {
  const node = document.createElement(tag);
  if (className) node.className = className;
  if (text !== undefined) node.textContent = text;
  return node;
};

function createShell(mount, mountId, sessionCount) {
  const ids = {
    change: `${mountId}-col-change`,
    momentum: `${mountId}-col-momentum`,
    close: `${mountId}-col-close`,
  };

  mount.innerHTML = `
    <main class="watchlist" aria-label="Sparkline table of sixteen instruments over ${sessionCount} sessions">
      <header class="watchlist__readout" aria-live="polite">
        <div class="watchlist__readout-id">
          <span class="watchlist__readout-symbol"></span>
          <span class="watchlist__readout-name"></span>
        </div>
        <dl class="watchlist__readout-figures">
          <div class="watchlist__figure"><dt>Session</dt><dd class="watchlist__readout-date"></dd></div>
          <div class="watchlist__figure"><dt>Close</dt><dd class="watchlist__readout-close"></dd></div>
          <div class="watchlist__figure"><dt>Day</dt><dd class="watchlist__readout-change"></dd></div>
          <div class="watchlist__figure"><dt>${MOMENTUM_SPAN}-session</dt><dd class="watchlist__readout-momentum"></dd></div>
        </dl>
      </header>

      <div class="watchlist__table" role="presentation">
        <div class="watchlist__headrow">
          <button type="button" class="watchlist__head watchlist__head--label" data-sort="symbol">
            <span class="watchlist__head-text">Symbol &middot; Name</span>
            <span class="watchlist__head-caret" aria-hidden="true"></span>
          </button>
          <button type="button" class="watchlist__head" data-sort="change">
            <span class="watchlist__head-text">Daily Change</span>
            <span class="watchlist__head-caret" aria-hidden="true"></span>
          </button>
          <button type="button" class="watchlist__head" data-sort="momentum">
            <span class="watchlist__head-text">Momentum</span>
            <span class="watchlist__head-caret" aria-hidden="true"></span>
          </button>
          <button type="button" class="watchlist__head" data-sort="close">
            <span class="watchlist__head-text">Close History</span>
            <span class="watchlist__head-caret" aria-hidden="true"></span>
          </button>
        </div>

        <div class="watchlist__cols">
          <div class="watchlist__col" id="${ids.change}"></div>
          <div class="watchlist__col" id="${ids.momentum}"></div>
          <div class="watchlist__col" id="${ids.close}"></div>
          <div class="watchlist__rules" aria-hidden="true"></div>
        </div>
      </div>

      <footer class="watchlist__note">
        <span class="watchlist__key">
          <i class="watchlist__key-swatch watchlist__key-swatch--up"></i>up
          <i class="watchlist__key-swatch watchlist__key-swatch--down"></i>down
          <i class="watchlist__key-line"></i>momentum vs zero
          <i class="watchlist__key-line watchlist__key-line--close"></i>close, anchored at both ends
        </span>
        <span>Change and price are scaled per row — read the shape, not the height. Momentum shares one scale across all rows.</span>
      </footer>
    </main>
  `;

  return ids;
}

/* ------------------------------------------------------------------ *
 * Interaction plumbing
 * ------------------------------------------------------------------ */

/**
 * The session under the pointer. A Muze interaction payload arrives as
 * `{ criteria: { dimensions: [[...fieldNames], [...values]] } }`, and a null
 * criteria means the pointer left the plot.
 */
function readSession(payload) {
  const dimensions = payload?.criteria?.dimensions;
  if (!Array.isArray(dimensions) || dimensions.length < 2) return null;

  const [fields, ...rows] = dimensions;
  if (!Array.isArray(fields) || !Array.isArray(rows[0])) return null;

  const index = fields.indexOf("Session");
  return index === -1 ? null : rows[0][index];
}

/**
 * Which row the pointer is in, as an index into the current facet order.
 *
 * The instrument is NOT in the criteria — a facet dimension never is. Muze
 * reports it structurally instead: `sourceInfo.unitRowIndex` is the position
 * of the visual unit the event came from, which for a row-faceted crosstab is
 * the row. All three canvases share one facet ordering, so the same index
 * means the same instrument in every column.
 */
function readRowIndex(payload) {
  const index = payload?.sourceInfo?.unitRowIndex;
  return Number.isInteger(index) && index >= 0 ? index : null;
}

/* ------------------------------------------------------------------ *
 * Build
 * ------------------------------------------------------------------ */

async function buildViz(muze, data, mountId, options = {}) {
  if (options.signal?.aborted) return null;

  const mount = document.getElementById(mountId);
  if (!mount) {
    throw new Error(`The Watchlist mount #${mountId} was not found.`);
  }

  // Muze measures label space before paint and the facet gutter sizes to its
  // text, so settle the webfont before the first render.
  await (document.fonts?.ready ?? Promise.resolve());
  if (options.signal?.aborted) return null;

  const { DataModel, ActionModel } = muze;
  const { SurrogateSideEffect } = muze.SideEffects.standards;

  const rows = rowsFromDataModel(data);
  const { instruments, drawn } = foldToInstruments(rows);
  const bySymbol = new Map(
    instruments.map((instrument) => [instrument.symbol, instrument]),
  );
  const sessionDay = new Map(drawn.map((day, index) => [sessionKey(index), day]));

  // The momentum axis is stated rather than derived per row, so it is worked
  // out once across every instrument and padded so the extremes are not drawn
  // flush against the edge of their band. Zero is forced into the middle: it
  // is the baseline the area is measured from.
  const momentumValues = instruments
    .flatMap((instrument) => instrument.points.map((point) => point.momentum))
    .filter(Number.isFinite);
  const momentumSpan = Math.max(
    Math.abs(Math.min(0, ...momentumValues)),
    Math.abs(Math.max(0, ...momentumValues)),
    1,
  );
  const momentumDomain = [-momentumSpan * 1.05, momentumSpan * 1.05];

  const ids = createShell(mount, mountId, drawn.length);
  const shell = mount.querySelector(".watchlist");
  const chartRows = toChartRows(instruments);
  const dm = new DataModel(DataModel.loadDataSync(chartRows, CHART_SCHEMA));

  /* ---- readout ---- */

  const readout = {
    symbol: shell.querySelector(".watchlist__readout-symbol"),
    name: shell.querySelector(".watchlist__readout-name"),
    date: shell.querySelector(".watchlist__readout-date"),
    close: shell.querySelector(".watchlist__readout-close"),
    change: shell.querySelector(".watchlist__readout-change"),
    momentum: shell.querySelector(".watchlist__readout-momentum"),
  };

  const signClass = (node, value) => {
    node.classList.remove("is-up", "is-down");
    if (Number.isFinite(value)) node.classList.add(value >= 0 ? "is-up" : "is-down");
  };

  /**
   * Writes the strip for one instrument at one session. With no session it
   * falls back to that instrument's latest, and with nothing hovered at all to
   * the whole window's leader — so the strip is never blank.
   */
  const paintReadout = (symbol, session) => {
    const instrument =
      bySymbol.get(symbol) ??
      instruments.reduce((a, b) => (b.period > a.period ? b : a));
    const point =
      instrument.points.find((candidate) => candidate.session === session) ??
      instrument.latest;

    readout.symbol.textContent = instrument.symbol;
    readout.name.textContent = instrument.name;
    readout.date.textContent = formatDate(point.day);
    readout.close.textContent = formatPrice(point.close);
    readout.change.textContent = formatPercent(point.change);
    readout.momentum.textContent = formatPercent(point.momentum);
    signClass(readout.change, point.change);
    signClass(readout.momentum, point.momentum);
  };

  /* ---- hover fan-out ---- */

  // Declared before the fan-out so the echo closes over a live array; the
  // columns push into it as they mount.
  const canvases = [];

  // The facet order the canvases are currently drawn in. A payload names its
  // row by position, so this is what turns that position into an instrument.
  // `applySort` keeps it in step with the table.
  let rowOrder = [];

  let hovered = { symbol: null, session: null };
  let pending;
  let frame = 0;
  let echoing = false;

  // Mirrors the hovered session into the other two canvases so the crossline
  // reads straight across the table rather than stopping at the column under
  // the pointer.
  const echoSession = (session) => {
    if (echoing) return;
    echoing = true;
    const criteria = session === null ? null : { dimensions: [["Session"], [session]] };
    canvases.forEach((canvas) => {
      try {
        canvas?.firebolt().dispatchBehaviour("highlight", { criteria });
      } catch {
        // A canvas still mid-render has nothing to highlight; the next hover
        // picks it up.
      }
    });
    // Released a frame later rather than here, because the highlight's own
    // side effects are not all synchronous with the dispatch. Until it clears,
    // watchSync ignores what it is handed — a pointer move inside the same
    // frame is dropped, and the next one repaints it.
    requestAnimationFrame(() => {
      echoing = false;
    });
  };

  const paintRowEmphasis = (symbol) => {
    shell
      .querySelectorAll(".wl-facet.is-hovered")
      .forEach((node) => node.classList.remove("is-hovered"));
    if (!symbol) return;
    shell.querySelectorAll(".wl-facet").forEach((node) => {
      if (node.textContent.trim() === symbol) node.classList.add("is-hovered");
    });
  };

  /**
   * Records what the pointer is on, coalesced through one animation frame so a
   * sweep along a line does not rewrite the strip and re-dispatch to three
   * canvases per pointer event.
   *
   * `null` clears the table, and only mouseleave sends it — see the note in
   * WatchSync about why Muze's own clears are not to be trusted for that.
   *
   * A real update with no symbol keeps the one already showing, because the
   * session echo dispatched into the other two canvases comes back through
   * here carrying a session and no row.
   */
  const setHovered = (next) => {
    if (next === null) {
      pending = { symbol: null, session: null };
    } else {
      const current = pending ?? hovered;
      pending = { symbol: next.symbol ?? current.symbol, session: next.session };
    }

    if (frame) return;
    frame = requestAnimationFrame(() => {
      frame = 0;
      const value = pending;
      pending = undefined;
      if (value.symbol === hovered.symbol && value.session === hovered.session) {
        return;
      }
      hovered = value;
      paintReadout(value.symbol, value.session);
      paintRowEmphasis(value.symbol);
      echoSession(value.session);
    });
  };

  paintReadout(null, null);

  /* ---- canvases ---- */

  const theme = {
    name: "watchlist",
    className: "wl-theme",
    font: {
      fontSize: "11px",
      fontFamily: FONT_FAMILY,
      fontWeight: "500",
      fontStyle: "normal",
    },
    loadCSS: () => {},
  };

  const env = typeof muze === "function" ? muze() : muze;
  const height = instruments.length * ROW_HEIGHT;

  /**
   * Retargets the readout strip on hover. It changes nothing inside the chart
   * it fires from, and closes over `setHovered` so no module-level state is
   * needed.
   */
  class WatchSync extends SurrogateSideEffect {
    static formalName() {
      return "watchSync";
    }

    static target() {
      return "visual-unit";
    }

    static mutates() {
      return false;
    }

    apply(_selectionSet, payload) {
      // The echo we dispatch into the other two canvases arrives back here,
      // once per visual unit, each reporting its OWN row. Left unguarded it
      // would walk the readout down the table on every hover.
      if (echoing) return this;

      // A null criteria is Muze clearing one unit, and those do not only mean
      // "the pointer left". Every echo provokes a burst of them — one per unit,
      // a frame or two after the echo, once this guard has already released —
      // so acting on them blanks the strip immediately after each hover. The
      // pointer genuinely leaving the table is handled by mouseleave instead.
      if (!payload?.criteria) return this;

      const rowIndex = readRowIndex(payload);
      setHovered({
        symbol: rowIndex === null ? null : (rowOrder[rowIndex] ?? null),
        session: readSession(payload),
      });
      return this;
    }
  }

  /**
   * Config shared by all three columns. The two ideas that make the table a
   * table live here: facet labels are drawn once (on the first column only)
   * and `uniformAxisDomains: false` gives every row band its own y domain.
   */
  const columnConfig = ({
    measure,
    showFacets,
    color,
    domainFields,
    yDomain,
  }) => ({
    theme,
    // Muze's own borders are drawn per canvas and only across its plot area,
    // so they stop short of the column dividers and notch the grid at every
    // crossing. The table's rules are drawn in CSS instead, over the full
    // width, from the band geometry measured after paint — see paintRowRules.
    border: {
      style: "none",
      width: 0,
      showRowBorders: { top: false, bottom: false, left: false, right: false },
      showColBorders: { top: false, bottom: false, left: false, right: false },
      showValueBorders: { top: false, bottom: false, left: false, right: false },
    },
    rows: {
      headers: { show: false },
      facets: {
        show: showFacets,
        className: "wl-facet",
        labels: { align: "left" },
      },
    },
    columns: { headers: { show: false }, facets: { show: false } },
    gridLines: { show: false },
    gridBands: { x: { show: false }, y: { show: false } },
    legend: {
      // No legend box — the footer key names the two directions in words, and
      // the range is stated here so the bars read their colour off Muze's own
      // colour axis rather than off a stylesheet.
      show: false,
      ...(color
        ? {
            color: {
              domainRangeMap: { Up: INK.up, Down: INK.down },
              ordering: { type: "custom", values: ["Up", "Down"] },
            },
          }
        : {}),
    },
    axes: {
      x: {
        show: false,
        showAxisName: false,
        showAxisLine: false,
        showInnerTicks: false,
        showOuterTicks: false,
        tickSize: 0,
        tickFormat: () => "",
        padding: 0.18,
      },
      y: {
        show: false,
        showAxisName: false,
        showAxisLine: false,
        showInnerTicks: false,
        showOuterTicks: false,
        tickSize: 0,
        tickFormat: () => "",
        ...(yDomain ? { domain: yDomain } : {}),
        fields: Object.fromEntries(
          // The whole point of a sparkline table: each row is scaled to its
          // own range, so the shapes are comparable even when the levels are
          // nowhere near each other. Keyed per field, so a shared axis names
          // both of the fields sitting on it.
          yDomain
            ? []
            : (domainFields ?? [measure]).map((field) => [
                field,
                { uniformAxisDomains: false },
              ]),
        ),
      },
    },
    interaction: {
      highlight: {
        sideEffects: {
          // The readout strip is the tooltip.
          tooltip: { enabled: false },
          crossline: {},
          watchSync: {},
        },
      },
    },
  });

  const makeColumn = ({
    id,
    measure,
    layers,
    showFacets,
    width,
    color,
    domainFields,
    yDomain,
  }) => {
    let canvas = env.canvas().data(dm).width(width).height(height);

    if (color) canvas = canvas.color(color);

    canvas = canvas
      .rows(["Symbol", "Name", measure])
      .columns(["Session"])
      .layers(layers)
      .config(
        columnConfig({
          measure,
          showFacets,
          color,
          domainFields,
          yDomain,
        }),
      )
      .mount(`#${id}`);

    // Registered on the group firebolt before the first paint so every visual
    // unit inherits the definition as it is created.
    canvas.firebolt().registerSideEffects([WatchSync]);
    canvases.push(canvas);
    return canvas;
  };

  // Column 1 — daily change. Bars off a zero baseline, coloured by direction,
  // which is the one place the diverging pair is spent.
  makeColumn({
    id: ids.change,
    measure: "Change",
    showFacets: true,
    width: GUTTER_ESTIMATE + CELL_WIDTH,
    color: "Direction",
    layers: [{ mark: "bar", className: "wl-bar" }],
  });

  // Column 2 — momentum. Area for the volume of the move, a line on top for
  // its edge: two layers over one measure, which is the layering idea in
  // miniature.
  makeColumn({
    id: ids.momentum,
    measure: "Momentum",
    showFacets: false,
    width: CELL_WIDTH,
    // The one column that does NOT scale per row. Momentum is a percentage,
    // so it is already comparable between instruments, and a stated domain
    // spanning zero is what gives the area an honest baseline: an area filled
    // from a row's own minimum overstates every move it draws.
    yDomain: momentumDomain,
    layers: [
      { mark: "area", className: "wl-momentum-area" },
      { mark: "line", className: "wl-momentum-line" },
    ],
  });

  // Column 3 — close history. A line for the series and a point layer for the
  // anchors at either end of it: annotations as a layer, rather than a colour
  // encoding that would break the line into one segment per marker value.
  makeColumn({
    id: ids.close,
    measure: "Close",
    showFacets: false,
    width: CELL_WIDTH,
    layers: [
      { mark: "line", className: "wl-close-line" },
      {
        mark: "point",
        className: "wl-close-marks",
        interactive: false,
        // Size is set on THIS LAYER, not on the canvas. A canvas-level
        // `.size()` is read by every layer, and the line layer answers it by
        // drawing itself as a variable-width ribbon that flares out at the
        // anchors. Here it only separates the two anchored sessions from the
        // rest; the radius is corrected in CSS, per the MarkerSize note above.
        encoding: { y: "Close", size: "MarkerSize" },
      },
    ],
  });

  ActionModel.for(...canvases)
    .registerSideEffects(WatchSync)
    .mapSideEffects({ highlight: ["watchSync"] })
    // A sparkline cell is too small to brush or to select into; hover is the
    // only gesture the table answers.
    .dissociateBehaviour(
      ["select", "click"],
      ["select", "longtouch"],
      ["brush", "drag"],
      ["brush", "touchdrag"],
    );

  /* ---- sorting ---- */

  let sort = { ...DEFAULT_SORT };

  const orderedSymbols = () => {
    const { numeric, key } = SORTS[sort.id];
    const sign = sort.direction === "asc" ? 1 : -1;

    return instruments
      .slice()
      .sort((a, b) => {
        if (!numeric) return sign * String(key(a)).localeCompare(String(key(b)));
        // A missing figure sorts to the bottom of a descending rank rather
        // than poisoning the comparison with NaN.
        const left = Number.isFinite(key(a)) ? key(a) : -Infinity;
        const right = Number.isFinite(key(b)) ? key(b) : -Infinity;
        // Ties fall back to the symbol so the order is total and a re-sort
        // never shuffles equal rows.
        return sign * (left - right) || a.symbol.localeCompare(b.symbol);
      })
      .map((instrument) => instrument.symbol);
  };

  /**
   * Re-ranks the table. The row order is a Muze facet ordering, not a DOM
   * reshuffle: the same custom order goes to all three canvases, so they stay
   * in step by construction.
   */
  const applySort = () => {
    const values = orderedSymbols();
    // Recorded before the canvases are told, so a payload arriving mid-sort
    // resolves against the order the table is moving to.
    rowOrder = values;
    canvases.forEach((canvas) => {
      try {
        canvas.config({
          rows: {
            facets: {
              fields: { Symbol: { ordering: { type: "custom", values } } },
            },
          },
        });
      } catch {
        // An ordering a build does not support leaves the table in its
        // previous order rather than tearing the column down.
      }
    });

    shell.querySelectorAll("[data-sort]").forEach((button) => {
      const active = button.dataset.sort === sort.id;
      button.classList.toggle("is-active", active);
      button.classList.toggle("is-asc", active && sort.direction === "asc");
      button.setAttribute(
        "aria-sort",
        active
          ? sort.direction === "asc"
            ? "ascending"
            : "descending"
          : "none",
      );
    });
  };

  const onHeadClick = (event) => {
    const button = event.target.closest("[data-sort]");
    if (!button) return;
    const id = button.dataset.sort;
    // Re-clicking the active column flips it; a new column starts descending,
    // except the symbol column which reads naturally A–Z.
    sort =
      sort.id === id
        ? { id, direction: sort.direction === "asc" ? "desc" : "asc" }
        : { id, direction: id === "symbol" ? "asc" : "desc" };
    applySort();
  };

  const headrow = shell.querySelector(".watchlist__headrow");
  headrow.addEventListener("click", onHeadClick);
  applySort();

  /* ---- grid measurement ---- */

  const rulesHost = shell.querySelector(".watchlist__rules");

  /**
   * Draws the row banding and the rules between rows, full width, from where
   * Muze actually put the bands.
   *
   * A facet label is only as tall as its text and sits at the TOP of its band,
   * so a label's own edges are useless as boundaries — but the gap above the
   * first one is the same padding every band has. Subtracting it from each
   * label's top therefore gives the boundaries exactly, and the spacing
   * between labels gives the band height. Nothing here needs to know how Muze
   * divided the canvas.
   *
   * Drawn over the columns rather than inside them, so a rule crosses the
   * whole table instead of stopping at one column.
   */
  const paintRowRules = () => {
    const labels = shell.querySelectorAll(`#${ids.change} .wl-facet`);
    if (labels.length < 2) return;

    const hostTop = rulesHost.parentElement.getBoundingClientRect().top;
    // Two label cells (Symbol and Name) share a band, so the distinct tops are
    // the bands.
    const tops = [
      ...new Set(
        [...labels].map((label) => label.getBoundingClientRect().top - hostTop),
      ),
    ].sort((a, b) => a - b);
    if (tops.length < 2) return;

    const inset = tops[0];
    const height = tops[1] - tops[0];
    const edges = tops.map((top) => top - inset);

    rulesHost.replaceChildren(
      ...edges.flatMap((edge, index) => {
        const nodes = [];
        // Banding on alternate rows, to keep the eye on one instrument across
        // three columns of small marks.
        if (index % 2 === 1) {
          const band = document.createElement("i");
          band.className = "watchlist__band";
          band.style.top = `${Math.round(edge)}px`;
          band.style.height = `${Math.round(height)}px`;
          nodes.push(band);
        }
        // The first edge is the top of the table, which the header's own
        // border already draws.
        if (index > 0) {
          const rule = document.createElement("i");
          rule.className = "watchlist__rule";
          rule.style.top = `${Math.round(edge)}px`;
          nodes.push(rule);
        }
        return nodes;
      }),
    );
  };

  // Muze sizes the Symbol/Name gutter to its own text, so the header row is
  // told the measured width instead of guessing it. Re-measured on resize
  // because the webfont and the zoom level both move it.
  let labelledWidth = GUTTER_ESTIMATE + CELL_WIDTH;

  const measureGutter = () => {
    const column = document.getElementById(ids.change);
    const labels = column?.querySelectorAll(".wl-facet");
    if (!labels?.length) return;

    const columnLeft = column.getBoundingClientRect().left;
    const right = Math.max(
      ...[...labels].map((label) => label.getBoundingClientRect().right),
    );
    const gutter = Math.round(right - columnLeft);
    if (gutter <= 0) return;

    shell.style.setProperty("--wl-gutter", `${gutter}px`);
    // CELL_WIDTH is the one source of truth for a column's plot width; the
    // header row's track sizing reads it from here rather than repeating it.
    shell.style.setProperty("--wl-cell", `${CELL_WIDTH}px`);

    // The first canvas was mounted at an estimated gutter, so its plot is
    // whatever was left over. Re-widening it to the measured gutter plus one
    // cell makes all three plots identical and the header grid exact.
    const width = gutter + CELL_WIDTH;
    if (width !== labelledWidth) {
      labelledWidth = width;
      try {
        canvases[0].width(width);
      } catch {
        // A build that will not resize in place keeps its first layout.
      }
    }
  };

  // Rules are painted after the gutter, because re-widening the first canvas
  // relays its bands and would leave them measured against the old layout.
  const measureGrid = () => {
    measureGutter();
    requestAnimationFrame(paintRowRules);
  };

  requestAnimationFrame(measureGrid);
  canvases[0].once?.("afterRendered", () => requestAnimationFrame(measureGrid));

  /* ---- lifecycle ---- */

  // Muze fires a null-criteria highlight on mouseout of a plot, but not when
  // the pointer jumps straight from a mark to the page chrome.
  const resetHover = () => setHovered(null);
  shell.addEventListener("mouseleave", resetHover);

  let resizeTimer;
  const onResize = () => {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(measureGrid, 200);
  };
  window.addEventListener("resize", onResize);

  return {
    dispose() {
      clearTimeout(resizeTimer);
      cancelAnimationFrame(frame);
      window.removeEventListener("resize", onResize);
      shell.removeEventListener("mouseleave", resetHover);
      headrow.removeEventListener("click", onHeadClick);
      // Each teardown is guarded on its own: a canvas that never finished
      // rendering must not stop the rest of the table being released.
      canvases.forEach((canvas) => {
        try {
          canvas?.dispose();
        } catch {
          /* already gone */
        }
      });
      try {
        dm.dispose();
      } catch {
        /* already gone */
      }
      mount.replaceChildren();
    },
  };
}


buildViz(muze, data, "chart");

CSS

Chart-scoped table chrome, mark paint for the layers Muze does not colour, the readout strip, and the facet gutter.

Preview
/* =====================================================================
 * The Watchlist — styles for Muze Studio.
 *
 * Anchored on Studio's #chart mount. Class names stay namespaced under
 * watchlist / wl- because the table mounts into a host page whose own
 * stylesheet it must not collide with. The wl- prefixes are the `className`
 * values handed to Muze's facets and layers — that is how a mark's paint is
 * set from CSS instead of from the encoding.
 * ===================================================================== */

#chart {
    --wl-bg: #ffffff;
    --wl-ink: #14181f;
    --wl-ink-soft: #4a5261;
    --wl-ink-faint: #8a93a3;
    --wl-rule: #e6e9ef;
    --wl-rule-strong: #cfd5e0;
    --wl-up: #2a78d6;
    --wl-down: #e34948;
    /* The close series. A third hue, for identity rather than polarity — it
     * never shares a column with the up/down pair, and the column heading
     * names it, which is the secondary encoding its distance from red needs. */
    --wl-close: #199e70;
    --wl-band: #f7f8fa;
    /* The banding is painted OVER the marks — the overlay that carries it sits
     * above the canvases — so it is kept low enough in alpha to tint a row
     * without dulling the bars in it. */
    --wl-stripe: rgba(20, 24, 31, 0.028);
    --wl-font: "Inter", "Segoe UI", "Helvetica Neue", Helvetica, Arial,
        sans-serif;
    /* Both are overwritten after the first paint: the gutter with the width
     * Muze actually drew, the cell with CELL_WIDTH from viz.js. The values
     * here are only what the header row uses before the chart reports back. */
    --wl-gutter: 168px;
    --wl-cell: 300px;

    color: var(--wl-ink);
    font-family: var(--wl-font);
    -webkit-font-smoothing: antialiased;
}

#chart * {
    box-sizing: border-box;
}

/* ---------------------------------------------------------------- *
 * Shell
 * ---------------------------------------------------------------- */

#chart .watchlist {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 14px;
    width: 100%;
    min-height: inherit;
    padding: 18px 16px 22px;
    background: var(--wl-bg);
}

/* ---------------------------------------------------------------- *
 * Readout strip — this table's tooltip
 * ---------------------------------------------------------------- */

#chart
    .watchlist__readout {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 28px;
    width: 100%;
    max-width: calc(var(--wl-gutter) + var(--wl-cell) * 3 + 32px);
    padding: 10px 14px;
    border: 1px solid var(--wl-rule);
    border-radius: 8px;
    background: var(--wl-band);
}

#chart
    .watchlist__readout-id {
    display: flex;
    align-items: baseline;
    gap: 10px;
    min-width: 0;
}

#chart
    .watchlist__readout-symbol {
    font-size: 17px;
    font-weight: 700;
    letter-spacing: 0.06em;
}

#chart
    .watchlist__readout-name {
    font-size: 13px;
    color: var(--wl-ink-soft);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

#chart
    .watchlist__readout-figures {
    display: flex;
    gap: 26px;
    margin: 0;
}

#chart .watchlist__figure {
    display: flex;
    flex-direction: column;
    gap: 1px;
    /* Fixed tracks so hovering swaps digits in place instead of reflowing the
     * strip under the pointer. */
    min-width: 92px;
    text-align: right;
}

#chart .watchlist__figure dt {
    font-size: 10px;
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--wl-ink-faint);
}

#chart .watchlist__figure dd {
    margin: 0;
    font-size: 14px;
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    color: var(--wl-ink);
}

#chart .is-up {
    color: var(--wl-up);
}

#chart .is-down {
    color: var(--wl-down);
}

/* ---------------------------------------------------------------- *
 * Table
 * ---------------------------------------------------------------- */

#chart .watchlist__table {
    display: flex;
    flex-direction: column;
    border: 1px solid var(--wl-rule);
    border-radius: 8px;
    overflow: hidden;
}

/* The header row mirrors the canvases' widths: one label track sized to the
 * measured facet gutter, then one track per microchart column. */
#chart .watchlist__headrow {
    display: grid;
    grid-template-columns: var(--wl-gutter) repeat(3, var(--wl-cell));
    border-bottom: 1px solid var(--wl-rule-strong);
    background: var(--wl-band);
}

#chart .watchlist__head {
    display: flex;
    align-items: center;
    gap: 5px;
    padding: 9px 12px;
    border: 0;
    border-left: 1px solid var(--wl-rule);
    background: none;
    font: inherit;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.07em;
    text-transform: uppercase;
    color: var(--wl-ink-faint);
    cursor: pointer;
    transition: color 120ms ease;
}

#chart
    .watchlist__head--label {
    border-left: 0;
}

#chart .watchlist__head:hover,
#chart
    .watchlist__head.is-active {
    color: var(--wl-ink);
}

#chart
    .watchlist__head:focus-visible {
    outline: 2px solid var(--wl-up);
    outline-offset: -2px;
}

/* The caret only exists on the active column — an inactive header shows no
 * direction to misread. */
#chart
    .watchlist__head-caret::before {
    content: "";
}

#chart
    .watchlist__head.is-active
    .watchlist__head-caret::before {
    content: "▾";
    font-size: 10px;
}

#chart
    .watchlist__head.is-active.is-asc
    .watchlist__head-caret::before {
    content: "▴";
}

#chart .watchlist__cols {
    position: relative;
    display: flex;
    align-items: flex-start;
}

/* No vertical rule down the body. The header's dividers are enough to say
 * where a column starts, and a full-height rule between columns of small marks
 * reads as heavily as the data does. */

/* Muze's own borders are off (see `border` in viz.js): each canvas drew them
 * across its plot area only, so every rule stopped short of the column edge
 * and notched the grid. The banding and rules are drawn over all three
 * columns instead, at the band edges measured from the facet cells, so each
 * one crosses the whole table. */
#chart .watchlist__rules {
    position: absolute;
    inset: 0;
    pointer-events: none;
}

#chart .watchlist__rule {
    position: absolute;
    left: 0;
    right: 0;
    height: 1px;
    background: var(--wl-rule);
}

#chart .watchlist__band {
    position: absolute;
    left: 0;
    right: 0;
    background: var(--wl-stripe);
}

/* ---------------------------------------------------------------- *
 * Marks
 *
 * Muze puts each layer's `className` on its mark group, so the paint is set
 * here rather than by spending a colour encoding on something that is not a
 * category. The one exception is the change bars, whose Up/Down colours come
 * off Muze's colour axis (see `legend.color.domainRangeMap` in viz.js).
 * ---------------------------------------------------------------- */

#chart .wl-momentum-area path {
    fill: var(--wl-up);
    fill-opacity: 0.16;
    stroke: none;
}

#chart .wl-momentum-line path {
    stroke: var(--wl-up);
    stroke-width: 1.5px;
    fill: none;
}

#chart .wl-close-line path {
    stroke: var(--wl-close);
    stroke-width: 1.5px;
    fill: none;
}

/* The anchors at either end of the close line.
 *
 * The scale is doing the sizing Muze will not: this build ignores the range
 * given to `.size()` and always draws the top of a size scale at r=50 and the
 * bottom at r=2. `transform-box: fill-box` is the part that matters — without
 * it a CSS transform on an SVG path is measured from the viewBox origin, so
 * the marks are moved as well as resized, which is what mangled them the first
 * time. Measured from each path's own box instead, the circle stays where the
 * data put it, and the two radii become a 3.5px anchor and 0.14px of nothing.
 * No stroke, so the 0.14px ones cannot show up as a ring. */
#chart .wl-close-marks path {
    fill: var(--wl-close);
    stroke: none;
    transform-box: fill-box;
    transform-origin: center;
    transform: scale(0.07);
}

/* Muze writes `opacity: 0.5` inline on each point's group — its default for a
 * point mark. An anchor is filled, so it is drawn solid, and an inline style
 * can only be answered with !important. */
#chart .wl-close-marks g {
    opacity: 1 !important;
}

/* ---------------------------------------------------------------- *
 * Hover anchors
 *
 * Muze's `line-anchors` side effect creates a mark on the line at the
 * highlighted session, on demand — it is not in the DOM until you hover. It
 * ships hollow, which read as a different kind of thing from the filled
 * anchors at the ends of the line, so it is filled here to match.
 *
 * The column ids carry the chart's mount id, so they are matched on their
 * suffix rather than spelled out.
 * ---------------------------------------------------------------- */

#chart
    [id$="-col-close"]
    .line-anchors-upper
    path {
    fill: var(--wl-close);
    stroke: var(--wl-bg);
    stroke-width: 1.5px;
}

#chart
    [id$="-col-momentum"]
    .line-anchors-upper
    path {
    fill: var(--wl-up);
    stroke: var(--wl-bg);
    stroke-width: 1.5px;
}

/* The momentum column layers an area under its line, and the area brings its
 * own pair of anchors — three marks on one row for one session. Only the
 * line's is kept. */
#chart .area-anchors-upper,
#chart .area-anchors-lower {
    display: none;
}


/* Muze's crossline, dialled back to a hairline: at this row height a default
 * crossline would read as heavily as the data. */
#chart .muze-crossline-x,
#chart .muze-crossline {
    fill: var(--wl-ink);
    fill-opacity: 0.07;
    stroke: none;
}

/* ---------------------------------------------------------------- *
 * Facet labels — the Symbol and Name columns
 * ---------------------------------------------------------------- */

#chart .wl-facet {
    font-size: 12px;
    color: var(--wl-ink-soft);
    white-space: nowrap;
    transition: color 120ms ease;
}

/* The first facet field is the symbol; it carries the row's identity. */
#chart .wl-facet:first-child {
    font-weight: 700;
    letter-spacing: 0.05em;
    color: var(--wl-ink);
}

#chart .wl-facet.is-hovered {
    color: var(--wl-up);
}

/* ---------------------------------------------------------------- *
 * Footer key
 * ---------------------------------------------------------------- */

#chart .watchlist__note {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 24px;
    width: 100%;
    max-width: calc(var(--wl-gutter) + var(--wl-cell) * 3 + 32px);
    font-size: 11.5px;
    color: var(--wl-ink-faint);
}

#chart .watchlist__key {
    display: flex;
    align-items: center;
    gap: 6px;
}

#chart .watchlist__key-swatch {
    display: inline-block;
    width: 10px;
    height: 10px;
    margin-left: 8px;
    border-radius: 2px;
}

#chart
    .watchlist__key-swatch--up {
    margin-left: 0;
    background: var(--wl-up);
}

#chart
    .watchlist__key-swatch--down {
    background: var(--wl-down);
}

#chart .watchlist__key-line {
    display: inline-block;
    width: 14px;
    height: 2px;
    margin-left: 8px;
    border-radius: 1px;
    background: var(--wl-up);
}

#chart
    .watchlist__key-line--close {
    background: var(--wl-close);
}

/* ---------------------------------------------------------------- *
 * Phone
 *
 * The three columns do not fold — a sparkline table is a table. Below the
 * width that fits them, the card scrolls sideways rather than shrinking the
 * cells past the point where a 25-session line is readable.
 * ---------------------------------------------------------------- */

@media (max-width: 860px) {
    #chart .watchlist {
        align-items: flex-start;
        overflow-x: auto;
    }

    #chart
        .watchlist__readout {
        flex-direction: column;
        align-items: flex-start;
        gap: 10px;
    }

    #chart
        .watchlist__readout-figures {
        gap: 18px;
    }
}

HTML

The mount element. JavaScript creates the table, readout strip, header row, and key.

Preview
<div id="chart"></div>

Dataset (CSV)

Symbol, name, sector, ISO date, and close for sixteen invented instruments over thirty sessions. Change, momentum, and markers are derived in JavaScript.