Skip to content

Fix the deep review issues #143-#180, and the SSR rendering gap - #182

Merged
dave-hillier merged 83 commits into
mainfrom
fix-deep-review-issues
Sep 24, 2026
Merged

dave-hillier merged 83 commits into
mainfrom
fix-deep-review-issues

Conversation

@dave-hillier

@dave-hillier dave-hillier commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Closes the September deep review: all 38 issues (#143–#180), plus #181, which was filed while this work was in progress.

Nothing was pushed while the work was done, so this is the first reviewable view of it. It is one branch because the fixes share a small number of hot files (notably src/react/Replot.tsx and src/react/useMark.ts, which 14 issues converge on), and parallel work would only have produced conflicts.

The branch now contains origin/main, so the 0.1.0-alpha.1 packaging work is merged in and this PR merges as a fast-forward.

By area

Pointer interaction (#143, #144). The selection store reports the clearing value when the mark showing a tip unmounts, instead of holding the vanished datum and swallowing the next selection of it. The removed usePointer exports are documented as breaking.

Marks (#156–#166). AreaY line draws a topline; custom marker functions draw, with defs scoped to the mark that uses them; a channel-driven <title> is a child of the element it describes; difference composes its render onto the areas and stops churning clip ids; raster parses colours through the canvas once instead of per pixel and honours colorSpace; zero-valued rotate and zero-length vectors are emitted rather than silently dropped.

Tips (#159–#162, #178). The box is measured and the anchor flips to fit the frame; per-datum channel styles no longer tint it, which is what had put two plots on the parity allow list for the wrong reason; a format returning null no longer throws on hover.

React core (#145, #146, #148–#151). Marks and scales register from layout effects, so StrictMode and registration order are correct by construction rather than by accident; computePlot failures reach an enclosing error boundary; an unserialisable prop no longer keys a plot at random; an accessor that closes over React state recomputes the plot.

Rendering (#152, #153, #168, #177, #180). No direct DOM manipulation from React, and svg.scale is the lookup function the imperative API exposes rather than the raw scales object; the svg shell is shared between the React and static paths; warnings drain after the marks render, so a render-time warning belongs to the plot that raised it; a faceted mark announces itself once rather than once per facet.

Server rendering (#147). renderToString returns the whole plot instead of an empty host. A hydrating client redraws it, because marks register from an effect, and README and the docs now say that rather than claiming SSR works unchanged.

Packaging (#154, #174, #175). The replot/react entry moved out of index.tsx; the main, module and umd conditions and the bundle filename are fixed; react and react-dom are required peers, as plot() and legend() statically import react-dom/server; a pack-and-install smoke test now imports both entry points through a bundler, TypeScript and the UMD bundle as part of yarn test.

Parity and tests (#169–#171). The parity comparison now runs in CI against a pinned upstream checkout, and parity-allow.json records each plot's expected divergence as a machine-checked signature rather than a bare name, so a listed plot that diverges further fails the build. StrictMode coverage and the null-data assertions were replaced with ones that can actually fail.

Docs and tooling (#172, #173, #179). The inherited Observable Plot changelogs, images and docs twins are gone; coverage measures the TypeScript sources; the React hooks rules are enabled.

Reconciling with origin/main

Merging the release line in hit 15 real conflicts, and three of them were disagreements rather than overlaps. The merge commit records each decision:

  • Packaging takes the base's: package name @dave-hillier/replot, the compiled dist/esm build, its entry points and its release workflow, with this branch's entry rename applied on top.
  • Source layout takes this branch's: plot() stays in plot.ts with the rest of the fixes rather than moving back to the base's plotDom.ts split.
  • renderTransform.ts keeps the base's jsxToDom (it builds DOM directly rather than importing react-dom/server, the fix for Published package breaks the Vite dev server: CJS deps reach the browser unconverted #140) with this branch's userRenderOf restored — without it, a transform composed under pointer() never ran.
  • Legend.tsx keeps the base's LegendProps union and resolveLegendElement (LegendProps is missing the plot-scoped scale prop, so <Legend scale="color"> fails to typecheck #136) with this branch's render-phase registration, since effect-based registration never runs on a server.
  • One trap worth naming: src/react/api.d.ts was a hand-written re-export that the entry rename turned into a self-reference, and it overwrote the generated declaration in dist, so a TypeScript consumer saw no exports from replot/react. It is deleted.

Verification

yarn test            1818 passing, 2 pending, 0 failing; tsc, lint and prettier clean
yarn test:smoke      all four checks pass against the dist entry points
yarn test:package    publint clean; arethetypeswrong green on node16 ESM, bundler, node10 and node16 CJS
yarn parity --fail   219 of 219 allow-list entries match, 0 problems
yarn docs:build      succeeds

One CI finding

The Node matrix this work added turned up a snapshot that is not byte-stable across V8 lines. projectionDomainRatioNC renders the same polygon from a different starting vertex on Node 24 than on Node 22, because d3-geo's clipping is sensitive to the floating-point behaviour of the V8 the tests run on; the geometry is identical, only the ring's start moves. Reproduced locally on 24.21.0, byte-identical to what CI produced, and it is the only plot affected.

The snapshots are therefore checked on the primary line only, and the other lines run every test except the snapshot harness, so a Node-specific breakage in the library still shows up.

🤖 Generated with Claude Code

dave-hillier and others added 30 commits September 3, 2026 09:16
withTitleChild now omits the <title> when the mark has a tip, as
applyChannelStyles does upstream; a native tooltip would otherwise compete
with the tip on hover. channelStyleProps no longer writes href onto the
shape element, since withHrefWrap already wraps it in an <a>. Arrow, circle
and tick markers get stroke-dasharray="none" so they don't inherit a dashed
line's pattern.

Regenerates the 28 affected snapshots; title and href counts now match the
upstream outputs exactly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The snapshots in test/output are Replot's own, so a green suite shows only
that Replot's output has not changed. `yarn parity` canonicalises each
snapshot and its counterpart in an Observable Plot checkout (attribute
order, generated ids and class names, numeric precision, whitespace) and
reports what differs per plot by tag and attribute, with --show for a
unified diff and --allow/--fail for CI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Groundwork for fixing the React pointer layer, which today only works for an
unfaceted mark with explicit x and y and one consumer per plot. No behaviour
changes yet: nothing is wired into <Plot>, so rendering is unaffected and no
snapshot moves.

Re-syncs src/interactions/pointer.js to upstream's current text (it was the
older pre-pool version) and lifts three pure functions out of its closure:
pointerOffsets, pointerAnchors and pointerSearch. Upstream's own closure calls
them, so the React path and the imperative one share an implementation and a
future re-sync stays a small diff. pointerSearch keeps upstream's arithmetic
exactly, including the NaN exclusion of a datum missing a coordinate.

Adds a React-free selection store keyed by the registration record itself
rather than by aria-label, so two tips or a crosshair's several same-labelled
sub-marks can no longer collide, and arbitration is per group rather than
winner-take-all. Adds a test harness that dispatches hovers in SVG user space
and can model a CSS-scaled plot.

Freezes today's 307 differing plots in parity-allow.json as a ratchet, so later
steps can be checked for parity regressions automatically.

The store's tests are mutation-checked: each behaviour has a single-edit mutant
that the suite is confirmed to kill.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
<Replot> now owns one pointer store, and every rendered (mark, facet) slot
registers a record in it. A slot renders only the datum its record has been
awarded and reads it through useSyncExternalStore, so a pointer move re-renders
the slots whose selection changed and nothing else; the mark tree is untouched.

The store remembers WHERE THE POINTER IS rather than which datum was selected:
when the registry changes it re-runs the ordinary hit test at that position
against the new index, data and anchors, so transforms, derived rows, reorders,
insertions, deletions and filters need no special case. Previous revisions
validated the retained index instead — first against the index's membership,
then the data array's reference, then the datum's identity — and each proxy for
"still the same datum" broke a legitimate case: groupX and binX synthesise new
datum objects on every recompute, and a controlled plot rebuilds its rows on
the very render the selection caused.

The value contract is upstream's: the focused datum is assigned to the plot's
root element (the <figure> when there is one, else the <svg>), and a bubbling
input event fires whenever it changes — one report per change, not one per
consumer — so viewof and addEventListener behave as they do in Observable Plot.
The onValue prop follows the same rule.

The hit test reports a miss exactly as upstream does, as maxRadius squared
rather than Infinity, so a miss can win its group and blank every mark in it.
An earlier revision dropped misses out of the arbitration and so showed a datum
wherever upstream shows nothing.

Removes usePointer, findNearest, formatTip and their types from the public
surface. A pointer consumer is declared the way Observable Plot declares one,
as a mark's tip option or a <Tip>/<Crosshair> component.

Four of the new tests fail until the next commit, which reports the clearing
value when the record holding the selection unmounts (#143).

Co-Authored-By: Claude Code <noreply@anthropic.com>
settle()'s third step was documented but never written. A record that unmounted
was cleared — so the tip went with its mark — while nothing was dispatched for
it, leaving figure.value, the input event and the onValue prop holding a datum
the plot no longer drew, and leaving the store's duplicate-value guard
(lastValue) holding it too, so the next genuine selection of that same datum
was swallowed as well.

The report is made at the plot level, and after the survivors have been
re-resolved: if nothing is showing while something is still reported, the plot
reports the clearing value. It cannot be left to the per-record clearing
dispatches, because those reproduce upstream's faceted suppression quirk
(pointer.js:172; see suppressesClearingDispatch) and a suppressed clearing
would leave the guard holding a vanished datum for good.

Closes #143.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The tip consumers' DOM changed shape: the facets of one tip mark render as the
children of a single promoted <g aria-label="tip">, the form upstream's render
walker produces, rather than as one labelled group per facet. Everything else
in the .svg baselines is that promotion moving attributes and cell transforms
with it.

The two viewof plots (pointer.tsx's pointerViewof and arrow.tsx's
arrowTestPointer) now render the figure-and-textarea shape upstream's own
pointer tests use, which has no SVG form, so their baselines move from .svg to
.html.

pointerRenderCompose comes off parity-allow.json: it matches the Observable
Plot snapshot now rather than differing in an accepted way.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The Unreleased section listed only the onInput change as breaking. The hook and
the helpers that went with it are gone from replot/react as well — usePointer,
findNearest, PointerState, UsePointerOptions and formatTip — so they are listed
as breaking too, along with what a caller declares instead.

The sentence describing how a selection survives a recompute stated the rule
the rewrite deliberately rejected, that the datum at the retained index decides
whether the selection stands. What survives is the pointer position, so it now
says that the tip follows the datum drawn under it and is dropped only when
nothing is drawn there.

PLAN.md's file tree and its interactions step still described a usePointer hook
and a usePointer.ts module, neither of which exists; they now name the store
and the context that replaced them.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Upstream decides the transform from the presence of the rotate channel, not
from the resolved angle (vector.js:106-114, image.js:99-100, text.js:110-114):
a zero angle is still written, and the vector anchor translate is written for
every anchor but start, even for a zero length. Replot tested the values for
truthiness, so a rotate channel with a zero value, and a zero-length vector,
silently lost their (identity) transform.

Three plot baselines change, each purely by the expected rotate(0) clause:
caltrainDirection and vectorField gain rotate(0) on their zero-angle vectors,
and kittenVariableRotate gains rotate(0) plus its transform-origin.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…eRendering

The raster mark parsed every pixel's colour with d3-color, which cannot
express a wide-gamut colour space, and it built the image-rendering
attribute as a hyphenated key, which React rejects on an SVG element (and
warns about on the console).

Parse each colour through a 1x1 canvas instead, as upstream's raster.js
does: getColorConverter fills one pixel, reads it back, caches every colour
it has seen and returns an all-zero sentinel for a null colour, so a raster
of one colour costs a single parse per render. The converter is cached on
the mark, the render canvas is created in the mark's colorSpace, and
colorSpace is now an option, lowercased onto the mark and defaulting to
srgb, all matching upstream. Colour syntax the CSS parser knows but
d3-color does not, such as oklch, survives as a result.

The image element is rewritten to use React's camelCase imageRendering
prop, and now takes the channel title as its own child rather than a
sibling, which closes raster's part of #157.

Regenerates rasterMixedOpacity, whose image data now matches upstream's
pixel for pixel: 0 of its 160 pixels differ, where 36.25% differed by as
much as 11 per channel before.

Fixes #164

Co-Authored-By: Claude Code <noreply@anthropic.com>
Both marks emitted the channel-driven <title> as a sibling of the path,
line or rect, so the tooltip attached to the enclosing group rather than to
the shape: hovering a contour band showed the group's first title, or none
at all, and a frame title, the frame having no group, landed directly on
the <svg> and described the whole plot.

Both now pass withTitleChild(...) as the element's child, as vector and
line already did, and contour gives each band its own title rather than one
for the group. Upstream reaches the same result through applyChannelStyles,
which appends the title to the selected element. Raster's title moved with
the raster rework in the previous commit.

Fixes #157

Co-Authored-By: Claude Code <noreply@anthropic.com>
Upstream's area mark has a second stroke-only path (aria-label
"area-line", area.js:96-152) selected through the line option, so that a
translucent fill can be combined with a crisp edge without a second mark.
Replot never ported it: `line` was not an option, so <AreaY line /> silently
rendered a plain filled area, and test/plots/aapl-close.tsx worked around it
with a separate <LineY>.

AreaLine extends Area, taking the line mark's defaults (fill-opacity 0.3,
stroke currentColor, stroke-width 1.5) and its markers via the shared
markers() options. Its renderJSX walks the same groups as Area and emits two
paths per group: the area without a stroke, and the topline without a fill,
both inside one group so the direct and channel styles apply to the pair.
Marker defs are hoisted into a single <defs>, as in Line.renderJSX.

The aaplClose test plot goes back to the upstream form and its baseline now
matches upstream's SVG byte for byte; the plot no longer needs its
parity-allow.json entry.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…158)

A marker option can be a custom function, but nothing ever called it: the
name-only resolver fell through to "invalid marker", so a documented
MarkerFunction was never drawn. markerToJSX now calls it with the stroke
color and the render context and converts the element it returns back into
React elements.

Marker ids were built from the marker name and colour alone, so two marks
drawing the same marker shared one def. That is not merely untidy: a def
resolves currentColor — and any CSS variable — against its own ancestors
rather than against the path that references it, so the second mark drew the
first mark's colours. Ids are now minted per mark: markers() gives each mark a
scope, which travels with the resolved marker, and the id for a (scope,
marker, colour) triple is remembered so that a re-render — an interactive mark
re-renders on every pointer move — does not remount its defs.

The scope travels on the resolved marker rather than on the mark because the
callers that build marker defs (rule, tick, line, link) dedupe them by id
inside renderJSX, so the ids have to be stable within a render.

Regenerates the 21 snapshots that carry markers. The only difference is the id
form — plot-marker-N rather than plot-m-name-hash — and the def counts are
unchanged; the preceding scheme could not have made them unique per mark.

Co-Authored-By: Claude Code <noreply@anthropic.com>
linearRegression drew its confidence band without the title its line carries.
Upstream applies the grouped channel styles — the title among them — to both
paths, so the band gets withTitleChild too.

hexgrid rendered its grid path with the mark's direct styles only, dropping
every channel style, title and href. The grid is a single path over a
singleton datum, so its channel values are read at index 0, which is what
upstream's .datum(0) amounts to.

tick's _transform methods went with the imperative d3-selection renderer; that
path no longer exists, so the methods and the applyTransform import they were
the last users of are gone. cell and bar still carry their copies.

geo's options parameter was typed any, which hid the centroid(options)
reassignment; it is a GeoOptions.

No snapshots move: these paths are styled and titled through channels that no
committed snapshot exercises.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Two defects in the difference mark, both in how it uses the clip renderJSX it
hands its two areas.

The render option was left in the options spread, so it reached all three
sub-marks. Upstream destructures it and composes it onto the two areas only —
composeRender(render, clipDifference(k, …)) (difference.js:38,60,77) puts the
user's transform outside the clip, and the line never sees it. In Replot the
option was inert on the areas (an own renderJSX suppresses the imperative
bridge) and live on the line, so a difference mark with a render transform
transformed the wrong mark. The areas now take the transform as an own
render, over a mark that inherits the clipped one, so the bridge runs with
the clipped area as `next` and `this` still the area.

The clip ids were allocated inside the render closure, and the JSX paths
re-render a mark whenever React re-renders the plot without recomputing it,
so every pass rewrote the clipPath ids and the clip-path refs. They are now
allocated once per facet and group and reused, which is what the imperative
render got for free by running once per mark. A per-facet key is needed
because each facet renders the areas with its own index and its own defs.

Three tests come with the change: the transform runs for both areas and not
the line, the clip ids differ per facet, and they hold still across a React
re-render. The arrowTestDifferenceY baseline is regenerated because the new
test file consumes clip ids earlier in the run, which removed a duplicate id
from that snapshot: its difference clip had been colliding with the plot's
frame clip, so two elements shared plot-clip-1. That collision itself is
untouched here — difference clip ids come from style.js's global counter
while the clip registry allocates its own plot-clip-N per render — and it
still needs the registry to own the allocation.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`.claude/settings.local.json`, `.claude/worktrees/` and `.playwright-cli/`
hold personal settings, agent worktrees and Playwright output. None of them
belong in the repository, and a worktree tree can run to several gigabytes,
so a single `git add -A` was enough to commit the lot.

Nothing is tracked under either directory, so no negation is needed to keep
a tracked file visible.

Fixes #173

Co-Authored-By: Claude Code <noreply@anthropic.com>
The area and marker branches were written independently: the area test
asserted the old marker id shape while the marker registry moved to
document-unique ids. Assert the three refs name the def that was actually
rendered, which is the property that matters and survives an id scheme change.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The c8 `include` glob matched only `.js`, so `yarn test:coverage` reported
on the 47 plain JavaScript sources and skipped every TypeScript one, 97
`.ts` and `.tsx` files where the React layer and the newer marks live. It
now covers `.js`, `.ts` and `.tsx`.

React hook rule violations were invisible: nothing configured
eslint-plugin-react-hooks. The plugin's flat recommended set now applies to
`src/react`, with `rules-of-hooks` as an error. The three React Compiler
rules (`immutability`, `refs`, `set-state-in-effect`) are reported as
warnings rather than errors: they flag the mark registry's design, where
marks register during render and record their resolved options on the mark
instance so `<Plot>` sees every mark before its compute effect runs. The
config records that reasoning, and the existing findings stay visible
instead of being silenced.

`rollup.config.js` and the four `@rollup/plugin-*` devDependencies are
gone. The bundle is built by vite (`vite.bundle.config.ts`), `@mdx-js/rollup`
is a vite plugin with its own dependency, and nothing else referenced rollup
or those plugins. Vite still depends on rollup itself.

Fixes #179

Co-Authored-By: Claude Code <noreply@anthropic.com>
Nothing exercised the package the way a consumer meets it, so an entry
point that stopped resolving would only be noticed after publishing.
`scripts/smoke-test.mjs` packs the package with `npm pack`, installs the
tarball into a throwaway project, and then imports both entry points:
through a bundler, through `tsc` as a TypeScript consumer, and through the
UMD bundle in plain Node with a `d3` global, which is what a `<script src>`
page provides. It also checks that every path the package declares exists
in the tarball. `yarn test:smoke` runs it, and `yarn test` now includes it.

The existence check failed on the state it was written against, and both
causes were real: `main`, `module` and the default export condition pointed
at `src/index.js`, which does not exist (they resolved only because the
TypeScript toolchain substitutes the `.js` extension for `.ts`), and
`jsdelivr`, `unpkg` and the `umd` condition pointed at
`dist/plot.umd.min.js` while the bundle is built as
`dist/replot.umd.min.js`.

Fixes #174

Co-Authored-By: Claude Code <noreply@anthropic.com>
The workflow pinned Node 22. It now runs a matrix over the Node lines that
are still maintained, 22 and 24, with `fail-fast: false`, so a failure on
one line does not hide the result on the other. The issue proposed 18, 20
and 22; 18 and 20 reached end of life before this change, so testing them
would only add failures that no one can act on.

`yarn test:smoke` joins the steps, so every pull request packs the package,
installs the tarball into a throwaway project and imports both entry points.
It builds the published artifacts itself, as the step above it does.

Co-Authored-By: Claude Code <noreply@anthropic.com>
CHANGELOG-2021.md through CHANGELOG-2024.md, and everything from 0.6.17
down in CHANGELOG.md, are Observable's release notes rather than
Replot's. The 172 images under img/ existed only to serve them, so they
go too; img/localhost.png stays, since CONTRIBUTING.md links to it.

Replot's own entries stay: the Unreleased section, with its pointer
notes and breaking changes, and the changelog's own title.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A titled dot rendered as <g><circle/><title/></g>. Upstream appends the
title to the selected element (style.js applyTitle), so the dot itself is
the element that carries it and no wrapper is emitted. The item key now
follows the datum index, which is the index the title channel is looked up
by, rather than the item's position in the list.

Baselines: the 19 fixtures whose dots carry titles.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…at (#160, #161, #162)

A format that returns null for one datum no longer throws: every datum is
formatted first and the nulls dropped, as upstream does (tip.js:132-134). The
visibility decision counts the showing items, so a tip whose data all format to
null is empty rather than hidden.

The box is no longer styled by the channels that feed it: the channels choose
the content of the tip, not its look (upstream tip.js:136-137), so a per-datum
fill cannot leak onto the box.

The box is measured where a layout engine exists. TipItem renders the item once
from an estimate and again from the text's getBBox in a layout effect, which
re-runs upstream's fit (tip.js:222-243): the box is oriented to stay inside the
frame, the lines shift to the measured left edge (tip.js:238), and a faceted tip
is fitted to its own cell. The estimate remains what jsdom, the server and a
static snapshot see, so those outputs keep a stable, meaningful box.

Also from upstream: the corner anchors use m/2 in getPath and getTextTranslate;
contours joins the ignored channels; and a channel whose values are all literal
is dropped whatever its default scale, so an explicit symbol no longer appears
as a tip line.

Co-Authored-By: Claude Code <noreply@anthropic.com>
docs/ carried 68 .md files alongside their .mdx twins, inherited from the
VitePress era. The docs build reads only .mdx: app.tsx globs ./**/*.mdx,
and the link checker crawls .mdx and maps each .md link target onto its
.mdx twin. The .md files were the twins: every page had both, and nothing
read the .md copies.

scripts/convert-docs.ts still globs docs/**/*.md, so it now matches
nothing. It is left in place rather than deleted here.

Co-Authored-By: Claude Code <noreply@anthropic.com>
boxX/boxY fill and the tree transform's stroke were fixed light-only greys,
so a dark-mode page drew them at the wrong contrast. Both now answer the colour
scheme with light-dark(), as upstream's do (box.js:19/45, tree.js:68).

Baselines: the 9 fixtures that use the box or tree defaults.

Co-Authored-By: Claude Code <noreply@anthropic.com>
yarn run mocha --conditions=mocha --parallel --watch test/marks/bar-test.js
fails with ERR_MODULE_NOT_FOUND for src/index.js: the tests import
"replot", which package.json maps to src/index.js, and that file exists
only as the TypeScript source src/index.ts. Plain Mocha cannot resolve
it, which is why test:mocha runs tsx.

Route the documented command through tsx the same way, drop the
--conditions=mocha flag (no "mocha" condition exists in the exports map)
and the --parallel flag (pointless for one file), and keep the timezone
that test:mocha sets, since date-dependent tests need it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…#178)

The three predicates destructured their argument, so asking about options that
were never given threw. They now read through optional chaining, as upstream's
do, and a missing object reads as "none of these channels".

Co-Authored-By: Claude Code <noreply@anthropic.com>
The projection is not a scale in the registry; it lives on the rendering
context, so exposeScales has to reach for it there (upstream scales.js:535). For
that to mean anything the projection has to be the point-wise object plot.scale
hands out, so createProjection returns stream/apply/invert and a projection
implementation passed as an object is wrapped in the same shape (upstream
projection.js:117-125). The frame transforms carry the inverse that invert
needs.

Tests ported from upstream (test/scales/scales-test.js:2313-2421): the
projection is undefined when unused, a named one applies and inverts back to the
same pixels, a projection implementation and a mercator object both work, and a
projection round-trips into a second plot.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@dave-hillier
dave-hillier merged commit 8c86cfe into main Sep 24, 2026
3 checks passed
This was referenced Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant