Skip to content

Add Elm graph scaffold - #111

Open
float3 wants to merge 6 commits into
masterfrom
elm
Open

float3 wants to merge 6 commits into
masterfrom
elm

Conversation

@float3

@float3 float3 commented May 8, 2026

Copy link
Copy Markdown
Owner

No description provided.

float3 added 6 commits May 7, 2026 23:49
The scaffold from May had five files in it, two of them empty. This is the
graph.

What it replaces is github:quartz-community/graph, which draws a good
picture and fetches d3 and pixi.js from jsDelivr to do it: about a
megabyte and a half of somebody else's JavaScript, from a third party, on
every page view, before a single node appears. Its script opens with
`@ts-nocheck`, so none of it is checked by anything. The reader's IP
reaches a CDN whether or not they ever look at the sidebar.

This is 54 KB of compiled Elm, 19 KB over the wire, served from here and
fetched only on a page that has a graph on it. It draws SVG, so the
browser does the compositing and the stylesheet does the colour --
`is-current`, `is-tag`, `is-visited`, `is-hovered`, `is-dim` -- where the
old one read six custom properties out of getComputedStyle at startup and
had to be told to rebuild itself when the theme changed.

The forces are d3-force's on purpose, so the graph settles into the shape
it used to: the same charge, link, centring and collision, the same alpha
decay. Two things are not d3's. Nothing is random -- positions come from
the phyllotaxis spiral d3 keeps as a fallback, so a page draws the same
graph every time it is opened -- and the first layout is run before
anything is drawn. A simulation starting from its seed positions spends
its first second looking like an explosion, and a graph in a background
tab is handed no animation frames at all, so without it the sidebar would
sit there as a spiral. That runs against a budget of node pairs rather
than a fixed number of steps: a step compares every node with every other,
so the whole-site graph's 94 nodes cost 4,000 times what the sidebar's
three do. Opening it blocked the page for 376 ms before the budget and 52
ms after it, and the sidebar still settles completely in 4 ms.
prefers-reduced-motion gets the layout finished and left still.

Elm owns the graph and nothing else; four ports carry what belongs to the
page. `follow` hands a clicked node's id back so Quartz's own router does
the navigating -- a node is an SVG `<a href>` for focus and for the
accessibility tree, but the router reads `href` off the anchor a click
came from and on an SVG anchor that is an SVGAnimatedString rather than a
string, so the anchor carries data-router-ignore and the id goes out
through the port instead. `halt` exists because a soft navigation patches
the sidebar back to the empty container the incoming page has, taking the
drawing with it and leaving the app rendering into nothing; it is stopped
and another is started around the page the reader arrived at.

Drag and zoom are not ported yet. `scale` is gone for good: it was a pixi
camera setting with nothing to configure in an SVG.

The build gets `site elm`, which is `elm make` and then esbuild --
Elm's own guide is explicit that --optimize does no minifying and that the
output is meant to be handed to a minifier, and the compiler guarantees
there is no eval or reflection in it to be broken by one. `site wasm`
calls it, since content/js is the directory it empties and fills. The one
thing that needed care is that Elm ends its output by calling itself with
`this`: fine in a script tag, undefined in a module, and esbuild reads it
as a module and folds it to `}(void 0));`, which dies reading Elm of
undefined. It is repointed at globalThis, with a test that fails loudly if
a future compiler stops emitting that tail rather than skipping it.

elm is in the flake's dev shell now, which means CI compiles it and so
needs package.elm-lang.org to be reachable the first time.
The port had the picture and none of the handling. This is the rest of
what the old graph did with a mouse, and the tests that say it is right.

Dragging a node pins it -- the forces keep pushing at it, it does not
move, and the rest of the graph rearranges itself around where it has been
put. d3 throws a pinned node's velocity away every tick and so does this,
so letting go of one after hauling it across the box leaves it there
rather than firing it off with everything the forces wanted to do to it in
the meantime. A node is also a link, and the click that ends a drag is the
same click that follows one, so a drag remembers whether the mouse
travelled more than three pixels and the click after one that did is
swallowed.

Dragging the background pans, and the wheel zooms about the pointer,
between a quarter and four times, on d3's own two-to-the-delta-over-500.
Both are the viewBox and nothing else: no transform on the nodes, no
second coordinate system to keep in step, and a stylesheet still measuring
strokes and font sizes in the units it was written in. Labels fade in
above life size, which is the old graph's arithmetic -- fifty labels at
once is not reading.

Two things had to cross the boundary for any of it. A mouse event says
where it happened in the window, and an Elm event decoder can read
properties but cannot call getBoundingClientRect, so `resized` now carries
the box's corner as well as its size and the page re-sends it while the
reader scrolls; without that, zooming a graph somebody had scrolled to
would zoom about the wrong point. And the background needed something to
catch a drag that misses a node, which is a rect the size of the view,
painted in transparent because fill:none is not hit-tested.

The tests are the point of the commit as much as the features are. Forty
nine of them, run by `site elm-test` and by `site check`: that a link
settles at about the length it asked for, that the neighbourhood walks a
link in both directions, that a tag is drawn as a tag, that a drag carries
a node by exactly the distance the mouse moved and half that at twice the
zoom, that the wheel keeps what is under the pointer under it, that the
zoom stops at both ends, that the click ending a drag is not a link being
followed -- and that it is swallowed once rather than for ever. Inverting
one sign in the zoom fails four of them, which is the check that they
bite.

None of this could be seen from here: an Elm view redraws on an animation
frame, and a browser pane that is not displayed never gets one. The tests
are what stands in for the eye, and Main is exposed wholesale so they can
read it -- an application is built from `main` outwards whatever the
module line says, so nothing extra is compiled into the page for it.

elm-test comes from the flake in CI and from `bun x` at a pinned
0.19.1-revision12 elsewhere, because plain `latest` is 0.19.2-1, written
for a compiler that is not released: it emits an elm.json asking for Elm
0.19.2 and then refuses the 0.19.1 that answers.
Two things from watching it in use.

The whole-site graph was behind a button in the corner of the box, which
is easy to miss and easy to mis-click. A click on the graph's own
background opens it now -- down and up in the same place, since dragging
the background is how the picture is panned and that must not open
anything. Elm says only that a click happened, through a port; the page
decides what it means, which is how the big graph's own background can
click without asking to be opened again.

And the big graph had a background of its own, so it hid the page it is a
graph of. It has none now: the dimming behind it is the whole effect, the
article goes on showing through it the way the sidebars already did, and
the thing reads as this site's graph rather than a window laid over the
top of it.

The drag threshold now covers a held node too, not just a panned
background. Pressing a trackpad moves the pointer a pixel or two on the
way down, and every one of those was a drag rather than a click, so a node
would refuse to follow its own link about half the time. Three pixels of
travel is the same allowance panning already had.

Fifty three tests now, including that a two-pixel twitch still follows the
link and that a click on the background asks to expand while the end of a
pan does not.
origin/elm held one commit adding an empty scaffold: component.ts, index.ts
and Main.elm were all zero bytes. The branch grew its own implementation from
the same base instead, so elm.json and package.json conflicted add/add and are
resolved to the branch's versions, and the empty component.ts is dropped in
favour of components.ts. The merged tree is identical to the branch before it.

This branch has not been deployed

No deployments
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