Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@ content/.trash/
content/.obsidian/

content/js

# The Elm compiler's package cache for quartz-local/elm-graph.
elm-stuff/
content/tools/wasm/
content/.stfolder
2 changes: 2 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
cargo-edit
cargo-hack
clang
elmPackages.elm
elmPackages.elm-test
git
gcc
gnugrep
Expand Down
153 changes: 153 additions & 0 deletions quartz-local/elm-graph/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# elm-graph

The site graph, drawn in Elm.

Quartz's graph is a community plugin, `github:quartz-community/graph`. It draws
a good picture, and to draw it the page fetches **d3 and pixi.js from jsDelivr**
— about a megabyte and a half of somebody else's JavaScript, from a third party,
on every page view — and paints into a WebGL canvas. Its script opens with
`@ts-nocheck`, so none of it is type-checked, and the reader's IP address goes
to a CDN before a single node is drawn.

This draws the same picture out of SVG, from a compiled Elm program served from
this site. **59 KB, 20 KB over the wire**, fetched only on a page that has a
graph on it.

## What is where

| file | what it is |
| ----------------------------- | --------------------------------------------------------------------------------- |
| `src/Main.elm` | the graph: neighbourhood, force simulation, SVG, hover, click |
| `components.ts` | the Quartz component — two empty containers and the button that opens the big one |
| `styles.ts` | what a node, a link and a label look like in each theme |
| `../../ts/src/graph.ts` | the page's side: the content index, the box's size, visited pages, navigation |
| `../../tools/site/src/elm.rs` | `site elm`, which compiles `Main.elm` into `content/js/elm.js` |

Nothing here is hand-compiled: `site build` and `site wasm` both run `site elm`,
and `content/js` is a build output.

## The layout

The forces are d3-force's, deliberately, so the graph settles into the shape the
old one settled into: charge at `-100 × repelForce`, links pulling towards
`linkDistance` with d3's own strength and bias, a centring force that moves
positions rather than velocities, one pass of collision, and the same alpha
decay of 0.0228 per tick down to 0.001.

Two things are not d3's:

- **Nothing is random.** d3 seeds positions with `Math.random`; this uses the
phyllotaxis spiral d3 falls back to for a node with no position, so the same
page draws the same graph every time it is opened.
- **The first layout is run before anything is drawn**, against a budget of
about 110,000 node-pair steps. A simulation that starts from its seed
positions spends its first second looking like an explosion, which in a
sidebar is movement for its own sake; and a graph in a background tab gets no
animation frames at all, so without this it would sit there as a spiral of
untouched seed positions. The budget is what keeps that cheap: a step compares
every node with every other, so the whole-site graph — 94 nodes against the
sidebar's three — gets twelve steps where the sidebar gets its full three
hundred and settles completely.

A reader who has asked for `prefers-reduced-motion` gets the layout settled and
still, rather than performed at them.

## Colour

There is none in the Elm. A node is a `circle` with `is-current`, `is-tag`,
`is-visited`, `is-hovered` or `is-dim` on it, and `styles.ts` says what those
mean in `var(--secondary)` and friends. The graph this replaced read six custom
properties out of `getComputedStyle` when it started and painted them into a
WebGL scene, which is why it had to be told to rebuild itself when the theme
changed; this one is repainted by the browser like anything else on the page.

## The four ports

Elm owns the graph and nothing else. Everything that is the page's job crosses a
port:

| port | direction | what it carries |
| --------- | --------- | ---------------------------------------------------------------------- |
| `follow` | out | the id of a clicked node, which the page hands to Quartz's router |
| `expand` | out | a click on the background, which the page turns into the big graph |
| `failed` | out | why flags could not be read, for the console |
| `resized` | in | where the container is and how big, from a `ResizeObserver` and scroll |
| `halt` | in | stop: this app's view has been patched out of the page |

`resized` carries the corner as well as the size because a mouse event says
where it happened in the window, and the graph works in the units of its own
drawing. An Elm event decoder can read properties off an event but cannot call
`getBoundingClientRect`, so the page measures and sends. Scrolling moves the
corner without changing the size, which is why the page watches for that too:
without it, zooming a graph the reader had scrolled to would zoom about the
wrong point.

