Lightweight Charts
TradingView Lightweight Charts draws time series on a canvas: price
lines with a crosshair, a price scale and a live last-value label, which suits a betting line moving or a win
probability swinging through a game. @sportsdataverse/sdvplot/lightweight-charts gives it team colours, prices read
as probabilities or odds, team logos at points, and the TradingView attribution its licence asks for. It never imports
lightweight-charts (install lightweight-charts 5.2.1 or later beside it, an optional peer), so everything can be
built anywhere, a server included; only createChart(...) needs a browser. It is synchronous: load the league first
(loadLeague). The chart below is drawn by Lightweight Charts in your browser.
The TradingView attribution
Lightweight Charts is licensed under the Apache License 2.0, and its README (at 5.2.1) adds:
This license requires specifying TradingView as the product creator. You shall add the "attribution notice" from the NOTICE file and a link to https://www.tradingview.com/ to the page of your website or mobile application that is available to your users.
and that its attributionLogo chart option "will satisfy the link requirement". So a page that shows a chart needs
two things:
-
The link.
withAttribution(chartOptions)returns the options forcreateChartwithlayout.attributionLogoon: the library draws TradingView's logo on the chart, a link to tradingview.com. It is on by default. The options are copied, never changed. If your options turn the logo off (layout: { attributionLogo: false }),withAttributionturns it back on and warns once. -
The notice.
attributionNotice(container)puts it on the page: a<p>appended tocontainer(give it the element around the chart,el.parentElement ?? el), holdingTRADINGVIEW_NOTICE, the attribution notice verbatim from the library's NOTICE file at 5.2.1, with its URL as a link:TradingView Lightweight Charts™Copyright (с) 2025 TradingView, Inc. https://www.tradingview.com/It returns that
<p>. Called again on the same container it returns the same notice and adds none, so a chart that is redrawn keeps one notice. On a server (nodocument) it does nothing.
Both calls, around createChart (a browser module):
import { loadLeague } from "@sportsdataverse/sdvplot";
import {
attributionNotice,
logoMarkers,
teamSeriesOptions,
withAttribution,
} from "@sportsdataverse/sdvplot/lightweight-charts";
import { type IChartApi, LineSeries, type UTCTimestamp, createChart } from "lightweight-charts";
/**
* A team's win-probability line in `el` (a browser module): TradingView's logo on the chart, its notice on the page
* under it, and the team's logo at the line's last point. `points` are seconds played and win probabilities.
*/
export async function winProbabilityChart(
el: HTMLElement,
team: string,
points: readonly { time: number; value: number }[],
): Promise<IChartApi> {
await loadLeague("nfl");
const chart = createChart(el, withAttribution({ width: 640, height: 320 }));
const line = chart.addSeries(LineSeries, teamSeriesOptions(team, { league: "nfl", units: "probability" }));
line.setData(points.map((p) => ({ time: p.time as UTCTimestamp, value: p.value })));
const last = points.at(-1);
if (last !== undefined)
line.attachPrimitive(
logoMarkers([{ ...last, team }], {
x: "time",
y: "value",
team: "team",
league: "nfl",
units: "probability",
}),
);
attributionNotice(el.parentElement ?? el);
return chart;
}
The only way to turn the logo off is explicit: withAttribution(chartOptions, { attributedElsewhere: true }). Pass it
only when your page already shows the notice and a link to https://www.tradingview.com/ somewhere else (a footer, an
about page the chart links to). Meeting the licence is then yours: the adapter can no longer show it for you.
sdvplot does not bundle or redistribute Lightweight Charts; the repository's NOTICE.md records the notice all the
same.
Team colours, and prices read as probabilities or odds
teamSeriesOptions(team, { league, units }) is a LineSeries' options in the team's colour, for
chart.addSeries(LineSeries, ...). units says what the prices are, and sets the series' priceFormat, which the
price scale, the crosshair label and the last-value label all read:
"probability": a fraction in [0, 1], read as a percentage ("58.5%", "100%");"american": American odds, read with their sign ("+150", "-120"), rounded to whole points half away from zero (150.5 reads "+151", -150.5 reads "-151"); a price that rounds to zero reads "0", never "+0" or "-0".
Without units, Lightweight Charts formats the numbers itself. A team that does not resolve takes fallback (default
#808080) and warns.
logoMarkers(rows, { x, y, team, league, height }) paints each row's logo centred on its x (a time, as in the series'
data) and y (a price on the series' scale), placed with the chart's own timeToCoordinate and priceToCoordinate,
height of the pane tall (a fraction in (0, 1], as in the Plotly, Vega-Lite and ECharts adapters; default 0.1).
Lightweight Charts' series markers draw only shapes and text, so the logos come from a series primitive: attach it with
series.attachPrimitive(logos). With units, logoMarkers also checks the y values: a "probability" over 1 (58.46
rather than 0.5846) or American odds between -100 and +100 (1.91 is decimal odds) throw.
Here each team's line is its win probability in Super Bowl LIX, by seconds played (Lightweight Charts' time), and
each line ends in its team's logo:
With JavaScript on, Lightweight Charts draws this figure here.
{
"lines": [
{
"team": "PHI",
"color": "#004c54",
"final": "100%"
},
{
"team": "KC",
"color": "#e31837",
"final": "0%"
}
]
}import { SUPER_BOWL_LIX_WP } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import {
attributionNotice,
logoMarkers,
teamSeriesOptions,
withAttribution,
} from "@sportsdataverse/sdvplot/lightweight-charts";
import type { Time, UTCTimestamp } from "lightweight-charts";
await loadLeague("nfl");
// Lightweight Charts' x is a time in seconds and must rise strictly: seconds played, keeping the last play at each
// second (two plays can start on one clock reading, as the two pregame rows at 0:00 do).
const bySecond = new Map<number, number>();
for (const p of SUPER_BOWL_LIX_WP) bySecond.set(Math.round(p.minute * 60), p.home_wp);
const phi = [...bySecond].map(([time, value]) => ({ time, value }));
// a two-team game: Kansas City's win probability is 1 - home_wp, derived from the home series, not a column of its own
const kc = phi.map((p) => ({ time: p.time, value: 1 - p.value }));
const teams = ["PHI", "KC"];
const line = (team: string) =>
teamSeriesOptions(team, { league: "nfl", teams, units: "probability", background: "#ffffff" });
// each team's logo at its own line's last point
const end = (team: string, points: readonly { time: number; value: number }[]) => {
const last = points[points.length - 1];
if (last === undefined) throw new Error(`no points for ${team}`);
return logoMarkers([{ ...last, team }], {
x: "time",
y: "value",
team: "team",
league: "nfl",
units: "probability",
height: 0.12,
});
};
// In the browser this page calls mount with lightweight-charts' module and the chart's element.
export const browser = {
lib: "lightweight-charts",
mount: ({ createChart, LineSeries }: typeof import("lightweight-charts"), el: HTMLElement) => {
// TradingView's logo (the link its licence asks for) stays on the chart
const chart = createChart(
el,
withAttribution({
width: 640,
height: 320,
rightPriceScale: { scaleMargins: { top: 0.1, bottom: 0.1 } },
timeScale: { rightOffset: 12, tickMarkFormatter: (t: Time) => `${Math.round(Number(t) / 60)}'` },
localization: { timeFormatter: (t: Time) => `${(Number(t) / 60).toFixed(1)} minutes played` },
}),
);
const lines = [
["PHI", phi],
["KC", kc],
] as const;
const primitives = lines.map(([team, points]) => [end(team, points)]);
const series = lines.map(([team, points], i) => {
const s = chart.addSeries(LineSeries, { ...line(team), title: team });
s.setData(points.map((p) => ({ time: p.time as UTCTimestamp, value: p.value })));
for (const p of primitives[i] ?? []) s.attachPrimitive(p);
return s;
});
chart.timeScale().fitContent();
// and TradingView's notice goes on the page, under the chart
attributionNotice(el.parentElement ?? el);
return { chart, series, primitives };
},
label:
"Super Bowl LIX win probability by minute, Philadelphia in Eagles green and Kansas City in Chiefs red, each line ending in its team's logo",
} as const;
// Lightweight Charts draws only in a browser; without JavaScript the page shows each line and its final reading.
{
lines: teams.map((team, i) => {
const o = line(team);
const final = (i === 0 ? phi : kc).at(-1)?.value ?? Number.NaN;
return { team, color: o.color, final: o.priceFormat?.formatter(final) };
}),
};
Building logoMarkers fetches nothing. The images start loading when it is attached, with
crossOrigin = "anonymous" so the canvas stays exportable, and the chart redraws as each one lands. A team with no logo
draws nothing and warns once, as the ECharts adapter does; an image that fails to load is never drawn (no broken image)
and warns once. A row at a time the series has no point for, or a price off the pane, is not drawn: keep the logos
inside the series' price range, or give the price scale room (scaleMargins).
Lightweight Charts' time must rise strictly. Plays that start on the same clock reading share a time, so the example
keeps the last play at each second.
Dark mode, and several teams on one chart
Pass the chart's background as background and the colour keeps at least 3:1 contrast against it (WCAG's minimum for
graphics): a team colour under that gives way to the team's other colour, else is lightened (on a dark background) or
darkened until it reads. Pass every team on the chart as teams, in order, and each also stays apart from the earlier
ones (a CIE76 ΔE of 20 or more); if no candidate is both readable and distinct, it keeps the readable colour and warns
once. Call teamSeriesOptions once per series with the
same teams. A colour that already reads and is distinct is kept; without background the colours are the teams' own.
A team that does not resolve never moves a real team's colour. Set the chart's own layout.background and
layout.textColor to match.
Betting lines
units: "american" is there for a moneyline moving over the days before a game, with logoMarkers marking where it
opened or closed. The examples here hold no real line history yet, and they never invent one, so the gallery shows a win
probability only.
Reading back what was drawn
drawnMarks(logos) returns [team id, x, y, height, url] for each logo the primitive last painted, height as a
fraction of the pane, the same shape as the other adapters' drawnMarks. A test can draw the chart (a browser, or jsdom
with a 2D context behind its canvases, as sdvplot's own tests do with the real library) and check which teams were
drawn where.