Svelte and LayerChart
LayerChart builds charts out of Svelte components: a <Chart> holds the scales, and marks
such as <Spline> or <Points> sit in its <Svg> layer. @sportsdataverse/sdvplot/svelte adds Svelte 5
components that sit there too: team logos and player headshots as marks, a team-painted court, field or rink as a
backdrop, and a palette for team-coloured series. Install svelte (5.57 or later: 5.56 cannot compile LayerChart 2.5.1) and layerchart (2.5.1 or later)
beside it; both are optional peers. The components read sdvplot's team data synchronously, so load the league first
(loadLeague). The charts below are drawn by Svelte in your browser.
Logo marks: a bump chart
<LogoMark data x y team league /> draws each row's team logo centred on the row's x and y columns, placed with
the chart's own scales (the middle of the band on a band scale). height is a fraction of the plot area's height, in
(0, 1] (default 0.1), as in the Plotly, Vega-Lite, ECharts and uPlot adapters: LayerChart hands its marks the plot
area's size, so a pixel size such as 24 is refused with an error that says so. data defaults to the chart's own
data. markType="wordmark" draws wordmarks, and season, variant, idSystem, alpha and embed work as in the
other adapters.
teamColorPalette(league, teams, { background }) gives one colour per team for the lines (a <Spline stroke>, a
series' color or the chart's cRange). With background, each colour keeps at least 3:1 contrast against it and
aims to stay apart from every earlier team's (a CIE76 ΔE of 20 or more), as in the uPlot and Recharts adapters: a team
whose colours both clash takes the nearest colour to its own (lightness, hue or chroma stepped) that is distinct and
reads. If no candidate is both, it keeps the readable colour and warns once, so ΔE 20 is a target, not a guarantee.
Here each AFC West team's division rank after every week of 2024 is a line in its colour, and the rows of the last week, picked by week, put each team's logo at its line's end:
With JavaScript on, Svelte (with LayerChart) draws this figure here.
[
{
"team": "KC",
"week": 18,
"rank": 1,
"color": "#e31837"
},
{
"team": "LAC",
"week": 18,
"rank": 2,
"color": "#007bc7"
},
{
"team": "DEN",
"week": 18,
"rank": 3,
"color": "#002244"
},
{
"team": "LV",
"week": 18,
"rank": 4,
"color": "#000000"
}
]import { AFC_WEEKS_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { teamColorPalette } from "@sportsdataverse/sdvplot/svelte";
await loadLeague("nfl");
// The AFC West, picked by team from the week-by-week results (a margin per week; null is the bye).
const teams = ["KC", "LAC", "DEN", "LV"];
const seasons = teams.map((team) => AFC_WEEKS_2024.find((s) => s.team === team));
// Each team's record after every week: wins (a tie counts half), then point differential for the rank. Ties in
// both are broken by name: a plain ranking, not the NFL's tiebreakers.
const rows = Array.from({ length: 18 }, (_, i) => {
const records = seasons.map((s, t) => {
const played = (s?.margins ?? []).slice(0, i + 1).filter((m): m is number => m !== null);
const wins = played.reduce((n, m) => n + (m > 0 ? 1 : m === 0 ? 0.5 : 0), 0);
return { team: teams[t] as string, wins, diff: played.reduce((n, m) => n + m, 0) };
});
records.sort((a, b) => b.wins - a.wins || b.diff - a.diff || a.team.localeCompare(b.team));
return records.map((r, rank) => ({ team: r.team, week: i + 1, rank: rank + 1 }));
}).flat();
// In the browser: examples/snippets/svelte/BumpChart.svelte, mounted with these props (Svelte's mount()).
export const browser = {
lib: "svelte",
component: "BumpChart",
props: { rows, teams, width: 640, height: 320 },
label:
"The AFC West's division rank after each week of 2024: Kansas City first from week 3 on, each line in its team's colour and ending in its logo",
} as const;
// Svelte draws only in a browser here; without JavaScript the page shows the final week and each team's colour.
const colors = teamColorPalette("nfl", teams, { background: "#ffffff" });
rows.filter((r) => r.week === 18).map((r) => ({ ...r, color: colors[teams.indexOf(r.team)] }));
The component the page mounts:
<script lang="ts">
// A LayerChart bump chart: each team's weekly rank as a line in its team colour, its logo at the last week.
// Call loadLeague("nfl") before this mounts (sdvplot/svelte reads the bundled team data synchronously).
import { LogoMark, teamColorPalette } from "@sportsdataverse/sdvplot/svelte";
import { Axis, Chart, Spline, Svg } from "layerchart";
interface Props {
rows: { team: string; week: number; rank: number }[];
teams: string[];
width: number;
height: number;
}
const { rows, teams, width, height }: Props = $props();
// one colour per team, readable on the white card and distinct from each other
const colors = $derived(teamColorPalette("nfl", teams, { background: "#ffffff" }));
const last = $derived(Math.max(...rows.map((r) => r.week)));
</script>
<Chart
data={rows}
x="week"
y="rank"
yDomain={[teams.length, 1]}
{width}
{height}
padding={{ top: 20, right: 48, bottom: 36, left: 36 }}
>
<Svg>
<Axis placement="bottom" label="Week" ticks={[1, 4, 7, 10, 13, 16, 18]} />
<Axis placement="left" label="Division rank" ticks={teams.map((_, i) => i + 1)} format="integer" />
{#each teams as team, i (team)}
<Spline data={rows.filter((r) => r.team === team)} stroke={colors[i]} stroke-width={3} />
{/each}
<!-- the logos at the last week, picked by week, so each sits on its own line's end -->
<LogoMark data={rows.filter((r) => r.week === last)} x="week" y="rank" team="team" league="nfl" height={0.13} />
</Svg>
</Chart>
A team with no logo draws nothing (no broken image) and warns once, as the ECharts adapter does. Every image carries
the data-sdv-* attributes drawnMarks (@sportsdataverse/sdvplot/testing) reads, so a test can check which teams
were drawn where, in data units, with height as the fraction asked for.
Headshot marks
<HeadshotMark x y player league /> draws ESPN headshots (600:436) from a column of athlete ids; idSystem="gsis"
reads nflverse gsis ids after loadGsis(). An id with no headshot draws nothing and warns once.
With JavaScript on, Svelte (with LayerChart) draws this figure here.
[
{
"team": "KC",
"pf": 385,
"pa": 326,
"qb_espn_id": "3139477",
"headshot": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/3139477.png",
"aspect": 1.3761467889908257
},
{
"team": "LAC",
"pf": 402,
"pa": 301,
"qb_espn_id": "4038941",
"headshot": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/4038941.png",
"aspect": 1.3761467889908257
},
{
"team": "DEN",
"pf": 425,
"pa": 311,
"qb_espn_id": "4426338",
"headshot": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/4426338.png",
"aspect": 1.3761467889908257
},
{
"team": "LV",
"pf": 309,
"pa": 434,
"qb_espn_id": "4038524",
"headshot": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/4038524.png",
"aspect": 1.3761467889908257
},
{
"team": "BUF",
"pf": 525,
"pa": 368,
"qb_espn_id": "3918298",
"headshot": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/3918298.png",
"aspect": 1.3761467889908257
},
{
"team": "MIA",
"pf": 345,
"pa": 364,
"qb_espn_id": "4241479",
"headshot": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/4241479.png",
"aspect": 1.3761467889908257
},
{
"team": "NYJ",
"pf": 338,
"pa": 404,
"qb_espn_id": "8439",
"headshot": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/8439.png",
"aspect": 1.3761467889908257
},
{
"team": "NE",
"pf": 289,
"pa": 417,
"qb_espn_id": "4431452",
"headshot": "https://a.espncdn.com/combiner/i?img=/i/headshots/nfl/players/full/4431452.png",
"aspect": 1.3761467889908257
}
]import { STANDINGS } from "@sportsdataverse/examples/data";
import { HEADSHOT_ASPECT, headshotUrl } from "@sportsdataverse/sdvplot";
// The STANDINGS teams' points for and against, and each starting quarterback's ESPN athlete id.
const rows = STANDINGS.map(({ team, pf, pa, qb_espn_id }) => ({ team, pf, pa, qb_espn_id }));
// In the browser: examples/snippets/svelte/QbHeadshots.svelte, mounted with these props (Svelte's mount()).
export const browser = {
lib: "svelte",
component: "QbHeadshots",
props: { rows, width: 560, height: 380 },
label:
"Points for against points allowed for eight 2024 AFC teams, each drawn as its starting quarterback's ESPN headshot",
} as const;
// Svelte draws only in a browser here; without JavaScript the page shows each headshot the chart draws, at its aspect.
rows.map((r) => ({
...r,
headshot: headshotUrl(r.qb_espn_id, "nfl"),
aspect: HEADSHOT_ASPECT,
}));
<script lang="ts">
// A LayerChart scatter of points scored against points allowed, each team drawn as its quarterback's headshot.
// ESPN headshot URLs are built from the id alone, so no league data has to load first.
import { HeadshotMark } from "@sportsdataverse/sdvplot/svelte";
import { Axis, Chart, Svg } from "layerchart";
interface Props {
rows: { team: string; pf: number; pa: number; qb_espn_id: string }[];
width: number;
height: number;
}
const { rows, width, height }: Props = $props();
</script>
<!-- fewer points allowed is better, so the y domain runs high to low: the best defences at the top -->
<Chart
data={rows}
x="pf"
y="pa"
xDomain={[270, 540]}
yDomain={[450, 280]}
{width}
{height}
padding={{ top: 20, right: 24, bottom: 36, left: 44 }}
>
<Svg>
<Axis placement="bottom" label="Points for" grid />
<Axis placement="left" label="Points against" grid />
<HeadshotMark x="pf" y="pa" player="qb_espn_id" league="nfl" height={0.14} />
</Svg>
</Chart>
A court, field or rink under the marks
<SurfaceBackdrop league team displayRange /> paints the league's surface in the team's colours, in the chart's x and
y units: put it first in the <Svg> layer so the marks draw over it, and set the chart's xDomain and yDomain to
the surface's bbox (sporty's surface(...).bbox) to fill the plot area, or to any range in those units to show part
of it. It is clipped to the plot area. Both scales must be numeric, linear and unreversed: on a band, log or reversed
scale it draws nothing and warns once, rather than a stretched or mirrored court. It also needs
@sportsdataverse/sporty installed.
With JavaScript on, Svelte (with LayerChart) draws this figure here.
[
{
"team": "LAL",
"made": 9,
"attempts": 16
},
{
"team": "DEN",
"made": 14,
"attempts": 22
}
]import { NBA_SHOTS } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { surface, toSurfaceFrame } from "@sportsdataverse/sporty";
await loadLeague("nba");
// stats.nba.com shots (x_legacy/y_legacy, tenths of a foot from the hoop), in court feet.
const shots = toSurfaceFrame(NBA_SHOTS, { from: "nba-legacy" }).map((s) => ({
x: Number(s.surface_x),
y: Number(s.surface_y),
team: s.team,
made: s.made,
}));
// the chart's domains are the surface's bbox, so the court fills the plot area and one foot is one foot both ways
const bbox = surface("basketball", "nba", { displayRange: "defense" }).bbox;
const width = 480;
const height = Math.round((width * (bbox[3] - bbox[1])) / (bbox[2] - bbox[0]));
// In the browser: examples/snippets/svelte/CourtShots.svelte, mounted with these props (Svelte's mount()).
export const browser = {
lib: "svelte",
component: "CourtShots",
props: { shots, bbox, width, height },
label:
"The Lakers' and the Nuggets' 38 fourth-quarter shots of 24 October 2023 on Denver's defensive half court, makes filled and misses ringed in each team's colour",
} as const;
// Svelte draws only in a browser here; without JavaScript the page shows each team's makes and attempts.
["LAL", "DEN"].map((team) => ({
team,
made: shots.filter((s) => s.team === team && s.made).length,
attempts: shots.filter((s) => s.team === team).length,
}));
<script lang="ts">
// A LayerChart shot chart over a team-painted court: the scales' domains are the court's bbox in feet, so the
// court fills the plot area and the shots land on it. Call loadLeague("nba") before this mounts.
import { SurfaceBackdrop, teamColorPalette } from "@sportsdataverse/sdvplot/svelte";
import { Chart, Points, Svg } from "layerchart";
interface Props {
shots: { x: number; y: number; team: string; made: boolean }[];
bbox: [number, number, number, number];
width: number;
height: number;
}
const { shots, bbox, width, height }: Props = $props();
const [lal, den] = $derived(teamColorPalette("nba", ["LAL", "DEN"]));
const color = (team: string) => (team === "LAL" ? lal : den);
</script>
<Chart
data={shots}
x="x"
y="y"
xDomain={[bbox[0], bbox[2]]}
yDomain={[bbox[1], bbox[3]]}
{width}
{height}
padding={{ top: 0, right: 0, bottom: 0, left: 0 }}
>
<Svg>
<SurfaceBackdrop league="nba" team="DEN" displayRange="defense" />
<!-- made shots filled in the shooting team's colour, misses white with a ring in it -->
{#each ["LAL", "DEN"] as team (team)}
<Points data={shots.filter((s) => s.team === team && s.made)} r={5} fill={color(team)} />
<Points
data={shots.filter((s) => s.team === team && !s.made)}
r={5}
fill="#ffffff"
stroke={color(team)}
stroke-width={2}
/>
{/each}
</Svg>
</Chart>
Loading, server rendering and a missing peer
Importing @sportsdataverse/sdvplot/svelte loads neither layerchart nor sporty: each component imports what it needs
when it first renders, and reads LayerChart's chart context once that has loaded. So:
- The marks and the backdrop draw on the client. In a SvelteKit server render (
<Chart ssr>), LayerChart draws its own marks and axes and the sdvplot components draw nothing; they appear once the page hydrates. The module itself touches nowindowordocument, so importing it on the server is safe. - Without layerchart installed, the import still works and
teamColorPalettestill works; a component that renders throwsOptionalDependencyError("LogoMark needs the optional peer layerchart, which could not be loaded: pnpm add layerchart"), which a<svelte:boundary>catches.
The components ship as Svelte source under the svelte export condition, as Svelte libraries do, so your app's Svelte
compiler builds them (Vite and SvelteKit pick the condition up themselves).