`halt` exists because of how Quartz navigates. It patches the document rather
than replacing it, so after a soft navigation the container is the same element
— with the drawing taken out of it by micromorph, because the incoming page's
HTML has an empty one. The app rendering into it is left holding a view that is
no longer in the page, so `graph.ts` stops it and starts another around the page
the reader has arrived at.

Clicks are the other place the two sides meet. A node is an SVG `<a href>`, so
it can be focused and read as a link — but Quartz's router reads `href` off
whatever anchor a click came from, and on an SVG anchor that is an
`SVGAnimatedString` rather than a string. The anchor carries `data-router-ignore`
to keep the router's hands off it, and Elm sends the id out through `follow`
instead, which the page turns into a URL the router can read.

## The mouse

Dragging a node pins it: the forces go on pushing at it, it does not move, and
everything else rearranges itself around where it has been put. Letting go
unpins it. A node is also a link, so the click that ends a drag would otherwise
follow it — every drag remembers whether the mouse travelled more than three
pixels, and the click after one that did is swallowed. Three rather than one
because pressing a trackpad moves the pointer a little on the way down, and
that is a click.

A click on the background — down and up in the same place — opens the
whole-site graph, because the button in the corner is easy to miss and a graph
that small is mostly an invitation to see a bigger one. Dragging the background
pans instead, and the wheel zooms about the pointer, between a quarter and four
times, on d3's own curve of two to the power of the delta over five hundred. Both are the `viewBox` and nothing else: no transform on the
nodes, no second coordinate system, and a stylesheet that goes on 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, so that zooming into a corner
is what makes it readable rather than fifty labels at once.

## What is not ported

Touch. The sidebar graph is desktop-only and the whole-site one is opened from
it, so this listens for mouse events rather than pointer events, and there is
no pinch. `scale` is gone for good: it was a pixi camera setting with nothing
to configure in an SVG.

## Tests

```sh
cargo run --locked --manifest-path tools/site/Cargo.toml -- elm-test
```

Forty-nine of them, and `site check` runs them. They cover the half of this
that a screenshot cannot show: that a link settles at about the length it asked
for, that the neighbourhood walks a link in both directions, that a drag
carries a node by exactly the distance the mouse moved and half that at twice
the zoom, that the wheel keeps whatever is under the pointer under the pointer,
and that the click ending a drag is not a link being followed.

Elm will not let a test look inside a `Cmd`, so where a message's only job is to
send one, what is checked is the state that decides whether it is sent.

## Building it

```sh
cargo run --locked --manifest-path tools/site/Cargo.toml -- elm --prod
```

`--dev` skips `--optimize` and the esbuild pass, so the JavaScript in the
debugger is the JavaScript on disk. The compiler and the test runner are
`elmPackages.elm` and `elmPackages.elm-test` in the flake's dev shell; on a
machine without nix, `site` reaches for elm-test through `bun x` at the pinned
`0.19.1-revision12`, because plain `latest` is built for a compiler that is not
out yet. `elm-stuff/` beside this file is the package cache and is ignored.
105 changes: 105 additions & 0 deletions quartz-local/elm-graph/components.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* The box the Elm graph is drawn in.
*
* Everything here is empty: two containers carrying the settings the graph
* should be built with, and a button that opens the second one. `/js/graph.js`
* reads the content index and starts `/js/elm.js` on them -- see
* `src/Main.elm` for the graph itself, and the README for why it is in Elm.
*
* The containers are empty on purpose rather than by accident of being drawn
* later: with the script blocked there is no half-graph and no empty framed
* box, because the CSS gives the box its border only once something is in it.
*/

import { h } from "preact"
import type {
QuartzComponent,
QuartzComponentConstructor,
QuartzComponentProps,
} from "../../quartz/components/types"
import { classNames } from "@quartz-community/utils"
import { styles } from "./styles"

