Skip to content

Speculative Plotting

enumeratio edited this page Nov 13, 2026 · 4 revisions

Plotting: the gap to Wolfram, and the next slice

Speculative design. Open, not settled — thinking and proposals, not shipped behavior. See Roadmap for what's in flight.

Status: slice approved (Dean, 2026-11-12, with every lean in §5); building (BL-396). A survey of what notatio draws, the gap to Wolfram's visualization functions (and Sage's where it adds something), and one slice to build first: field plots drawn per pixel, which adds RegionPlot and implicit curves and makes DensityPlot and ContourPlot exact at every zoom. Nothing here adds a math head; plots are held heads in @notatio/formats, lowered in frontend, drawn in components.

1. What we draw today

family heads sampled by drawn as
curves Plot, ParametricPlot, PolarPlot, ListPlot, ListLinePlot, ListPolarPlot, DiscretePlot compiled JavaScript; adaptive bisection on bend and on a finite/non-finite change (adaptiveSample, 41 points, depth 6, cap 4000); resampled over the window on zoom SVG LineBox in a GraphicsBox
fields ContourPlot, DensityPlot, ListContourPlot, ListDensityPlot compiled JavaScript on a fixed grid (40 or 48 a side, capped 120 and 160) marching-squares LineBox per level, or one RasterBox; no pan or zoom
vector fields VectorPlot, StreamPlot compiled JavaScript on cell centers; fixed-step RK4 streamlines ArrowBox, LineBox
surfaces Plot3D, ListPlot3D, BarChart3D, Graphics3D(Line) grid 26 a side (to 60); Gpu -> n evaluates the grid in a fragment shader (to 400) and reads it back Graphics3DBox, painter's algorithm to SVG or a 2-D canvas
complex ComplexPlot, ComplexPlot3D emitComplexGLSL, one fragment per pixel; literals in uniform slots, so a slider never relinks; a CPU twin as fallback WebGL2 canvas, pan and zoom by uniform
charts BarChart, Histogram, PieChart, BoxWhiskerChart, ArrayPlot data GraphicsBox
layers Show, the diagram layers, LatticeTiles with ColorRules, ColorMixing, BoundaryStyle per view GraphicsBox, canvas blending

Options that reach a sampler or a drawer today: PlotLabel, GridLines, PlotLegends (a boolean), Filling (a boolean), ColorFunction (a gradient name, or "x"/"y"), PlotPoints, AxesLabel, PlotRange (the y pair on Plot, the z pair on DensityPlot), ScalingFunctions, Epilog/Prolog, and on Show the frame options (AspectRatio, Axes, Ticks, the camera). Field plots also read three names that are not Wolfram's: Levels, Filled, N.

Every plot example states a boxes shape (about 45 rows), held by packages/frontend/tests/example-boxes.test.ts, which lowers in Node with the page's own samplers. Shaders run only in the Playwright GPU stories (web/tests/stories/gpu.spec.ts).

