Skip to main content

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 for createChart with layout.attributionLogo on: 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 }), withAttribution turns it back on and warns once.

  • The notice. attributionNotice(container) puts it on the page: a <p> appended to container (give it the element around the chart, el.parentElement ?? el), holding TRADINGVIEW_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 (no document) it does nothing.

Both calls, around createChart (a browser module):

chart.ts
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.