/**
* The knobs the old graph had, kept name for name so that a config written
* against it means the same thing here. Two are gone: `drag` and `zoom`, which
* this does not do yet, and `scale`, which was a pixi camera setting.
*/
export interface GraphConfig {
/** How many links away from this page to draw. Negative draws the site. */
depth: number
repelForce: number
centerForce: number
linkDistance: number
/** Dim everything that is not the hovered node or a neighbour of it. */
focusOnHover: boolean
/** Pull the nodes onto a ring, which keeps the site graph from knotting. */
enableRadial: boolean
showTags: boolean
removeTags: string[]
}

export interface GraphOptions {
localGraph?: Partial<GraphConfig>
globalGraph?: Partial<GraphConfig>
}

const local: GraphConfig = {
depth: 1,
repelForce: 0.5,
centerForce: 0.3,
linkDistance: 30,
focusOnHover: false,
enableRadial: false,
showTags: true,
removeTags: [],
}

const global: GraphConfig = {
depth: -1,
repelForce: 0.5,
centerForce: 0.2,
linkDistance: 30,
focusOnHover: true,
enableRadial: true,
showTags: true,
removeTags: [],
}

/** Three nodes and two links: the picture the button opens, in 16 pixels. */
const icon = h("svg", { viewBox: "0 0 16 16", width: "16", height: "16", "aria-hidden": "true" }, [
h("line", { x1: "4", y1: "12", x2: "8", y2: "4", stroke: "currentColor", "stroke-width": "1" }),
h("line", { x1: "8", y1: "4", x2: "12", y2: "11", stroke: "currentColor", "stroke-width": "1" }),
h("circle", { cx: "8", cy: "3.5", r: "2.5", fill: "currentColor" }),
h("circle", { cx: "3.5", cy: "12.5", r: "2.5", fill: "currentColor" }),
h("circle", { cx: "12.5", cy: "11.5", r: "2.5", fill: "currentColor" }),
])

export const ElmGraph: QuartzComponentConstructor<GraphOptions> = (opts) => {
const Component: QuartzComponent = ({ displayClass }: QuartzComponentProps) => {
const near = JSON.stringify({ ...local, ...opts?.localGraph })
const whole = JSON.stringify({ ...global, ...opts?.globalGraph })

return h("div", { class: classNames(displayClass, "elm-graph") }, [
h("h3", null, "Graph"),
h("div", { class: "elm-graph-outer" }, [
h("div", { class: "elm-graph-container", "data-cfg": near }),
h(
"button",
{ type: "button", class: "elm-graph-expand", "aria-label": "Graph of the whole site" },
icon,
),
]),
h(
"div",
{ class: "elm-graph-modal", hidden: true },
h("div", { class: "elm-graph-container is-global", "data-cfg": whole }),
),
])
}

Component.css = styles
return Component
}

export default ElmGraph
28 changes: 28 additions & 0 deletions quartz-local/elm-graph/elm.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"type": "application",
"source-directories": ["src"],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.1",
"elm/json": "1.1.4",
"elm/svg": "1.0.1"
},
"indirect": {
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.5"
}
},
"test-dependencies": {
"direct": {
"elm-explorations/test": "2.2.1"
},
"indirect": {
"elm/bytes": "1.0.8",
"elm/random": "1.0.0"
}
}
}
1 change: 1 addition & 0 deletions quartz-local/elm-graph/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export function init(): void {}
26 changes: 26 additions & 0 deletions quartz-local/elm-graph/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@hill/quartz-elm-graph",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./index.ts",
"./components": "./components.ts"
},
"quartz": {
"name": "elm-graph",
"displayName": "Elm Graph",
"description": "The site graph, drawn in Elm: the neighbourhood, the force simulation and the SVG are all compiled from quartz-local/elm-graph/src/Main.elm, with no d3 and no pixi.js fetched from a CDN.",
"version": "1.0.0",
"category": "component",
"components": {
"ElmGraph": {
"name": "ElmGraph",
"displayName": "Elm Graph",
"description": "Interactive site graph, drawn as SVG by a compiled Elm program.",
"version": "1.0.0",
"defaultPosition": "right",
"defaultPriority": 10
}
}
}
}
Loading