Two routes compile an expression for the GPU. The complex one (emitComplexGLSL, ours) hoists literals into uniforms and has a CPU mirror. The real one (toGlslFn, compute-engine's GLSLTarget) returns float plotFn(float x, float y); it is used only to fill a grid and read it back, it bakes a Manipulate value into the source (each slider move relinks), and it declines loops.

2. The gap, ranked

Value is to a reader of the docs and a notebook user; cost is relative (S about a day of builder time, L a design of its own).

# gap value cost GPU
1 Fields per pixel: DensityPlot/ContourPlot are a 40-cell grid with no zoom; no RegionPlot; no implicit curve (ContourPlot(f == g), Sage's implicit_plot); no RegionFunction; Contours/ContourShading/PlotPoints spelled Levels/Filled/N high: inequalities and implicit curves are the most-missed plots, and every field page gains pan, zoom and sharp edges M is the point: one fragment per pixel, as the portrait already does
2 Curve options: Exclusions (none; a pole is found only by a finite/non-finite change or a jump wider than the window), MaxRecursion (fixed at 6), PlotStyle, ClippingStyle, PlotRange in full (All, Automatic, both axes), Filling -> Bottom | Axis | value | {1 -> {2}}, PlotLegends with labels, Legended high for correctness (a Tan or Floor plot still draws a join where the samples straddle it), medium for looks M no. compute-engine's interval-js target can prove a segment continuous or locate the break
3 3-D beyond a height field: ParametricPlot3D, RevolutionPlot3D, SphericalPlot3D, ContourPlot3D, RegionPlot3D, ListPointPlot3D high (tori, spheres, knots as tubes) L: a self-intersecting surface breaks the painter's algorithm, so it needs a depth-buffered WebGL drawer for Graphics3DBox first yes: the drawer, and marching the implicit ones
4 Surface options: Mesh, MeshFunctions, MeshShading, RegionFunction, BoxRatios, PlotRange clipping, ColorFunction as an expression, lighting medium M once 3's drawer exists; S for RegionFunction alone follows 3
5 Vector fields, deeper: VectorScaling/VectorPoints/StreamPoints/StreamScale, VectorColorFunction, ListVectorPlot, ListStreamPlot, StreamDensityPlot, LineIntegralConvolutionPlot; Sage's plot_slope_field medium S for options and the list forms; M for the dense ones LIC and stream density are per-pixel shaders over two plotFns
6 Graphics as values: a 2-D Graphics(primitives) head, Legended, Labeled around a plot, GraphicsRow/GraphicsGrid, Inset medium; it is the Figures and Speculative-Box-Primitives agenda M no
7 Named scale forms (LogPlot, LogLogPlot, LogLinearPlot, ListLogPlot): ScalingFunctions already does the work low S no
8 Plot themes and frames: PlotTheme, Frame/FrameLabel/FrameTicks on Plot, PlotRangePadding, AxesOrigin low to medium S each no
9 Data plots: ListPlot with Joined, PlotMarkers, ErrorBar; DateListPlot, SmoothHistogram, BubbleChart low for a math site S to M no

Two candidates named when this lane opened are already built: VectorPlot and StreamPlot exist (on the CPU, which is right for arrows and polylines), and Plot already samples adaptively. What is missing next to them is RegionPlot, and Exclusions.

Not in this lane: BL-329 (the notebook's shared range for Plot and Plot3D, an open design call). BL-322's plot fixes (explicit ranges, square field plots) merged as #1213, so no lane holds the plot files. A change to field output may need the cli vector-plot golden regenerated (UPDATE_*), coordinated with B's cli test lane.

3. Recommended slice: fields per pixel

One fragment shader host for a real f(x, y), the way portraitShader is for a complex f(z), and a CPU twin that draws the same picture where there is no shader. Four heads share it.

RegionPlot(x^2 + y^2 < 4 && y > Sin(x), (x, -3, 3), (y, -3, 3))
ContourPlot(x^3 + y^3 == 3 x y, (x, -3, 3), (y, -3, 3))            // an implicit curve
ContourPlot(Sin(x) Cos(y), (x, 0, 6), (y, 0, 6), Contours -> 12, ContourShading -> True)
DensityPlot(Sin(x y), (x, -3, 3), (y, -3, 3), RegionFunction -> (x^2 + y^2 < 9), ColorFunction -> "Dusk")
Show(RegionPlot(y > x^2, (x, -2, 2), (y, -1, 4)), Plot(x^2, (x, -2, 2)))

3.1 A region is a margin

A predicate becomes one real function, positive inside:

predicate margin
a < b, a <= b b - a
a > b, a >= b a - b
p && q min(p, q)
p || q max(p, q)
!p -p
a == b the curve a - b = 0 (a contour, not a region)
a chain a < b < c min(b - a, c - b)

marginOf(expr) is one pure function in frontend (field-margin.ts). Both routes compile its result with the ordinary real compile (compilePlot), so the region the shader fills and the boundary the CPU marches are the same function, and nothing depends on how a compile target treats booleans. A predicate it cannot rewrite (Element(x, Integers), a quantifier) declines: the plot says so and draws nothing.

  • RegionPlot fills margin > 0 and strokes margin = 0 (BoundaryStyle; PlotStyle the fill). A list of predicates is one region each, colored in order, mixed where they overlap by ColorMixing (ours, as on the layers; default "Normal" with Wolfram's translucent fill).
  • ContourPlot(f == g, …) is the zero contour of f - g: Wolfram's form, and what Sage calls implicit_plot. A list of equations is a curve each.
  • RegionFunction -> pred on any field plot is a margin too: outside it nothing is drawn. Its body is written over the plot's own variables, as the examples show (Wolfram's pure function Function({x, y, z}, …) is accepted and applied).

Strict and non-strict inequalities draw alike (Wolfram dashes nothing either).

3.2 The shader host

fieldShader(kind, plotFns) (frontend/src/field-shader.ts) is built like portraitShader: the same Prm uniform block (center, resolution, extent, literal slots, gradient stops), the same gradient(t), the same pan and zoom by uniform, the same ComplexPlotRenderer lifecycle (generalized into a shared renderer rather than copied). Per pixel it evaluates plotFn and colors by kind:

  • density: gradient((f - z0) / (z1 - z0)), z0..z1 from PlotRange or, when automatic, from a coarse CPU grid over the view (the 2nd to 98th percentile, as curves do).
  • contours: band index floor over the levels into the gradient when ContourShading, and a line where |f - level| < w · |∇f|, with ∇f from dFdx/dFdy, so a line is w pixels wide at every zoom and slope. Levels ride in a uniform array (MAX_LEVELS 32; more declines to the CPU). Contours -> n picks n round levels over the view's range, Contours -> [v…] exact ones.
  • region: smoothstep of margin / |∇margin| for an antialiased fill, and the same line rule at 0 for the boundary.
  • masks: a non-finite f is transparent; outside RegionFunction is transparent.

Numerics, by the house rule (decline rather than answer wrong):

  • f32 only. The view's extent is clamped where a pixel step falls under ulp(center), as the portrait clamps its zoom; past it the plot stops zooming rather than drawing blocks.
  • Where |∇f| underflows (a flat region at exactly a level) no line is drawn; the band color still is.
  • toGlslFn declining (a loop, Random, a head with no GLSL name) sends the plot to the CPU twin.

Literal slots for the real route. Today a Manipulate value is baked into plotFn, so a slider relinks the program on every move. The host binds each wildcard to a uniform slot instead (prm.p[i].x), compiled as a free symbol whose GLSL name is the slot. If compute-engine's GLSLTarget cannot name a symbol that way without a change, the slice keeps baking (programs are already cached per source) and the ask goes to the CE upstream session; nothing in ce-patches is touched here.

3.3 The CPU twin and the box

The value of a field plot stays a GraphicsBox, and Node, print, the terminal and a page with no WebGL2 all get it from the CPU:

RegionPlot(…)   → GraphicsBox(RasterBox(mask), LineBox(boundary)…, PlotRange -> …, Frame -> True)
ContourPlot(…)  → GraphicsBox(LineBox per level…)            // or RasterBox when ContourShading
ContourPlot(f == g, …) → GraphicsBox(LineBox…)               // roles: Series
DensityPlot(…)  → GraphicsBox(RasterBox(cells), …)

That is today's lowering (fieldBoxOfGrid, marchingSquares), plus the region mask and RegionFunction. On the web, <graphics-box> draws the same box's field layer with the shader when it can: a canvas under the SVG frame, in the frame's shared view, so Show(RegionPlot, Plot) overlays and pans as one (Figures, "Frames and views"). This is decision 9 of Speculative-Box-Primitives (a producer keyed by view inside GraphicsBox) for fields: the box holds the expression (Producer -> head, as an unsampled plot does now), the shader is one producer, the grid another, and reduce pins the view for static environments.

The CPU grid also gets better on its own: on zoom it resamples over the window (fields are #unzoomable today), and marching squares refines a cell where the margin changes sign more than once, so a boundary is not lost between samples.

3.4 Options, declared on the heads

Each head's record declares its options (Figures, "Options and heads"); all are Rules.

option heads notes
Contours -> n | [v…] ContourPlot, ListContourPlot replaces Levels (hard rename, no alias)
ContourShading -> True | False same replaces Filled; default False stays (ours; Wolfram's is Automatic, shaded) — a booleans-default-False call for Dean, see §5
PlotPoints -> n all fields replaces N; the CPU grid only, the shader ignores it
RegionFunction -> pred all fields, then Plot3D in a later slice §3.1
PlotRange -> [z0, z1] | All | Automatic DensityPlot, ContourPlot already on DensityPlot
ColorFunction -> name, PlotLegends, PlotLabel, FrameLabel all fields as today (XLabel/YLabel become FrameLabel -> [x, y])
PlotStyle, BoundaryStyle RegionPlot a color, Opacity(a, color), None; ColorRules is for addressed elements, a region has none
ContourStyle ContourPlot line color and AbsoluteThickness
ColorMixing RegionPlot of a list ours

3.5 Examples and tests

  • formats/reference/RegionPlot/ is new (index.md, examples.tsv): a disk, a conjunction, a list of two regions, a RegionFunction, a declined predicate. ContourPlot gains the implicit curve and Contours/ContourShading rows; DensityPlot a RegionFunction row. Each states boxes ({head: GraphicsBox, marks: {RasterBox: 1, LineBox: 1}, roles: {Region: 1, Boundary: 1}}), held in Node by example-boxes.test.ts through the CPU twin. No pixel goldens.
  • marginOf has unit tests over the table in §3.1, and a cross-check that the sign of the compiled margin agrees with evaluate() of the predicate on a grid (the routes agree).
  • The shader's constants and coloring are shared with the CPU painter from one table, and a Node test holds them equal, as portrait-cpu.test.ts does for the portrait.
  • web/tests/stories/gpu.spec.ts gains behavior checks read from pixels: a disk's filled share is π r² of the frame, a region and its complement tile it, an implicit circle's line has the radius it should after a zoom, a slider moves a boundary without a relink.

3.6 Files

  • new: frontend/src/field-margin.ts, field-shader.ts, field-cpu.ts; formats/reference/RegionPlot/
  • changed: frontend/src/{contour,densityplot,plot-lowering,plot-notation,symbols,plot-compile,complex-plot,gpu-eval}.ts, frontend/scripts/example-boxes.ts, components/src/{plot-view,graphics-box}.ts, formats/src/{graphics,graphics-options}.ts, formats/reference/{ContourPlot,DensityPlot,ListContourPlot,ListDensityPlot}/, docs that use Levels/Filled/N, web/tests/stories/gpu.spec.ts
  • untouched: packages/boxes (no new box head: RasterBox and LineBox carry it), packages/ce-patches, packages/engine, the complex emitter.

Two PRs: (a) margins, RegionPlot, implicit curves, RegionFunction and the option renames on the CPU route, with the records; (b) the shader host, the shared view, slots and the GPU stories.

4. After it

  1. Curve options (row 2): Exclusions -> Automatic | None | [x == a, …], where Automatic asks the interval-js compile of the expression whether a segment's image is bounded, bisects to the break, and never joins across it; MaxRecursion, ClippingStyle, PlotStyle, Filling in full, labeled PlotLegends.
  2. A depth-buffered drawer for Graphics3DBox (rows 3 and 4): then ParametricPlot3D, RevolutionPlot3D, SphericalPlot3D, Mesh/MeshFunctions, and ContourPlot3D by marching the field host's plotFn in 3-D.
  3. Dense vector fields (row 5) on the field host: StreamDensityPlot, line integral convolution.

5. Decisions (Dean, 2026-11-12: every lean accepted)

  1. Fields per pixel (§3) first, in two PRs, before curve options and 3-D.
  2. Hard renames, no aliases: Levels → Contours, Filled → ContourShading, N → PlotPoints on the field plots; FrameLabel -> [x, y] captions their frame.
  3. ContourShading defaults to False (lines), by the booleans-default-False rule.
  4. RegionFunction -> (x^2 + y^2 < 9), a predicate over the plot's variables, is the primary spelling; a Function is accepted.
  5. Overlapping regions of RegionPlot([p, q], …) are translucent and painted each over the last (ColorMixing -> "Normal") unless ColorMixing says otherwise.

Home

Project

Working

Design: every design page, by subject

Clone this wiki locally