Timeline marks
Pin dividends, splits, earnings, news, and other events to the chart with chart.marks.
Pin events to the chart — dividends, splits, earnings, news, releases, deployments, anything
with a moment in time. Vela™ shows each one as a small glyph on a lane just above the time
axis, snapped to the bar it belongs to, and opens a popup with the details when it is
clicked. The surface is chart.marks; it needs no scripting engine.
Marks are data, not user state. The chart never persists them — you re-supply them when the market changes. What the chart does save is which kinds of event the user chose to hide (the Events tab of the chart settings).
Quick start
import { Vela } from '@luxalgo/vela';
const chart = new Vela('#chart', { symbol: 'NVDA', timeframe: 'D' });
chart.marks
.defineGroup({ id: 'dividends', label: 'Dividends' })
.add({
id: 'div-2024-06-11',
time: Date.UTC(2024, 5, 11),
title: 'Dividend',
group: 'dividends',
glyph: { shape: 'circle', color: '#2962ff', letter: 'D' },
tooltip: 'Dividend · $0.01',
content: {
panel: {
items: [
{ type: 'field', label: 'Ex-dividend date', value: "Tue 11 Jun '24" },
{ type: 'field', label: 'Amount', value: '0.01' },
{ type: 'field', label: 'Payment date', value: "Fri 28 Jun '24" },
],
},
},
});A blue token with a D appears above the time axis on the 11 June bar. Hover it for the tooltip; click it for the popup. Open the chart settings: an Events tab now lists a Dividends checkbox.
What a mark is
| Field | Required | What it does |
|---|---|---|
id | yes | Stable identity. Adding a mark with an existing id replaces it in place. |
time | yes | The moment the event belongs to, in epoch-ms. The glyph never sits at this exact pixel — see Where a mark lands. |
glyph | yes | How the token looks: { shape?, color, letter? } or { shape?, color, icon } — see Glyphs. |
title | no | The popup's heading, whatever form the content takes. |
tooltip | no | Hover text on the glyph. Without it a lone mark shows its title. |
group | no | The kind of event ('dividends', 'news'…). Marks of one group on one bar — or on neighbouring bars once zoomed out far enough for their glyphs to overlap — fold together; each group gets a checkbox in settings — see Groups. |
content | no | What the popup shows — text, sanitized HTML, or a panel; a value or a function resolved on click. Without it a click only emits mark:click — see The popup. |
The full type is TimelineMark, exported from @luxalgo/vela.
Where a mark lands
A mark's time is a moment; the lane shows bars. The glyph centers on the bar whose span
contains the time:
- a time inside a bar's
[open, open + interval)span sits on that bar; - a time that falls in a gap — a weekend, a closed session — goes to the first bar after it (an event dated outside trading hours affects the next traded bar, not the previous one);
- a time past the newest bar projects onto the extrapolated bar grid, in the whitespace to the right of the last candle;
- a time before the first loaded bar has nothing to anchor to yet — the glyph appears once history backfills that far.
Marks re-snap whenever the bars change: switch the timeframe and three news items 15 minutes apart become three glyphs on a 5-minute chart and one cluster on an hourly chart. Zoom does the same within a timeframe — see Clustering.
const t = Date.UTC(2024, 5, 11, 14, 0);
chart.marks.defineGroup({ id: 'news', label: 'News' }).set(
[0, 15, 30].map((minutes, i) => ({
id: `news-${i + 1}`,
time: t + minutes * 60_000,
title: `News ${i + 1}/3`,
group: 'news',
glyph: { color: '#26a69a', letter: 'N' },
content: { text: `Headline ${i + 1}` },
})),
);Glyphs
A glyph is a colored token: an outline and a symbol in the mark's color on the chart
background. The token swells briefly when the pointer lands on it, and fills with its color
(white symbol) while its popup is open.
shape—'circle'(default),'square','diamond', or'pin'.color— any CSS color; the outline, the symbol, and the filled state all use it.letter— one or two characters drawn inside the token ('D','E','!').icon— an icon id from the icon registry, drawn inside the token instead of a letter. Register the SVG once, from host code; a mark payload only ever carries the id, so marks can come straight from data without carrying markup.
import { registerIcon, svg16 } from '@luxalgo/vela/ui';
registerIcon('myco.rocket', svg16('<path d="M9.5 2.5c1.8 0 3.5 1.7 4 4.5l-4 4-4-4c.5-2.8 2.2-4.5 4-4.5z"/><path d="M5.5 7 3 9.5l2 .5.5 2L8 9.5"/>'));
chart.marks.add({
id: 'release-2-4',
time: Date.UTC(2024, 5, 12, 9, 30),
title: 'Release v2.4',
group: 'releases',
glyph: { shape: 'pin', color: '#7e57c2', icon: 'myco.rocket' },
content: { text: 'Deployed v2.4.0 to production.' },
});Several marks of one group on one bar share a single, slightly larger token that carries the first mark's glyph (earliest time, then insertion order).
The popup
Clicking a glyph opens a popup centered on it: the mark's title, then its content. A cluster
lists every mark it holds, one section each, in a scrollable panel. The popup closes on an
outside click or Escape.
content takes one of three forms:
Plain text — rendered as text, never as markup:
content: { text: 'Special dividend of 0.25, declared the same session.' }HTML — formatting kept, hazards removed. The markup goes through an allowlist: paragraphs,
emphasis, lists, tables, code, links, and images survive; scripts, styles, frames, forms, event
handlers (onclick…), inline style, and javascript:/data: URLs never reach the page. Links
open in a new tab with rel="noopener noreferrer"; images must have an absolute http(s)
source.
content: {
html: '<b>10-for-1 split</b><br>Effective <i>10 Jun 2024</i> · <a href="https://example.com/filing">Filing</a>',
}A panel — rows of field (label / value), text, and button. Consecutive buttons share
one row; primary emphasizes a button; a button closes the popup after run unless close: false.
content: {
panel: {
items: [
{ type: 'field', label: 'EPS', value: '6.12 (est. 5.59)' },
{ type: 'field', label: 'Revenue', value: '26.0B (est. 24.6B)' },
{ type: 'text', text: 'Beat on both lines; guidance raised for Q2.' },
{ type: 'button', label: 'Transcript', primary: true, run: () => openTranscript('NVDA', '2024Q1') },
{ type: 'button', label: 'Dismiss', run: () => undefined },
],
},
}On demand — pass a function (sync or async) returning any of the forms above. It runs when the popup needs the section: on click for a lone mark, and for a cluster as each section scrolls into view — a cluster of forty marks costs one request per entry actually read. While it resolves the section shows Loading…; a rejection shows a short error line.
content: async () => ({ html: await fetchDividendDetails('NVDA', '2024-06-11') }),Groups, clusters, and stacks
group names the kind of event. It drives three things.
Clustering. Marks of one group that land on the same bar fold into one cluster glyph —
slightly larger than a single mark, carrying the first mark's glyph, and hovering as
<Group label> · <count>. Its popup lists every mark, earliest first. The same fold happens
across neighbouring bars whenever the glyphs would overlap at the current zoom: zoomed far
out, same-group marks that would overlap fold into one cluster sitting on their earliest bar, and
the next cluster starts where the glyphs clear each other — so a dense feed reads as a row of
clusters a glyph apart, never a solid band and never one giant glyph. Zoom back in and they
separate again. Groups never fold into each other.
Stacking. Marks of different groups on one bar form a stack: a small deck showing the top group's token with the others peeking out behind it. Hovering the deck fans the tokens out vertically so each can be opened; on touch, one tap fans it out and a second tap opens a token. Defined groups stack in definition order; groups that were never defined follow, and ungrouped marks sit last.
Visibility. Every group gets a checkbox on the Events tab of the chart settings (under
Visible events), labeled with the group's label. Define a group to name it and to choose its
default:
chart.marks
.defineGroup({ id: 'dividends', label: 'Dividends' })
.defineGroup({ id: 'splits', label: 'Splits' })
.defineGroup({ id: 'news', label: 'News', visible: false }); // hidden until the user opts inA mark may name a group you never defined — it then shows its capitalized id ('splits' →
Splits). Ungrouped marks have no checkbox and always show.
Nesting. A group can name a parent. Its checkbox lists indented under the parent's and
dims while the parent is off; its marks paint only while both switches are on. That gives a
master switch with sub-choices — the child keeps its own choice for when the parent comes back:
chart.marks
.defineGroup({ id: 'news', label: 'News', visible: false })
.defineGroup({ id: 'news-latest', label: 'Show most recent', parent: 'news' })
.defineGroup({ id: 'news-all', label: 'Show historical', parent: 'news', visible: false })
.defineGroup({ id: 'economic', label: 'Economic releases' })
.defineGroup({ id: 'economic-high', label: 'High impact', parent: 'economic' })
.defineGroup({ id: 'economic-low', label: 'Low impact', parent: 'economic', visible: false });A parent may carry marks of its own. A parent that names no defined group is ignored (the
group lists at the top level), so a typo can never make a group vanish from the tab.
The user's choice is persisted with the chart's cosmetic config (chart.renderer.getConfig()
→ marks.groups), so it survives a reload and rides workspace templates. A stored choice for a
group the host has not registered yet is kept verbatim until that group shows up. The same
switch is available from code:
chart.marks.setGroupVisible('news', false);
chart.marks.isGroupVisible('news'); // false — the stored choice, else the group's declared default, else true; a nested group also needs every ancestor on
chart.renderer.set('marks', false); // hide the whole lane
chart.renderer.set('marks', { groups: { splits: false } }); // the settings checkbox, from codeReacting to clicks
Every click on a glyph emits mark:click with the mark's id, the ids of every mark under
the glyph (a cluster lists them all), the first mark's time, and the group. It fires
before the popup opens — and a mark without content opens nothing, so a host that
wants its own UI simply omits the content and listens:
chart.marks.add({
id: 'alert-42',
time: Date.UTC(2024, 5, 13, 15, 30),
glyph: { shape: 'diamond', color: '#e0b400', letter: '!' },
tooltip: 'Open in the alerts panel',
});
chart.on('mark:click', ({ id, ids, time, group }) => {
if (id === 'alert-42') alertsPanel.open(id);
});A content-less token still fills briefly when clicked, so the click reads as acknowledged.
Keeping marks in sync with the market
The chart keeps the marks you gave it across timeframe changes (they re-snap) but has no idea which events belong to which symbol. Replace the set when the market changes:
async function showEventsFor(symbol) {
const events = await api.corporateEvents(symbol); // your source
chart.marks.set(events.map(toMark));
}
await chart.ready();
showEventsFor(chart.market.symbol);
chart.on('market:changed', ({ symbol }) => showEventsFor(symbol));set replaces everything (and closes an open popup); add, remove, and clear edit in place;
all() returns the current set in insertion order.
In a workspace, every cell is its own chart — seed the cells alive now and the ones a later layout switch mints:
const seed = (chart) => void chart.ready().then(() => showEventsFor(chart));
for (const cell of ws.cells()) seed(cell.chart);
ws.on('cell:created', ({ id }) => seed(ws.cell(id).chart));Settings ids and renderer support
The Events tab, its section, and every checkbox carry settings ids, so a host can hide them like
any other setting: settings: { hidden: ['events'] } removes the tab, 'events.groups.<group-id>'
one row (the group id, kebab-cased). The tab is present only while marks name groups. See
Options for the full catalog.
Timeline marks are a native-renderer capability (capabilities.timelineMarks).
chart.marks.supported reports it; on a renderer without it the model still fills — all()
works — but nothing paints and setGroupVisible warns and no-ops.
API summary
| Member | Description |
|---|---|
chart.marks.add(mark) | Add one mark; an existing id is replaced. Chainable. |
chart.marks.set(marks) | Replace the whole set. |
chart.marks.remove(id) · clear() | Drop one mark, or all. |
chart.marks.all() | Every mark, in insertion order. |
chart.marks.defineGroup({ id, label, visible?, parent? }) | Name a group, choose its default visibility, and optionally nest it under another defined group on the Events tab. |
chart.marks.groups() | The defined groups, in definition order. |
chart.marks.setGroupVisible(id, visible?) · isGroupVisible(id) | The settings checkbox, from code. |
chart.marks.supported | Whether the active renderer paints marks. |
chart.renderer.set('marks', …) | The lane's display state: false/true, or { visible?, groups? }. |
chart.on('mark:click', cb) | { id, ids, time, group? } on every glyph click. |
The type-level detail lives in the API reference.