polarflex
v0.3.0
Published
A zero-dependency polar layout engine for the web. Flexbox semantics — justify, gap, flex — on circles, arcs and spirals. Pure JS core; render to DOM, Canvas, SVG or CSS.
Downloads
87
Maintainers
Readme
⊙ PolarFlex
A zero-dependency polar layout engine for the web. Flexbox semantics — justify, gap, flex, alignment — on circles, arcs and spirals. The core is pure JavaScript with no DOM access, so it runs anywhere: browsers, workers, Node, any framework. Renderers are thin consumers.
npm i polarflex # ESM, TypeScript types included, Node ≥ 18// or straight off a CDN — there is no build step to need one
import { Orbit, layoutOrbit, sectorPath } from 'https://esm.sh/polarflex';Working on the repo itself:
npm test # 66 unit tests, node --test, no deps
npm run typecheck # exercises the .d.ts against real usage
npm run demo # serves the repo → open /demos/
# (or: python3 -m http.server 4173)The demos must be served over HTTP (they use ES modules) — opening the files directly with
file://won't work.
Using an AI coding agent? Point it at
llms.txt(shipped in the package). It front-loads the conventions an LLM gets wrong from priors — the 12-o'clock-clockwise angle basis, animating inputs instead of transforms, the closed-ring center convention — plus copy-correct recipes.
The mental model
CSS lays out along rows and columns. PolarFlex bends the flexbox vocabulary around an origin:
| Flexbox | PolarFlex | Notes |
| --- | --- | --- |
| main axis | the arc | angles in degrees, 0° at 12 o'clock, clockwise |
| cross axis | the radius | |
| justify-content | justify | start · center · end · space-between · space-around · space-evenly |
| gap | gap (degrees) or gapLength (px of arc) | |
| flex-grow | flex | items split leftover sweep by weight — a donut chart is just data-as-weights |
| align-items | align / per-item balance | a 0–100 scale: 0 = inner edge on the baseline circle, 50 = centered, 100 = outer edge |
| writing-mode-ish | orientation | axis (upright) · radial (reads outward) · tangential (follows the curve) |
| flex-shrink-ish | overflow | visible (spill, the default) · shrink (compress sweeps proportionally; gaps survive) · grow (enlarge the radius until content fits — the polar answer: a ring can always grow) |
Items can size themselves by measurement (real DOM boxes), fixed sweep (degrees), arc length (px along the curve), or flex weight.
When content exceeds the range, the result always reports it (overflow,
free), and the overflow option decides what happens: 'shrink' scales
every non-flex sweep by one factor (reported as result.scale), 'grow'
solves for the baseline radius at which measured/arc-length content fits
(reported as result.radius) — fixed-degree sweeps never shrink or trigger
growth, because degrees don't change with radius.
One convention worth knowing: on a closed ring (full 360°) there are no
edges, so the space-* modes become phase choices. PolarFlex uses the
instrument-maker's convention — item 0's center sits exactly at
startAngle — so a 60-tick dial puts its 12-o'clock tick truly vertical and
rotate means what you think it means. (justify: 'start' keeps edge
semantics: item 0's leading edge at startAngle, the donut-chart convention.)
Fast paths and the slow path
Radial/tangential/fixed-sweep items have closed-form angular footprints — the solver is a single O(N) pass. Upright (axis) items are the hard case: their angular footprint depends on where they sit, which depends on everyone's footprint. The engine relaxes that system iteratively (exact 4-corner extents, O(K·N), K ≤ 16 — converges in 2–3 passes in practice). The playground demo draws the solver's working live.
Architecture
src/
math.js the kinematics — conversions, chords, sagitta, spirals, roses
layout.js THE BRAIN — layoutOrbit / layoutCurve / hitTest (pure functions)
paths.js THE MUSCLE — sectorPath / arcPath / spokePath / spiralPath / rosePath
dom.js vanilla DOM adapter — Orbit (ResizeObserver-batched, rAF-coalesced)
canvas.js Canvas2D glue — fitCanvas / drawPath, HTML-in-Canvas detectionShapes are strings, not commitments. Every generator in paths.js returns SVG path data — the one currency accepted by three renderers:
svgPathEl.setAttribute('d', d); // SVG
ctx.fill(new Path2D(d)); // Canvas2D
el.style.clipPath = `path("${d}")`; // CSSSo PolarFlex is not "an SVG library." HTML stays real HTML — native z-index, focus, tooltips, selection, accessibility — positioned by plain CSS transforms. Canvas paints the heavy geometry. SVG remains available when you want it. When the emerging HTML-in-Canvas API (layoutsubtree / canvas.onpaint) ships, the same placements can drive DOM inside WebGL scenes — supportsHTMLInCanvas() is already there.
Quickstart (React)
import { useOrbit } from 'polarflex/react';
function RadialMenu({ items }) {
const { ref, orbit } = useOrbit({ justify: 'space-evenly', gap: 4, radius: 'auto' });
return (
<nav ref={ref} style={{ position: 'relative', aspectRatio: '1' }}>
{items.map((it) => (
<button key={it.id} data-pf-flex={it.weight} onClick={it.run}>
{it.label}
</button>
))}
</nav>
);
}The division of labor is clean: React owns the children, PolarFlex owns their
positions. Keys, conditionals and .map() just work — the Orbit's
MutationObserver picks up reconciliation, the batched ResizeObserver re-measures
content changes, and per-item layout props are ordinary JSX attributes
(data-pf-flex, data-pf-sweep, data-pf-orientation, data-pf-balance, …)
that re-solve the ring when a render changes them. Pass fresh options every
render — updates are merged and coalesced into one rAF solve.
SSR/RSC-safe: the Orbit is constructed inside an effect, so server renders emit
plain HTML and positioning hydrates on the client. StrictMode's double-mounted
effects are handled. orbit.current exposes the full imperative API
(placements, setItem, hit-testing) when you need it. React is an optional
peer dependency — only the polarflex/react entry touches it.
Quickstart (vanilla DOM)
<div id="menu">
<button>Compose</button>
<button data-pf-flex="2">Search</button>
<button data-pf-orientation="tangential">Share</button>
</div>import { Orbit } from 'polarflex';
const orbit = new Orbit(document.querySelector('#menu'), {
radius: 'auto', // fit the container (or a number in px)
justify: 'space-evenly',
gap: 4, // degrees
align: 50, // 0 inner … 100 outer
});
orbit.set({ rotate: 90 }); // re-solves next frameThe adapter implements the Mount → Measure → Math → Paint lifecycle: children register automatically (MutationObserver), one batched ResizeObserver measures real boxes, the pure engine solves, and positions land in a single rAF write pass as transform plus CSS custom properties (--pf-x, --pf-y, --pf-rotation, --pf-angle, --pf-radius, --pf-index). Because the output is a transform, CSS transitions animate every re-layout for free — the radial-menu demo's bloom is one transition line, staggered with --pf-index.
One honest caveat: a CSS transition interpolates each item's transform in a
straight line between solves. Perfect for blooms and reflows; wrong for
rotation, where items should travel along the arc. For that, animate the
layout inputs instead — orbit.animate() tweens numeric options and
re-solves every frame (solves cost microseconds), so items move in polar
space. Angular keys take the shortest way around, and it respects
prefers-reduced-motion:
orbit.animate({ rotate: -120 }, { duration: 800, easing: 'expo-out' });
// → { finished: Promise<boolean>, cancel() } — re-animating a key takes
// over seamlessly from its current mid-flight valuePrefer to paint yourself? Use apply: 'vars' (custom properties only) or apply: 'none' + onLayout.
Stacking multiple orbits (read this before you nest rings)
A common composition is several Orbits layered over the same center — a main
ring with a sub-ring, ornament rings behind interactive ones:
<div class="stage">
<div class="ring" id="sub-ring">…</div>
<div class="ring" id="main-ring">…</div>
</div>The rings are full-size positioned overlays, and a transparent element still captures pointer events — the top ring's empty box will swallow every hover and click meant for the ring beneath it. The fix is one CSS pattern: ring containers pass events through, interactive items opt back in:
.ring { position: absolute; inset: 0; pointer-events: none; }
.ring :is(button, a, [tabindex]) { pointer-events: auto; }With that in place, layers stack freely, z-order is plain DOM order, and items can even overhang their container — hit areas follow the elements, not the boxes. (The radial-menu demo is the reference implementation.)
Quickstart (headless core — any framework, workers, Node)
Vue/Svelte/Solid: use the same pattern as React — bind a container ref, hand it
to new Orbit(el, options) in your mount hook, destroy() on unmount. Or skip
the DOM adapter entirely and consume the pure solver:
import { layoutOrbit, sectorPath } from 'polarflex';
const { placements } = layoutOrbit(
data.map(d => ({ flex: d.value, id: d.name })),
{ radius: 120, gap: 2, justify: 'start' }
);
for (const p of placements) {
ctx.fill(new Path2D(sectorPath({
innerRadius: 90, outerRadius: 150,
startAngle: p.startAngle, endAngle: p.endAngle,
cornerRadius: 8,
})));
}hitTest(placements, x, y, { innerRadius, outerRadius }) gives you pointer→slice resolution with pure polar math (law of cosines, angular intervals) — no pixel-picking.
API surface
layout.js — layoutOrbit(items, options), layoutCurve(items, options), hitTest(…), rotationFor(orientation, angle), tangentRotation(angle, r, r′)
layoutCurve has two modes. Step mode (pass step): items at uniform
angular intervals — unchanged since 0.1. Flow mode (pass endAngle):
a full solver in arc-length space — measurement / arcLength / sweep /
flex sizing with justify / gap / gapLength distribution along any
r(θ), where the currency is px of curve (which is what "equal spacing"
means on a spiral — equal degrees bunch up as the radius grows). Flow-mode
placements also rotate along the curve's true tangent (tilted by
atan(r′/r)), not the circle approximation.
math.js — polarToCartesian, cartesianToPolar, normalizeAngle, unwrapAngle, angularDelta, angleInRange, arcAngle/arcLength, chordAngle/chordLength, polarDistance, sagitta, archimedeanRadius, logarithmicRadius, roseRadius, legibleRotation, mulberry32
paths.js — sectorPath (rounded corners, pies, donuts, full rings), arcPath (stroke alignment, >180° splitting), spokePath (sine/zigzag/seeded-random drift with taper envelopes), curvePath (any r(θ)), spiralPath, rosePath, ringPath
dom.js — Orbit (with set, setItem, animate, layout, hit-testable placements), orbitify(selector, options), orbitStyles(); per-item config via data-pf-sweep, data-pf-flex, data-pf-orientation, data-pf-balance, data-pf-radius-offset, data-pf-arc-length, data-pf-ignore
canvas.js — fitCanvas (DPR-correct sizing), drawPath, supportsHTMLInCanvas
gestures.js (polarflex/gestures) — interaction on a polar basis. A
generic "swipe" classifies as left/right/up/down; near a ring the meaningful
split is tangential px (r·Δθ — spin it) vs radial px (Δr — pull it).
createPolarTracker is a pure recognizer fed (x, y, t) samples (states:
tap · press · hold · spin · radial; arc-length slop so tolerance is fair at
every radius; unwrapped winding angle and turns for cranks; smoothed
flick velocity) — Node-testable with synthetic streams. createInertia +
nearestDetent are the flick physics. polarGestures(el, handlers) is the
DOM binding — it captures the pointer only once a gesture classifies, so
taps on child buttons stay perfectly native. And the payoff:
import { attachDial } from 'polarflex/gestures';
attachDial(orbit, { snap: 'items', inertia: true });
// drag spins along the arc · flick coasts · settles on a placement —
// the layout's own solve is the detent table. Open arcs rubber-band.react.js (polarflex/react) — useOrbit(options) → { ref, orbit }; React is an optional peer dependency of this entry only
The demos (/demos)
| | Proves |
| --- | --- |
| Atlas (index) | the hero astrolabe is solved by the engine it advertises, re-solving per frame |
| Playground | every engine option live, with the solver's claimed angular intervals drawn underneath |
| Radial menu | real <button>s, native stacking/tooltips/focus, bloom = one CSS transition, composable sub-arcs |
| Donut chart | one solve drives Canvas slices and HTML labels; polar hit-testing; ~µs solves at 60 fps |
| Chronograph | ticks/numerals/word-ring/subdial as four engine outputs on two renderers |
| Spiral catalogue | layoutCurve on an Archimedean spiral, drag-to-wind, legibility auto-flip |
| Fifths | a playable circle-of-fifths: grab-and-spin via attachDial (placements as detents), Web Audio triads, fixed canvas window |
| Dial | the gestures playground: spin/flick/tap/hold/radial with live recognizer telemetry, native-click proof, open-gauge rubber-banding |
| Commands | a nested marking menu: center: 'press' makes stroke direction the polar angle; slide onto a ▸ command to unfold its children in one drag |
| Airlock | tangential vs radial on one surface — spin the carousel, or pull a crate past the threshold to jettison it; survivors close ranks via one re-solve |
Design notes / departures from spec v1.0
- No per-shape
<svg>wrappers. The spec's "every primitive renders its own absolutely-positioned<svg>" achieved native stacking, but path-data-as-strings achieves the same with renderer freedom (and fewer DOM nodes). The Z-index story is unchanged: HTML items are true siblings. - The React hook protocol maps 1:1 onto
Orbit.usePolarLayout≈ subscribe toonLayout+ a registry keyed by ref; the core was built so that adapter is an evening's work, not a rewrite. deg/rad, the kinematics, spirals, rose and sagitta utilities follow the spec'smath.tsdesign (§7), with angles in degrees at the API surface and the screen convention fixed library-wide.
MIT licensed. No dependencies, no build step, no opinions about your renderer.
