-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
GSoC 2026 ‐ Sachin Pangal
Hey! I’m Sachin Pangal, a Computer Science undergraduate at Walchand Institute of Technology, Solapur, India, graduating in 2027. I like building things that other people get to build with, like libraries, tooling, and anything where the output is a good API rather than a finished app. Outside of stdlib, I write a fair amount of code in the JavaScript ecosystem, mess around with C++ and Python, and have shipped a couple of side projects that real people actually use.
Vega is a visualization grammar for describing charts through data, marks, scales, and encodings. stdlib’s plot namespace uses Vega as a backend to bring that capability into JavaScript, letting you build visualizations programmatically. When I started working on it, stdlib already supported parts of the Vega grammar. My work was about extending that foundation so common charts could be built end-to-end through stdlib APIs, without having to hand-write a Vega specification.
The question I kept coming back to throughout the summer was: can someone hand stdlib an ndarray and get a chart back?
- Finish the value-reference system so data fields, colors, and gradients can be mapped onto visual properties.
- Implement the remaining mark types —
rect,symbol,line,rule,text,area,arc,path,shape,image,trail,group— each with its own encoding, encoding set, and assertions. - Build a complete legend system, including type/direction/orientation enumerations.
- Add the data transforms that charts actually depend on:
extent,stack,collect,filter,formula,sample,sequence,loess,label. - Add layout support for composing group marks.
- Wire it all together into end-to-end chart constructors (scatter).
The whole thing was built bottom-up, in layers, because every layer above depends on the one below being correct:
-
Enumerations first. Every Vega property with a fixed set of legal values (interpolation methods, symbol shapes, stroke joins, legend orientations, format types, grid alignments, …) got its own package plus a matching
plot/vega/base/assert/*predicate. Boring, but it means every constructor above gets validation for free. -
Marks next. For each mark type, four packages: the
ctor, anencoding-set(the per-mark set of visual channels), anencoding(the enter/update/exit lifecycle), and assertions for both. - Then the supporting grammar — legends, scales, transforms, layouts.
- Then charts. A user-facing chart constructor that consumes all of the above and hands back a valid Vega spec.
Each layer shipped as its own PR, which is why there are a lot of them.
Vega is a visualization grammar. Instead of calling functions to draw things, you write a JSON document describing what the chart is, and Vega figures out how to draw it. A chart is built from a few pieces: data, transforms that reshape it, scales that turn data values into visual values, marks that get drawn, encodings that connect data fields to mark properties, and guides (axes and legends).
The nice part is that these snap together in different ways. Change a symbol mark to a rect mark and a scatter plot becomes a bar chart. Add a bin transform and it becomes a histogram. So you don't build thirty chart types — you build the pieces and combine them.
Because writing that JSON by hand is painful. A full chart is hundreds of lines of nested objects, and JSON doesn't help you: type "symbal" instead of "symbol" and nothing complains, the chart just comes out wrong. Want five series? Copy the block five times.
So stdlib gives every piece of the grammar its own JavaScript constructor. You build objects, get errors when you make a mistake, and call toJSON() at the end. My project was writing the constructors that didn't exist yet — which was most of them.
What I built: ten of Vega's eleven mark types — rect, symbol, path, text, arc, area, trail, image, and rule — each with four packages: the constructor, an encoding set, an encoding, and assertions for both. group is still in review.
A mark is a shape drawn once for each row of data. Marks are the only thing that actually puts pixels on screen; everything else exists to tell marks where to go and what to look like. Between them, Vega's eleven types cover most of what charts need:
| Mark | What it draws | Used for |
|---|---|---|
rect |
rectangles | bar, column, histogram, heatmap |
symbol |
points | scatter, bubble |
line |
a connected path | line charts |
area |
a filled band | area charts |
arc |
pie slices | pie, donut |
rule |
straight lines | error bars, reference lines |
text |
text | labels |
path / shape
|
custom SVG shapes | maps, custom glyphs |
image |
images | icon plots |
trail |
a line that changes width | tapered lines |
group |
a container for other marks | small multiples |
Each mark's encoding is split three ways, because Vega charts can change while you're looking at them. When the data updates, Vega compares old to new and sorts marks into three groups: enter (new marks), update (marks that are staying), and exit (marks whose data is gone). Each group gets its own styling, which is how you get smooth animations — fade in on enter, slide on update, fade out on exit. A static chart only really uses update, but the model has to support all three.
- Example
var Value = require( '@stdlib/plot/vega/value/ctor' );
var SymbolEncodingSet = require( '@stdlib/plot/vega/mark/symbol/encoding-set' );
var SymbolEncoding = require( '@stdlib/plot/vega/mark/symbol/encoding' );
var SymbolMark = require( '@stdlib/plot/vega/mark/symbol/ctor' );
var mark = new SymbolMark({
'encode': new SymbolEncoding({
'enter': new SymbolEncodingSet({
'stroke': new Value({
'value': '#000'
})
}),
'update': new SymbolEncodingSet({
'stroke': new Value({
'value': 'steelblue'
})
}),
'exit': new SymbolEncodingSet({
'stroke': new Value({
'value': '#fff'
})
})
})
});
console.log( mark.toJSON() );
// => {...}Different marks have different properties. shape and size only make sense on a symbol. cornerRadius only on a rect. startAngle only on an arc.
With one shared encoding set, you could set cornerRadius on a symbol mark and nothing would stop you — Vega would just ignore it, and you'd get a chart that looks fine but isn't what you asked for. Giving each mark its own encoding set means you get an error right where you made the mistake. That's why it's four packages per mark instead of one.
What I built: the remaining properties on value/ctor, plus value/field-descriptor and its assertion.
An encoding says which data field controls which visual property — "x comes from the year field", "color comes from the category field". A value reference is the small object underneath that answers where a value comes from. Three cases:
- a fixed value —
{ 'value': 'red' }, the same for every point - a field with a scale —
{ 'scale': 'colorScale', 'field': 'species' } - a signal — a value that can change while the chart runs
Everything depends on it. Every property of every mark — position, color, size, opacity, shape — is a value reference underneath, so this had to be finished before any mark work could start.
It's also where you decide whether a chart is readable. People are good at comparing positions, decent at comparing lengths, and pretty bad at comparing colors or shapes. So the important numbers belong on x and y, and category belongs on color or shape.
What I built: paired packages for every fixed list of values I needed — interpolation methods, area orientations, symbol shapes, text alignments, format types, grid alignments, stroke joins, legend types/directions/orientations, stack offsets, label transform options, and layout bounds. One package returns the valid values, the other checks against them.
Because Vega doesn't tell you when you get one wrong. Write "orient": "lft" instead of "left" and Vega doesn't throw, doesn't warn, doesn't log. It silently ignores the property and uses the default. Your legend shows up on the right, you assume you misread the docs, and you waste half an hour. With these, a typo throws an error on the line where you typed it.
- Example
var isSymbolMarkShape = require( '@stdlib/plot/vega/base/assert/is-symbol-mark-shape' );
var bool = isSymbolMarkShape( 'circle' );
// returns true
bool = isSymbolMarkShape( 'beep' );
// returns falseWhat I built: the legend constructor, plus the type, direction, and orientation enumerations it needs (and their assertions).
A scale turns data into visuals — it turns 'setosa' into the color #1f77b4. A legend is that same mapping drawn on screen, so the reader can go backwards: they see blue, and the legend tells them it means 'setosa'.
It explains why the API looks the way it does. You don't hand a legend a list of labels and colors — you hand it the name of a scale:
var Legend = require( '@stdlib/plot/vega/legend/ctor' );
var legend = new Legend({
'fill': 'colorScale',
'orient': 'left'
});
console.log( legend.toJSON() );
// => {...}The labels come from the scale's domain and the colors from its range, so there's nothing to list. It also means the legend can never disagree with the chart — the usual problem with hand-written legends, where you change a color and forget to update the key. Axes work the same way: an axis is a legend for a position scale.
What I built: twelve packages in plot/base — lists of symbol aliases and shorthands, converters between all three representations (single and array versions), and four assertions. This wasn't in my proposal; it came up while building the scatter plot.
Vega wants a symbol shape as either a built-in name like 'circle' or a raw SVG path string. Nobody wants to type an SVG path.
Meanwhile, people who plot things already know the short forms from MATLAB — 'o' for circle, '+' for plus, 'x' for cross, '*' for asterisk, 's' for square. matplotlib, R, and gnuplot all use the same ones, so stdlib should accept them too.
So the layer handles three ways of naming the same thing: shorthand ('o', what people type), alias ('circle', the readable name), and shape (what Vega needs — a built-in name if one exists, otherwise an SVG path).
- Example
var symbol2shape = require( '@stdlib/plot/base/symbol2shape' );
var v = symbol2shape( 'o' );
// returns 'circle'
v = symbol2shape( '*' );
// returns 'M0.2,-1L-0.2,-1L-0.2,-0.34641L...Z'
v = symbol2shape( 'beep' );
// returns nullWhat I built: the stack and label transform enumerations (merged), and constructors for formula, sequence, loess, extent, filter, sample, and label (all still in review).
A transform is a step that changes data before it gets drawn — binning, grouping, filtering, stacking.
Transforms are what let chart types be combinations instead of special cases. A histogram isn't its own chart type; it's a rect mark with bin and aggregate in front of it. A stacked bar chart is a rect mark with aggregate and stack. A trend line is a line mark with loess.
Write each of those as a separate chart implementation and you write the same code over and over, and users can only make the charts you thought of. Build the transforms instead and users can combine them however they like.
What I built: band and point scales (both in review).
A scale maps a domain (your data) to a range (pixels, colors, sizes). Three flavours: numbers in and numbers out (linear, log, time), categories in and numbers out (band, point), and categories in and categories out (ordinal).
The scatter plot only needed linear and ordinal (mapping each series to a color, shape, size, and opacity). Bar charts need band, and that's worth explaining.
A bar needs two numbers: where it starts and how wide it is. A band scale splits the range into equal slices with padding between them and gives you back the width of one slice — which is exactly the bar width. Without it you'd calculate bar widths by hand every time and get the padding wrong at the edges. point is the same thing with zero width, giving evenly spaced positions instead of slices, which is what categorical line charts need.
What I built: layout/bounds with its assertion, and the layout constructor (all in review).
A group mark is a container — it holds its own marks, scales, and axes, and can hold other groups. Layout arranges those groups in a grid.
Together they give you small multiples: a grid of small charts that look the same and share their scales, each showing a different slice of the data. It's one of the best ways to show an extra dimension without cramming everything into one messy plot, and it's the "multidimensional data" part of my project title.
The shared scales are the important bit. If each small chart quietly gets its own y-axis, you can't compare them any more and the whole point is lost — which is why layout has to be built on group marks that can inherit scales, rather than being a grid of separate images.
What I built: plot/charts/scatter/ctor — a full chart constructor that takes an ndarray and produces a renderable Vega spec, with per-series symbols, colors, sizes, opacities, edges, and a legend. Still in review.
There's a mismatch to solve. stdlib stores data in ndarrays — a block of numbers, say 100 rows by 5 columns. Vega wants a list of objects, one per point, with named fields:
ndarray (100 × 5) Vega data
[ { x: 0, y: 12.4 },
[[12.4, 23.1, ...], → { x: 1, y: 15.9 },
[15.9, 21.8, ...], ... ]
...]
Converting between the two, then building the marks, the scales for every channel, and the legend, is what the chart constructor does. It also picks sensible defaults — the category10 color scheme, and 0.6 opacity so overlapping points show density without getting too faint to see.
- Example
var array = require( '@stdlib/ndarray/array' );
var random = require( '@stdlib/random/uniform' );
var Scatterplot = require( '@stdlib/plot/charts/scatter/ctor' );
var opts = {
'shape': [ 1, 5 ]
};
var a = array( [ 10.0, 20.0, 30.0, 40.0, 50.0 ], opts );
var b = array( [ 20.0, 30.0, 40.0, 50.0, 60.0 ], opts );
// Five series, 100 points each:
var y = random( [ 100, 5 ], a, b );
var chart = new Scatterplot( y, {
'symbols': [ 'o', 'square', '^', 'd', '*' ],
'symbolOpacity': [ 0.6 ],
'symbolSize': [ 100 ],
'legend': true
});
chart.view( 'browser' );-
rect -
symbol -
path -
text -
arc -
area -
trail -
image -
rule -
group
- Mark interpolation methods — #11991, #11992
- Area mark orientations — #12000, #12001
- Symbol mark shapes — #12135, #12136
- Text alignments — #12153
- Format types — #12234, #12235
- Grid alignments — #12254, #12256
- Stroke joins — #12987, #12988
- Types — #12159, #12160
- Directions — #12170, #12171
- Orientations — #12172, #12173
-
legend/ctor— #12324 - Legend tests — #13337
- Symbol lists — #14242 (aliases), #14243 (shorthands)
- Single converters — #14253 (
symbol2alias), #14254 (symbol2shape), #14263 (symbol2shorthand) - Array converters — #14264, #14265, #14266
- Assertions — #14280, #14287, #14288, #14289
- Label transform enumerations — #13489, #13504, #13505, #13506, #13508, #13510
- Stack transform offsets — #12883, #12888
- Transform constructors — #12648 (
formula), #12740 (sequence), #12840 (loess), #12852 (extent), #13227 (filter), #13416 (sample), #13517 (label) - Aggregate operations refactor — #13540
-
scale/band— #12509 -
scale/point— #12516 -
layout/bounds+ assertion — #13267, #13268 -
layout/ctor— #13428
-
plot/charts/scatter/ctor— #14299
The mark layer is essentially done. Ten of eleven mark types are merged with full encoding machinery, and group is the only one left in review. Every enumeration I needed exists and is validated. The legend system is merged and working. The symbol utility layer in plot/base is fully merged.
The scatter plot works end-to-end today — you can hand it an ndarray and get a rendered chart in the browser, with legends, per-series symbols, sizes, opacities, and hollow-marker support.
-
Other chart types — bar, column, line, and histogram. Scatter showed the pattern works, so the rest follow the same shape once transforms and scales land. In short, the building blocks for
plot/charts/*are still being laid, and the scope will only keep growing as more chart types get added — so this is continuing work.
Learning the Vega grammar took a while. Before I could write a single constructor I had to understand how Vega actually composes a chart — marks, scales, encodings, and how they all reference each other. Reading through the spec and pulling apart example charts took a good chunk of the community bonding period, and I kept coming back to it whenever a new mark or transform behaved in a way I didn't expect.
Building bottom-up feels slow and isn't. For weeks I wrote enumeration packages — lists of strings and a predicate. It felt like nothing. But by the time I reached the marks, all the validation was already written, and I never once had to debug a rejected property.
Small PRs beat big ones. They got reviewed fast, and the feedback was specific enough to act on straight away. The few times I bundled changes together, review took longer and the discussion got muddier.
Scope estimation is hard. I planned eleven marks, legends, transforms, layouts, signals, and five chart types in twelve weeks. The marks alone took a large chunk of the summer. I'd rather have shipped a solid mark layer and one real chart than a shaky version of everything, but it's still a miss against my timeline.
Code reviews taught me a lot. A big part of what I now know about stdlib's conventions came from the feedback maintainers left on my PRs. The contributing guide lays the rules out clearly, but having someone point them out on my own code is what made them stick — and I'm grateful for how patient and detailed that feedback was.
I really enjoyed contributing this summer. The highlight for me was the weekly standups — a mix of genuine feedback and fun time with the mentors and the other contributors. I always came away from them knowing more than I went in with, and often about things well outside my own project.