JavaScript Parallel Coordinate Plot

Demonstrates a JavaScript Parallel Coordinate Plot, which visualises multivariate data as polylines crossing one vertical axis per dimension. Switch between a multi-series chart and a single-series chart of 100,000 records.

This chart type is also known as a Parallel Coordinates Chart or a PCP

Chart Variant
Interactions
Left-drag — one at a time

Fullscreen

Edit

 Edit

Docs

drawExample.ts

index.tsx

theme.ts

ParallelCoordinateAxisReorderModifier.ts

ParallelCoordinateCursorModifier.ts

ParallelCoordinateDataSource.ts

ParallelCoordinatePlotHighlightModifier.ts

ParallelCoordinateRecordSelectionModifier.ts

ParallelCoordinateSingleHighlightModifier.ts

parallelCoordinateTooltipDataTemplate.ts

SplineLineHitTestProvider.ts

Copy to clipboard
Minimise
Fullscreen
1import {
2    EAxisType,
3    EXyDirection,
4    IRenderableSeries,
5    MouseWheelZoomModifier,
6    NumberRange,
7    NumericAxis,
8    RubberBandXyZoomModifier,
9    SciChartSurface,
10    SeriesSelectionModifier,
11    SplineLineRenderableSeries,
12    ZoomExtentsModifier,
13    ZoomPanModifier,
14} from "scichart";
15import { appTheme } from "../../../theme";
16import { ParallelCoordinateAxisReorderModifier } from "./ParallelCoordinateAxisReorderModifier";
17import { ParallelCoordinateCursorModifier } from "./ParallelCoordinateCursorModifier";
18import { ParallelCoordinateDataSource, PCP_HIDDEN_YAXIS_ID } from "./ParallelCoordinateDataSource";
19import { ParallelCoordinatePlotHighlightModifier } from "./ParallelCoordinatePlotHighlightModifier";
20import { ParallelCoordinateRecordSelectionModifier } from "./ParallelCoordinateRecordSelectionModifier";
21import { ParallelCoordinateSingleHighlightModifier } from "./ParallelCoordinateSingleHighlightModifier";
22import { SplineLineHitTestProvider } from "./SplineLineHitTestProvider";
23import { createParallelCoordinateTooltipDataTemplate } from "./parallelCoordinateTooltipDataTemplate";
24
25const originList = ["USA", "Europe", "Japan"];
26
27// Per-origin engine profiles reproduce the classic auto-mpg relationships:
28// USA cars trend to big engines / low mpg, European and Japanese cars to smaller engines / higher mpg.
29const originProfiles: Record<string, { minCyl: number; maxCyl: number }> = {
30    USA: { minCyl: 6, maxCyl: 8 },
31    Europe: { minCyl: 4, maxCyl: 6 },
32    Japan: { minCyl: 3, maxCyl: 4 },
33};
34
35const originColors: Record<string, string> = {
36    USA: appTheme.VividOrange,
37    Europe: appTheme.VividSkyBlue,
38    Japan: appTheme.VividTeal,
39};
40
41// Mulberry32 - a small deterministic PRNG so the generated dataset is reproducible
42const createSeededRandom = (seed: number) => {
43    let state = seed;
44    return () => {
45        state |= 0;
46        state = (state + 0x6d2b79f5) | 0;
47        let t = Math.imul(state ^ (state >>> 15), 1 | state);
48        t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
49        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
50    };
51};
52
53interface ICarsData {
54    origins: string[];
55    mpg: number[];
56    cylinders: number[];
57    displacement: number[];
58    horsepower: number[];
59    weight: number[];
60    acceleration: number[];
61}
62
63// Generate a synthetic "Cars" dataset of recordCount records. Uses a seeded PRNG so the data is reproducible.
64const generateData = (recordCount: number): ICarsData => {
65    const random = createSeededRandom(1972);
66    const data: ICarsData = {
67        origins: [],
68        mpg: [],
69        cylinders: [],
70        displacement: [],
71        horsepower: [],
72        weight: [],
73        acceleration: [],
74    };
75    for (let i = 0; i < recordCount; i++) {
76        const origin = originList[i % originList.length];
77        const profile = originProfiles[origin];
78        // Correlated values: cylinders drives displacement -> horsepower -> weight; mpg and acceleration fall
79        // as the engine grows.
80        const cyl = Math.round(profile.minCyl + random() * (profile.maxCyl - profile.minCyl));
81        const disp = Math.round((60 + cyl * 45) * (0.85 + random() * 0.3));
82        const hp = Math.round(disp * (0.4 + random() * 0.25));
83        const wt = Math.round(1400 + disp * 6 * (0.85 + random() * 0.3));
84        const mpgVal = Math.max(9, Math.round(46 - hp * 0.12 - cyl * 1.5 + random() * 6));
85        const accel = Math.round((26 - hp * 0.05 + random() * 4) * 10) / 10;
86        data.origins.push(origin);
87        data.cylinders.push(cyl);
88        data.displacement.push(disp);
89        data.horsepower.push(hp);
90        data.weight.push(wt);
91        data.mpg.push(mpgVal);
92        data.acceleration.push(accel);
93    }
94    return data;
95};
96
97const addCategories = (source: ParallelCoordinateDataSource, data: ICarsData): void => {
98    source.addCategory("mpg", data.mpg, EAxisType.NumericAxis);
99    source.addCategory("cylinders", data.cylinders, EAxisType.NumericAxis);
100    source.addCategory("displacement", data.displacement, EAxisType.NumericAxis);
101    source.addCategory("horsepower", data.horsepower, EAxisType.NumericAxis);
102    source.addCategory("weight", data.weight, EAxisType.NumericAxis);
103    source.addCategory("acceleration", data.acceleration, EAxisType.NumericAxis);
104    source.addCategory("origin", data.origins, EAxisType.CategoryAxis);
105};
106
107export const MULTI_SERIES_RECORD_COUNT = 100;
108export const SINGLE_SERIES_RECORD_COUNT = 100000;
109
110/**
111 * Multi-series: one renderable series per record (100 records). Each line can be hovered/clicked with
112 * the {@link SeriesSelectionModifier}, which highlights that record via onSelectedChanged/onHoveredChanged.
113 * Drag a range on one of the category axes with the {@link ParallelCoordinatePlotHighlightModifier} to highlight
114 * all records passing through that range (click to clear).
115 */
116export const drawMultiSeriesExample = async (rootElement: string | HTMLDivElement, interpolate: boolean = false) => {
117    const { sciChartSurface, wasmContext } = await SciChartSurface.create(rootElement, {
118        theme: appTheme.SciChartJsTheme,
119    });
120    const xAxis = new NumericAxis(wasmContext, { drawLabels: false, growBy: new NumberRange(0.07, 0.07) });
121    sciChartSurface.xAxes.add(xAxis);
122
123    const carsData = generateData(MULTI_SERIES_RECORD_COUNT);
124    const data = new ParallelCoordinateDataSource(wasmContext, sciChartSurface, {
125        useSingleSeries: false,
126        pointMarkerSize: 0,
127    });
128    addCategories(data, carsData);
129
130    // Bold + opaque when selected or hovered, faint otherwise; keeps the origin colour.
131    const applySelectionStyle = (series: IRenderableSeries) => {
132        series.strokeThickness = series.isSelected ? 4 : series.isHovered ? 3 : 2;
133        series.opacity = series.isSelected || series.isHovered ? 1 : 0.5;
134    };
135    data.createSeriesFn = (index) => ({
136        // interpolationPoints > 0 -> SplineLineRenderableSeries (smooth curves); 0 -> straight segments
137        interpolationPoints: interpolate ? 5 : 0,
138        stroke: originColors[carsData.origins[index]],
139        strokeThickness: 2,
140        opacity: 0.5,
141        onSelectedChanged: applySelectionStyle,
142        onHoveredChanged: applySelectionStyle,
143    });
144    data.build();
145
146    // Spline series render a curve that bows away from the straight chords the default line hit-test uses, so
147    // hover/selection would miss the visible line. Swap in a provider that tests against the spline points.
148    if (interpolate) {
149        data.renderableSeries.forEach((series) => {
150            if (series instanceof SplineLineRenderableSeries) {
151                series.hitTestProvider = new SplineLineHitTestProvider(series, wasmContext);
152            }
153        });
154    }
155
156    // Both left-drag modifiers share the same gesture, so they are exposed as independent toggles in the demo
157    // UI and both start disabled - enable at most one at a time.
158    const highlightModifier = new ParallelCoordinatePlotHighlightModifier(data);
159    highlightModifier.isEnabled = false;
160    // X-only rubber-band zoom: EXyDirection.XDirection makes getRubberBandRect span the full Y height, so only
161    // the horizontal drag extent matters.
162    const rubberBandZoomModifier = new RubberBandXyZoomModifier({ xyDirection: EXyDirection.XDirection });
163    rubberBandZoomModifier.isEnabled = false;
164    // Drag a category axis left/right to reorder the dimensions (reorders categories + rebuilds the source).
165    const axisReorderModifier = new ParallelCoordinateAxisReorderModifier(data);
166    axisReorderModifier.isEnabled = false;
167    const panModifier = new ZoomPanModifier({ xyDirection: EXyDirection.XDirection });
168    panModifier.isEnabled = false;
169    // Hover/click a record line to select it (drives the per-series onHovered/onSelectedChanged styling above).
170    const seriesSelectionModifier = new SeriesSelectionModifier({ enableHover: true, enableSelection: true });
171    seriesSelectionModifier.isEnabled = false;
172    // Hover tooltip showing the nearest record's values (independent of the left-drag group).
173    const cursorModifier = new ParallelCoordinateCursorModifier({
174        showAxisLabels: false,
175        showTooltip: true,
176        hitTestRadius: 2000,
177        tooltipDataTemplate: createParallelCoordinateTooltipDataTemplate(data),
178        isSvgOnly: true,
179    });
180    cursorModifier.isEnabled = false;
181
182    sciChartSurface.chartModifiers.add(
183        highlightModifier,
184        rubberBandZoomModifier,
185        axisReorderModifier,
186        panModifier,
187        new MouseWheelZoomModifier({ xyDirection: EXyDirection.XDirection }),
188        new ZoomExtentsModifier({ includedYAxisIds: [PCP_HIDDEN_YAXIS_ID] }),
189        seriesSelectionModifier,
190        cursorModifier
191    );
192
193    return {
194        wasmContext,
195        sciChartSurface,
196        highlightModifier,
197        rubberBandZoomModifier,
198        axisReorderModifier,
199        panModifier,
200        seriesSelectionModifier,
201        cursorModifier,
202    };
203};
204
205/**
206 * Single-series: all 100,000 records drawn by one renderable series (NaN gaps between records) for
207 * performance. The {@link ParallelCoordinateCursorModifier} hit-tests the nearest record and shows all its values.
208 */
209export const drawSingleSeriesExample = async (rootElement: string | HTMLDivElement, interpolate: boolean = false) => {
210    const { sciChartSurface, wasmContext } = await SciChartSurface.create(rootElement, {
211        theme: appTheme.SciChartJsTheme,
212    });
213    const xAxis = new NumericAxis(wasmContext, { drawLabels: false, growBy: new NumberRange(0.07, 0.07) });
214    sciChartSurface.xAxes.add(xAxis);
215
216    const carsData = generateData(SINGLE_SERIES_RECORD_COUNT);
217    const data = new ParallelCoordinateDataSource(wasmContext, sciChartSurface, { useSingleSeries: true });
218    addCategories(data, carsData);
219    data.createSeriesFn = (index) => ({
220        // interpolationPoints > 0 -> spline curves; 0 -> straight segments
221        interpolationPoints: interpolate ? 5 : 0,
222        stroke: originColors[carsData.origins[index]],
223        strokeThickness: 2,
224        opacity: 0.02,
225    });
226    data.build();
227
228    // In spline mode the drawn curve bows away from the straight chords the default hit-test uses, so swap in a
229    // provider that tests the spline points (and maps the hit back to a source point -> record for selection).
230    if (interpolate) {
231        const single = data.renderableSeries[0];
232        if (single instanceof SplineLineRenderableSeries) {
233            single.hitTestProvider = new SplineLineHitTestProvider(single, wasmContext);
234        }
235    }
236
237    // All modifiers start disabled and are driven by the demo UI toggles (see index.tsx).
238    const cursorModifier = new ParallelCoordinateCursorModifier({
239        showAxisLabels: false,
240        showTooltip: true,
241        // hit-test the nearest data point in coordinate space, then show every value for its record
242        hitTestRadius: 2000,
243        tooltipDataTemplate: createParallelCoordinateTooltipDataTemplate(data),
244        isSvgOnly: true,
245    });
246    cursorModifier.isEnabled = false;
247    // Left-drag group (mutually exclusive, enforced in the UI): pan, X-only rubber-band zoom, axis reorder.
248    const panModifier = new ZoomPanModifier({ xyDirection: EXyDirection.XDirection });
249    panModifier.isEnabled = false;
250    const rubberBandZoomModifier = new RubberBandXyZoomModifier({ xyDirection: EXyDirection.XDirection });
251    rubberBandZoomModifier.isEnabled = false;
252    const axisReorderModifier = new ParallelCoordinateAxisReorderModifier(data);
253    axisReorderModifier.isEnabled = false;
254    // Click a record polyline to select it: highlights that record and dims the rest via the palette provider
255    // (single-series has no per-record series, so selection is done by paletting a subset of data points).
256    const recordSelectionModifier = new ParallelCoordinateRecordSelectionModifier(data);
257    recordSelectionModifier.isEnabled = false;
258    // Range highlight: drag over a Y axis to highlight records passing through the range (palette-based).
259    const highlightModifier = new ParallelCoordinateSingleHighlightModifier(data);
260    highlightModifier.isEnabled = false;
261
262    sciChartSurface.chartModifiers.add(
263        cursorModifier,
264        panModifier,
265        rubberBandZoomModifier,
266        axisReorderModifier,
267        highlightModifier,
268        recordSelectionModifier,
269        new MouseWheelZoomModifier({ xyDirection: EXyDirection.XDirection }),
270        new ZoomExtentsModifier({ includedYAxisIds: [PCP_HIDDEN_YAXIS_ID] })
271    );
272
273    return {
274        wasmContext,
275        sciChartSurface,
276        cursorModifier,
277        panModifier,
278        rubberBandZoomModifier,
279        axisReorderModifier,
280        highlightModifier,
281        recordSelectionModifier,
282    };
283};
284
285export const drawExample = drawMultiSeriesExample;
286

JavaScript Parallel Coordinate Plot

A Parallel Coordinate Plot (PCP) places one vertical axis per dimension of a multivariate dataset and draws each record as a polyline crossing every axis, which makes correlations and clusters between the dimensions easy to spot.

This example builds the plot on top of standard SciChart.js building blocks: a stacked axis layout (one NumericAxis or CategoryAxis per dimension), FastLineRenderableSeries / SplineLineRenderableSeries for the record polylines and a set of custom chart modifiers for the interactions.

Use the Select Chart menu to switch between the two modes:

  • Multi-Series — one renderable series per record. Best when you need per-record styling, hover and click selection.
  • Single-Series — all 100,000 records drawn by a single series with NaN gaps between them, for maximum performance. Selection and range highlighting are done with a PaletteProvider.

The Interactions switches turn the individual modifiers on and off. Pan, rubber-band zoom, axis reorder and range highlight all use the left-drag gesture, so only one of them can be active at a time.

SciChart Ltd, 16 Beaufort Court, Admirals Way, Docklands, London, E14 9XL.