Skip to main content

Perspective

Perspective (Apache-2.0) is a data grid and chart viewer whose engine is WebAssembly: a table lives in a Web Worker, a <perspective-viewer> element queries it, and the user can group, split, filter and re-plot it in the page. Its npm packages were renamed: the @finos/perspective line stopped at 3.8.0, and the project now publishes @perspective-dev/client (the engine's client), @perspective-dev/server (the engine), @perspective-dev/viewer (the element), @perspective-dev/viewer-datagrid (the grid) and @perspective-dev/viewer-charts (the charts: "Y Bar", "X Bar", "Y Line", "X/Y Scatter" and the rest). In 5.x viewer-charts, a WebGL plugin, replaced @perspective-dev/viewer-d3fc, whose line stopped at 4.4.1.

@sportsdataverse/sdvplot/perspective builds what a viewer takes, as plain data: perspectiveConfig(rows, options) returns a viewer config (the argument of viewer.restore()) that colours each team by its own colours and formats the number columns, and perspectiveLogoColumn(rows, teamCol, options) adds a column of logo URLs. It never imports Perspective, not even its types, so it runs anywhere, a server included. It is synchronous: load the league first (loadLeague). It is tested on Perspective 5.5.1, the floor of its optional peer @perspective-dev/viewer.

The same config as Python​

sdvplot for Python has the same pair in sdvplot.perspective: perspective_sdv_config and perspective_logo_column. Given the same rows and options, perspectiveConfig returns the same dictionary, key for key and colour for colour, so a viewer set up from a notebook and one set up from a web page look alike. A test holds the two together: the Python function was run on real frames (the grid, split bars and lines, a scatter, light and dark, numeric team ids, a missing team, teams that do not resolve, number formats, a logo column, no rows, a table theme), its answers are committed as a fixture, and the test checks that the JavaScript output equals each one, with the same warnings. The logo column is held the same way: perspective_logo_column's URLs on real frames (string and numeric team ids, a missing team, teams that do not resolve or have no logo, the "dark" variant, relocated franchises by season) equal perspectiveLogoColumn's, although Python reads the logo manifest and JavaScript the index bundled in this package. The test does not run Python: when either side changes, the fixture is written again from the Python package. The options are the Python keyword arguments in JavaScript's spelling:

PythonJavaScript
perspective_sdv_config(frame, team_col=, league=, ...)perspectiveConfig(rows, { teamCol, league, ... })
plugin, mode, background, formats, season, strictthe same names
logo_col, id_systemlogoCol, idSystem
perspective_logo_column(frame, team_col, league=, name=, ...)perspectiveLogoColumn(rows, teamCol, { league, name, ... })
the frame's column typesschema, or worked out from the rows
theme="datagolf"theme: (m) => tableThemeSpec("datagolf", m), from @sportsdataverse/sdvtables

Python reads each column's type from the frame. Rows of plain objects carry no types, so perspectiveConfig works them out as Perspective does when it loads the rows: numbers, and strings that read as numbers ("12", "1.5"), make a number column, an integer one when every value is a whole number. That matters for team ids: ESPN ids held as strings are numbers to Perspective, so they get no grid fill, as numbers do. When you create the table from a schema of your own (an empty table you stream into, say), pass the same schema.

Team colours in the Datagrid​

On the Datagrid (plugin "Datagrid", the default) the team column's cells are filled, each team in its colour, and Perspective picks a readable text colour on each. Perspective takes a palette, not a colour per team: the grid hands the palette's colours to the values in the order it first draws them, so perspectiveConfig lists them in the rows' order. Keep the grid in that order (no sort) and to about one row per team; in a long grid, scrolling to a team the grid has not drawn yet shifts the later colours. A missing team takes no colour. Only a text team column is filled: Perspective fills no number column, so numeric team ids get none, with one warning.

Every number column also gets a number_format (Intl.NumberFormat options): 3 decimals for a float such as EPA, 1 for a float whose name says it is a percentage (pct, percent), and an integer as typed (2024, not 2,024). formats replaces a column's format, or drops it with null.

The Datagrid draws no images, so a logo column is shown as links: add it with perspectiveLogoColumn, which puts each team's logo URL right after the team column (null, with one warning for each reason, for a team that does not resolve or has no logo), and name it in logoCol.

With JavaScript on, Perspective draws this figure here.

{
  "plugin": "Datagrid",
  "theme": "Pro Light",
  "columns_config": {
    "team": {
      "bg_mode": "series",
      "bg_color": "linear-gradient(to right, #241773, #c60c30, #8d9296, #5a1414, #b85149, #53575f, #ff4a41, #203731, #bf880e, #97233f, #a71600, #006fce, #000000, #6d2482, #0043aa, #004c6e, #00472c, #59a222, #0d6f7f, #1e2d00, #32202f, #dc751d, #0085ca, #003876, #82703f, #e64100, #000940, #002244, #492e5b, #6d92b8, #2d0000, #311d00)"
    },
    "plays": {
      "number_format": {
        "useGrouping": false,
        "maximumFractionDigits": 0
      }
    },
    "epa_per_play": {
      "number_format": {
        "minimumFractionDigits": 3,
        "maximumFractionDigits": 3
      }
    },
    "def_epa_per_play": {
      "number_format": {
        "minimumFractionDigits": 3,
        "maximumFractionDigits": 3
      }
    },
    "team_logo": {
      "link": true
    }
  }
}
import { NFL_TEAM_EPA_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { perspectiveConfig, perspectiveLogoColumn } from "@sportsdataverse/sdvplot/perspective";

await loadLeague("nfl");
// nflverse's 2024 regular season, best offence first: the grid hands each team its colour in this order
const offence = NFL_TEAM_EPA_2024.map((t) => ({
team: t.team,
plays: t.off_plays,
epa_per_play: t.off_epa / t.off_plays,
def_epa_per_play: t.def_epa / t.def_plays,
})).sort((a, b) => b.epa_per_play - a.epa_per_play);
// a logo URL column right after the team (the Datagrid draws no images: it shows a URL as a link)
const rows = perspectiveLogoColumn(offence, "team", { league: "nfl" });
const config = perspectiveConfig(rows, { teamCol: "team", league: "nfl", logoCol: "team_logo" });

// In the browser: the rows as a Perspective table, the viewer bound to it, and sdvplot's config restored
export const browser = {
lib: "perspective",
mount: async ({
worker,
viewer,
}: {
worker: import("@perspective-dev/client").Client;
viewer: import("@perspective-dev/viewer").HTMLPerspectiveViewerElement;
}) => {
const table = await worker.table(rows.map((r) => ({ ...r })));
await viewer.load(worker);
// the logo links last: a URL is wide, and the numbers stay in view
await viewer.restore({
table: await table.get_name(),
...config,
columns: ["team", "plays", "epa_per_play", "def_epa_per_play", "team_logo"],
});
return () => void table.delete();
},
height: 520,
label: `The 2024 NFL offences by EPA per play in Perspective's Datagrid, best first, each team's cell in its own colour: ${rows
.map((r) => `${r.team} ${r.epa_per_play.toFixed(3)}`)
.join(", ")}`,
} as const;

// Perspective draws only in a browser; without JavaScript the page shows the config sdvplot hands it
config;

Fonts and sign colours from a table theme​

theme takes one of the 122 surveyed table themes of the table theme registry, the same file the Python package reads. sdvplot does not ship the registry; sdvtables does, and its tableThemeSpec(name, mode) resolves a theme as Python's table_theme_spec does: the fonts and sizes, the palette with every missing colour taken from the shared defaults, and the structure. Give theme a function of the mode that returns the spec: perspectiveConfig calls it with its own mode, on every plugin, so the colours always match the mode and a light-only theme in dark mode throws, as Python's theme= does. A spec object works too, but it does not record its mode, so resolve it in the same mode; anything else (a theme name, say) is an InputError. tableThemeSpec raises a TableSpecError for an unknown name (suggesting close ones; the suggestions can differ from Python's), for "espn", "fivethirtyeight" or "pff" (reactablefmtr ports: the fmtr-espn theme, or "espn-site" for the surveyed site), and for mode: "dark" on a light-only theme.

On the Datagrid the theme sets the grid's font and size (plugin_config), gives every number column the theme's sign colours (fg_color: linear-gradient(to right, <neg> 0%, <pos> 100%), the theme's neg and pos colours, as Python sets them) and, when the theme has a numeric face of its own (DM Mono in "the-pudding", Roboto Mono in "nfl-next-gen-stats"), puts that face on the number columns. The team column keeps its team fill. Perspective takes each face as one family name, with no fallback, so load the font on the page (Google Fonts) or the browser draws its default. A chart takes no fonts from a config, so on any other plugin the theme adds nothing. Any object with the same fonts, sizes and palette fields does as well (the type is PerspectiveTheme; Perspective reads only #hex and rgb() colours in fg_color, so a named or hsl() colour leaves the default), and the parity test above covers the theme too: Python's answers with "datagolf" light and dark, "nfl-next-gen-stats" and "ft-markets-data" equal what perspectiveConfig gives with sdvtables' spec.

Perspective 5.5 paints a positive number in the gradient's last colour and a negative one in its first, so the theme's pos and neg (a zero keeps the grid's text colour). The example below, in "nfl-next-gen-stats", is checked in Chromium: every number is painted in its sign's colour and given the numeric face, the rest of the grid the body face and size, and each team's cell keeps its colour. The page loads no font file, so the check is of the faces the cells are given, not the glyphs drawn: your browser may draw Roboto and Roboto Mono with its own fallback, and the colours do not depend on it. The colour follows the sign only, not whether a number is good: a defence's EPA allowed is green when it is positive, and a count such as plays is green on every row.

With JavaScript on, Perspective draws this figure here.

{
  "plugin": "Datagrid",
  "theme": "Pro Light",
  "plugin_config": {
    "font_family": "Roboto",
    "font_size": 13
  },
  "columns_config": {
    "team": {
      "bg_mode": "series",
      "bg_color": "linear-gradient(to right, #241773, #c60c30, #8d9296, #5a1414, #b85149, #53575f, #ff4a41, #203731, #bf880e, #97233f, #a71600, #006fce, #000000, #6d2482, #0043aa, #004c6e, #00472c, #59a222, #0d6f7f, #1e2d00, #32202f, #dc751d, #0085ca, #003876, #82703f, #e64100, #000940, #002244, #492e5b, #6d92b8, #2d0000, #311d00)"
    },
    "plays": {
      "number_format": {
        "useGrouping": false,
        "maximumFractionDigits": 0
      },
      "fg_color": "linear-gradient(to right, #c82d2d 0%, #327835 100%)",
      "font_family": "Roboto Mono"
    },
    "epa_per_play": {
      "number_format": {
        "minimumFractionDigits": 3,
        "maximumFractionDigits": 3
      },
      "fg_color": "linear-gradient(to right, #c82d2d 0%, #327835 100%)",
      "font_family": "Roboto Mono"
    },
    "def_epa_per_play": {
      "number_format": {
        "minimumFractionDigits": 3,
        "maximumFractionDigits": 3
      },
      "fg_color": "linear-gradient(to right, #c82d2d 0%, #327835 100%)",
      "font_family": "Roboto Mono"
    }
  }
}
import { NFL_TEAM_EPA_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { perspectiveConfig } from "@sportsdataverse/sdvplot/perspective";
import { tableThemeSpec } from "@sportsdataverse/sdvtables";

await loadLeague("nfl");
// nflverse's 2024 regular season, best offence first: EPA per play on offence and allowed on defence, both signed
const offence = NFL_TEAM_EPA_2024.map((t) => ({
team: t.team,
plays: t.off_plays,
epa_per_play: t.off_epa / t.off_plays,
def_epa_per_play: t.def_epa / t.def_plays,
})).sort((a, b) => b.epa_per_play - a.epa_per_play);
// the NFL Next Gen Stats look: Roboto for the grid, Roboto Mono for the numbers, its red and green for the sign. No
// font file is loaded here: a reader without the faces sees the browser's fallback, in the same colours
const config = perspectiveConfig(offence, {
teamCol: "team",
league: "nfl",
theme: (m) => tableThemeSpec("nfl-next-gen-stats", m),
});

// In the browser: the rows as a Perspective table, the viewer bound to it, and sdvplot's config restored
export const browser = {
lib: "perspective",
mount: async ({
worker,
viewer,
}: {
worker: import("@perspective-dev/client").Client;
viewer: import("@perspective-dev/viewer").HTMLPerspectiveViewerElement;
}) => {
const table = await worker.table(offence.map((r) => ({ ...r })));
await viewer.load(worker);
await viewer.restore({ table: await table.get_name(), ...config });
return () => void table.delete();
},
height: 520,
label: `The 2024 NFL offences by EPA per play in Perspective's Datagrid, in the NFL Next Gen Stats table theme, best first, negative numbers red and positive green: ${offence
.map((r) => `${r.team} ${r.epa_per_play.toFixed(3)}`)
.join(", ")}`,
} as const;

// Perspective draws only in a browser; without JavaScript the page shows the config sdvplot hands it
config;

Team colours in a chart, and dark mode​

For a chart, merge your own view into the config: split a bar, line or area chart by the team (group_by and split_by the team column) and each team's series takes its colour from the value column's palette. Perspective sorts the split values (by code unit, so upper case first, and numbers as numbers), and the palette is in that order; a missing team splits into a series of its own that comes first and takes grey. For an "X/Y Scatter", put the team column in the Color slot (the third of columns): its points take their colours from the team column's palette, in the order Perspective meets the teams, the rows' order. (A scatter's split_by draws a panel per team instead.)

mode: "dark" picks Perspective's "Pro Dark" theme, and the colours are made for its background, #242526: a team colour under 3:1 contrast against it is swapped for the team's other colour or lifted until it reads, and the teams' colours stay apart (CIE76 ΔE 20). It is the team palette every sdvplot adapter shares, the same as the Python package's (Team colours): a team named two ways keeps one colour, and a value that names no team is grey without moving any team's colour. background sets another colour to read against.

With JavaScript on, Perspective draws this figure here.

{
  "plugin": "Y Bar",
  "theme": "Pro Dark",
  "columns_config": {
    "team": {
      "palette": "linear-gradient(to right, #b15a6f, #737373, #9e7c0c, #4c70af, #009ae5, #e64100, #ff7500, #ff001f, #b0b7bc, #00799d, #759dc8, #ffb612, #007c80, #886ba0, #00aec6, #e31837, #006ddd, #759dfc, #9d6c46, #00aa9c, #9055ba, #ac4c8f, #d3bc8d, #7a54db, #2d7e5f, #6f9f96, #6b7512, #69be28, #ca4533, #cc3053, #57c7ff, #d3744b)"
    },
    "epa_per_play": {
      "number_format": {
        "minimumFractionDigits": 3,
        "maximumFractionDigits": 3
      },
      "palette": "linear-gradient(to right, #b15a6f, #737373, #9e7c0c, #4c70af, #009ae5, #e64100, #ff7500, #ff001f, #b0b7bc, #00799d, #759dc8, #ffb612, #007c80, #886ba0, #00aec6, #e31837, #006ddd, #759dfc, #9d6c46, #00aa9c, #9055ba, #ac4c8f, #d3bc8d, #7a54db, #2d7e5f, #6f9f96, #6b7512, #69be28, #ca4533, #cc3053, #57c7ff, #d3744b)"
    }
  },
  "group_by": [
    "team"
  ],
  "split_by": [
    "team"
  ],
  "columns": [
    "epa_per_play"
  ]
}
import { NFL_TEAM_EPA_2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { perspectiveConfig } from "@sportsdataverse/sdvplot/perspective";

await loadLeague("nfl");
// nflverse's 2024 regular season: each offence's EPA per play
const rows = NFL_TEAM_EPA_2024.map((t) => ({ team: t.team, epa_per_play: t.off_epa / t.off_plays }));
// a chart splits by team in sorted order and colours each series from the value column's palette; on Pro Dark
// (#242526) every team colour keeps 3:1 contrast and stays apart from the others
const config = {
...perspectiveConfig(rows, { teamCol: "team", league: "nfl", plugin: "Y Bar", mode: "dark" }),
group_by: ["team"],
split_by: ["team"],
columns: ["epa_per_play"],
};

export const browser = {
lib: "perspective",
mount: async ({
worker,
viewer,
}: {
worker: import("@perspective-dev/client").Client;
viewer: import("@perspective-dev/viewer").HTMLPerspectiveViewerElement;
}) => {
const table = await worker.table(rows.map((r) => ({ ...r })));
await viewer.load(worker);
await viewer.restore({ table: await table.get_name(), ...config });
return () => void table.delete();
},
height: 480,
label: `EPA per play of every 2024 NFL offence, a bar per team in its colour on a dark background: ${rows
.map((r) => `${r.team} ${r.epa_per_play.toFixed(3)}`)
.join(", ")}`,
} as const;

// Perspective draws only in a browser; without JavaScript the page shows the config sdvplot hands it
config;

Streaming​

A Perspective table takes updates: table.update(rows) adds rows (or, with an index column, replaces the rows with the same key), and every view of the table redraws. The example below replays the first six weeks of the 2024 season into an empty table, one week of plays at a time, and the bars of each offence's total EPA grow as each batch lands. The config is built once, from all the plays and the table's own schema, so each team's colour is fixed before its first play arrives. Restore the config once the table has rows: Perspective 5.5 drops a columns_config restored onto an empty table (checked: viewer.save() then has none), so the colours and number formats would be lost. The example sends the first week, restores the config, and then streams the rest.

With JavaScript on, Perspective draws this figure here.

{
  "batches": [
    {
      "week": 1,
      "plays": 2007
    },
    {
      "week": 2,
      "plays": 2009
    },
    {
      "week": 3,
      "plays": 2043
    },
    {
      "week": 4,
      "plays": 2045
    },
    {
      "week": 5,
      "plays": 1854
    },
    {
      "week": 6,
      "plays": 1820
    }
  ],
  "config": {
    "plugin": "Y Bar",
    "theme": "Pro Light",
    "columns_config": {
      "posteam": {
        "palette": "linear-gradient(to right, #97233f, #c60c30, #241773, #bf880e, #82703f, #0085ca, #002244, #311d00, #59a222, #004c6e, #203731, #53575f, #003876, #32202f, #dc751d, #0d6f7f, #0043aa, #8d9296, #006fce, #2d0000, #492e5b, #6d2482, #ff4a41, #000940, #00472c, #a71600, #000000, #1e2d00, #6d92b8, #e64100, #b85149, #5a1414)"
      },
      "week": {
        "number_format": {
          "useGrouping": false,
          "maximumFractionDigits": 0
        },
        "palette": "linear-gradient(to right, #97233f, #000000, #241773, #c60c30, #0085ca, #e64100, #ff4a41, #311d00, #002244, #004c6e, #8d9296, #203731, #32202f, #003876, #0d6f7f, #bf880e, #0043aa, #006fce, #2d0000, #dc751d, #6d2482, #000940, #82703f, #492e5b, #00472c, #53575f, #1e2d00, #59a222, #a71600, #b85149, #6d92b8, #5a1414)"
      },
      "epa": {
        "number_format": {
          "minimumFractionDigits": 3,
          "maximumFractionDigits": 3
        },
        "palette": "linear-gradient(to right, #97233f, #000000, #241773, #c60c30, #0085ca, #e64100, #ff4a41, #311d00, #002244, #004c6e, #8d9296, #203731, #32202f, #003876, #0d6f7f, #bf880e, #0043aa, #006fce, #2d0000, #dc751d, #6d2482, #000940, #82703f, #492e5b, #00472c, #53575f, #1e2d00, #59a222, #a71600, #b85149, #6d92b8, #5a1414)"
      }
    },
    "group_by": [
      "posteam"
    ],
    "split_by": [
      "posteam"
    ],
    "columns": [
      "epa"
    ],
    "aggregates": {
      "epa": "sum"
    }
  }
}
import { nflPlays2024 } from "@sportsdataverse/examples/data";
import { loadLeague } from "@sportsdataverse/sdvplot";
import { type PerspectiveSchema, perspectiveConfig } from "@sportsdataverse/sdvplot/perspective";

await loadLeague("nfl");
// nflverse's 2024 play-by-play, weeks 1 to 6: the rush and pass plays, a batch per week
const plays = (await nflPlays2024()).map((p) => ({
play: p.play,
week: p.week,
posteam: p.posteam,
defteam: p.defteam,
epa: p.epa,
}));
const weeks = [...new Set(plays.map((p) => p.week))].sort((a, b) => a - b);
const batches = weeks.map((w) => plays.filter((p) => p.week === w));
// the table starts empty, so its types are given: the same schema goes to Perspective and to sdvplot
const schema: PerspectiveSchema = {
play: "string",
week: "integer",
posteam: "string",
defteam: "string",
epa: "float",
};
const config = {
...perspectiveConfig(plays, { teamCol: "posteam", league: "nfl", plugin: "Y Bar", schema }),
group_by: ["posteam"],
split_by: ["posteam"],
columns: ["epa"],
aggregates: { epa: "sum" as const },
};

export const browser = {
lib: "perspective",
mount: async ({
worker,
viewer,
}: {
worker: import("@perspective-dev/client").Client;
viewer: import("@perspective-dev/viewer").HTMLPerspectiveViewerElement;
}) => {
const table = await worker.table(schema, { index: "play" });
// the first week before the config: Perspective 5.5 drops a columns_config restored onto an empty table (the
// team colours and number formats would be lost)
await table.update((batches[0] ?? []).map((p) => ({ ...p })));
await viewer.load(worker);
await viewer.restore({ table: await table.get_name(), ...config });
// then a week's plays every 0.8 s, as a live feed would send them: the bars grow as each batch lands
let next = 1;
const timer = setInterval(() => {
const batch = batches[next++];
if (batch === undefined) return clearInterval(timer);
void table.update(batch.map((p) => ({ ...p })));
}, 800);
return () => {
clearInterval(timer);
void table.delete();
};
},
height: 480,
label: `The 2024 NFL season's first ${weeks.length} weeks replayed into Perspective a week at a time: each offence's total EPA as a bar in its colour, growing with each batch of plays (${batches
.map((b, i) => `week ${weeks[i]}: ${b.length} plays`)
.join(", ")})`,
} as const;

// Perspective draws only in a browser; without JavaScript the page shows each batch and the config
{ batches: batches.map((b, i) => ({ week: weeks[i], plays: b.length })), config };

WebAssembly from your own files​

Perspective's engine and its viewer are WebAssembly modules. To serve them from your own site rather than a CDN, name each file with new URL(…, import.meta.url), which webpack 5, Rspack and Vite copy into your build and hand you the URL of: the engine (perspective-server.wasm, 2.4 MB) goes to init_server, and the viewer's module (perspective-viewer.wasm, 1.6 MB, which also serves the client) to the viewer's init_client. Both ship already compressed (they unpack themselves in the browser), so brotli or gzip on your server saves about 1%. The engine runs in a Web Worker the client starts from an inline script, so there is no worker file to serve. The function below starts it all, and it is what draws the examples on this page, which ask nothing of any other site:

start.ts
import client from "@perspective-dev/client";
import type browserClient from "@perspective-dev/client/dist/esm/perspective.browser.js";
import viewer from "@perspective-dev/viewer";
import "@perspective-dev/viewer-datagrid/dist/esm/perspective-viewer-datagrid.js";
import "@perspective-dev/viewer-charts/dist/esm/perspective-viewer-charts.js";
import "@perspective-dev/viewer/dist/css/pro.css";
import "@perspective-dev/viewer/dist/css/pro-dark.css";

/**
* Perspective 5.5 in the page, from the packages' own files, never a CDN. Each
* `new URL("@perspective-dev/…/x.wasm", import.meta.url)` tells the bundler (webpack 5, Vite, Rspack) to copy that
* file next to your app and gives its URL there: the engine ("server", 2.4 MB, run in a Web Worker the client starts
* from an inline script) and the viewer's own module, which also serves as the client. The viewer must be ready
* before `worker()` asks for the client. Importing the two plugins registers the Datagrid and the charts ("Y Bar",
* "X/Y Scatter", …), imported by file because their type declarations reach into the viewer's TypeScript source,
* which a strict project would then type-check; the two stylesheets are the "Pro Light" and "Pro Dark" themes. Start it
* once per page and share the worker: browser only (it touches `customElements`), so import this module lazily from
* client code.
*/
export async function startPerspective(): Promise<Awaited<ReturnType<typeof browserClient.worker>>> {
// TypeScript under Node resolution reads the package's Node build; a bundler for the browser picks the browser one
const perspective = client as unknown as typeof browserClient;
perspective.init_server(
fetch(new URL("@perspective-dev/server/dist/wasm/perspective-server.wasm", import.meta.url)),
);
await viewer.init_client(
fetch(new URL("@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm", import.meta.url)),
);
return perspective.worker();
}

With webpack 5 or Rspack (which this site builds with), two more steps are needed; esbuild needed neither (checked with esbuild 0.27), and Vite pre-bundles dependencies with esbuild, so it should not need them either (not tried here).

  • The viewer and the charts plugin load their WebAssembly glue code with import(url) of a URL they make in the browser, and the bundler turns every import() into a lookup among its own modules, which fails with "Cannot find module 'blob:…'". Mark those calls /* webpackIgnore: true */ so the browser does the import: this site runs a three-line loader over the two builds (node_modules/@perspective-dev/viewer/dist/esm/ and node_modules/@perspective-dev/viewer-charts/dist/esm/) that adds the comment to each import( of an expression. Webpack 5 alone could instead switch off its parsing of import() for those files (parser: { import: false }); Rspack has no such switch (that webpack-only switch was not tried here).
  • The Datagrid and charts plugins of 5.5 import two modules of the viewer's TypeScript source, @perspective-dev/viewer/src/ts/column-format.js and @perspective-dev/viewer/src/ts/extensions.js (no such JavaScript files exist; the sources are .ts), and the bundler does not compile TypeScript from node_modules. Everything the plugins take from them is exported by the viewer's own build, so alias both requests to that build's file, the one require.resolve("@perspective-dev/viewer") gives (in resolve.alias, with a trailing $ on each key for an exact match). Aliasing them to the package name does nothing: the resolver skips an alias whose target the request already starts with.

The plugins' type declarations reach into the same source, which is why the function above imports them by file: a strict TypeScript project would otherwise type-check the viewer's source.

Install the five packages at one version. @perspective-dev/client asks for @perspective-dev/server with a caret range, so without the server pinned beside it a package manager can install a newer engine than the client.

Server rendering: @sportsdataverse/sdvplot/perspective imports nothing at run time, so a server component or a server-side load function can build the config and the rows and pass them to the page. Perspective itself needs the browser (custom elements, Web Workers), so import it lazily from client code, after the page has loaded, as the examples do; a module that imports it at the top of a server-rendered page fails on the server.

What installing it brings​

The five packages are Apache-2.0. The viewer and the client depend on pro_self_extracting_wasm (Apache-2.0, from the Prospective Company, the company behind Perspective; "pro" is its name, not a paid tier): in the browser it is a small decompressor for the WebAssembly modules. Its command-line half, used to build those modules and never bundled into a page, brings http-server, zx, and two packages under the Mozilla Public License 2.0 (leb128 and buffer-pipe). The grid draws with regular-table and the viewer lays out with regular-layout, both Apache-2.0.