Skip to content

feat(points): per-point edge stroke in a shade of the point's own color - #267

Draft
rokotyan wants to merge 4 commits into
mainfrom
feat/point-stroke
Draft

rokotyan wants to merge 4 commits into
mainfrom
feat/point-stroke

Conversation

@rokotyan

@rokotyan rokotyan commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds an optional edge stroke: a thin inset band along every point's edge, by default in a shade of the point's own color a fixed OKLab lightness step away from the fill, or an explicit color and width per point. Off by default (pointDefaultStrokeWidth: 0), so existing renders are unchanged.

New config keys (config.ts, defaults in variables.ts, documented in configuration.mdx):

Key Meaning Default
pointDefaultStrokeWidth Default stroke width in CSS px, inset so the point never grows; constant across zoom. Points narrower than their stroke get none. 0
pointDefaultStrokeColor A color used as is, or a derivation rule: 'auto' (per point: darken light points, lighten dark ones), 'darken', 'lighten'. 'auto'
pointStrokeContrast Step in OKLab lightness L (0..1) between fill and derived stroke. 0.1

Per-point channels, following the pointDefaultColor / setPointColors pattern (NaN = use the default):

graph.setPointStrokeColors(Float32Array)  // RGBA per point; animates with transitionDuration
graph.setPointStrokeWidths(Float32Array)  // CSS px per point; animates with transitionDuration

Each channel has its own transition property (PointStrokeColors, PointStrokeWidths), so a stroke change never restarts the fill's transition from its stale source buffer and vice versa.

Greyed-out points always get the derived shade of their greyed fill, explicit color or not, so the stroke fades with the point.

Why OKLab

A fixed-fraction mix toward black/white has no headroom on bright saturated fills: at the same setting a blue's channels moved ~0.1 while a yellow's moved ~0.01, giving ~30% vs ~5% luminance contrast — one cluster visibly outlined, the other not. A step in OKLab L reads as the same contrast on every hue. The conversions live in a reusable luma ShaderModule (src/modules/Shared/oklab-module.ts: sRGB ⇄ linear ⇄ OKLab plus a chroma-reducing gamut map that preserves hue) and run once per point in the vertex stage.

Rendering

  • Stroke is a second coverage band off the same device-pixel edge distance the edge ramps (One-device-pixel edge anti-aliasing, centred on the edge #262) already use, so it anti-aliases exactly like the fill. With the stroke off the band is never mixed in and rendering is unchanged.
  • Occlusion culling, greyout, images and the existing outline ring are untouched.

Story

Examples/Points → Point Stroke: a static size grid (exact-pixel sizes, sub-pixel to ~28 px, all eight shapes) and a simulated set of overlapping single-color clusters framed at 2× fit, with controls for the default width, contrast and color rule, a light/dark background toggle, and a per-point overrides toggle (one group stroked white, another at 2 px, through the two setters).

History entry: history/2026/2026-09-15-point-stroke.md (commit hash left as TODO until merge).

Examples/Points → Stroke & Outline Rings: the stroke next to the existing selection rings (outlinedPointIndices, hover, focus) on six overlapping clumps, with a highlight toggle showing greyed points keeping a greyed stroke while their ring is skipped. The outlinedPointIndices doc now cross-references the stroke.

Screenshots

Default stroke width 0.75 px, contrast 0.10, 'auto' color rule, dark background.

Size grid — all eight shapes, sizes from sub-pixel to ~28 px. Points narrower than the stroke (far left) get none.

Size grid: all eight shapes with sizes ramping left to right, each with a thin lighter or darker edge stroke

Overlapping clusters — eight single-color piles after the simulation settles, framed at 2× fit.

Eight dense single-color clusters of overlapping circles, each disc outlined by a stroke in a shade of its own color

Why OKLab, up close — at the same 0.10 step, 'auto' darkens the yellow pile and lightens the blue one, and both outlines read at the same strength. With the earlier fixed-fraction mix toward white the yellow had almost no visible stroke.

Close-up of a yellow cluster with darker strokes and a blue cluster with lighter strokes, equally legible

Testing

  • npm run lint and npm run build pass.
  • Verified in Storybook: all shapes take the stroke; 'auto' darkens the yellow cluster and lightens the blue one at the same step; explicit rules work; per-point overrides (white color on one group, 2 px width on another) render with the rest on defaults; the small-point cutoff shows at the grid's left edge; width 0 restores the original rendering; no console errors.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Sep 15, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Overlapping nodes of one color read as a single blob: nothing marks where
one disc ends and the next begins. The common remedy is a thin outline in a
slightly darker or lighter version of each node's own fill, which separates
neighbours without introducing a second color. The existing outline feature
(`outlinedPointIndices`) is a different thing — a selection ring in one
uniform color drawn outside the point on a scaled-up sprite — so this adds a
rendering-level stroke every point gets, derived from its own color.

Contract: with `pointStrokeWidth > 0`, every point whose on-screen diameter
is at least the stroke width gets an inset band of that width along its
edge, in a color a fixed perceptual lightness step away from its fill; with
the default width of `0` the band is never mixed in and rendering is
unchanged.

- The stroke is a second coverage band read off the same signed edge
  distance as the fill ramp — the device-pixel distance the edge ramps
  already use (radius for circles, field over its per-pixel gradient for
  polygons) — so it anti-aliases exactly like the fill and a 0.5 px stroke
  means 0.5 px at any point size and zoom, including with
  `scalePointsOnZoom`.
- The band is inset (`d ∈ [-w, 0]`) so the point never grows and the
  sprite needs no room for it.
- Points narrower than the stroke are drawn without one — they would be
  nothing but stroke.
- The stroke color is a step in OKLab lightness (`pointStrokeIntensity`),
  not a mix toward black or white: a fixed-fraction mix has no headroom on
  bright saturated fills (a yellow moved ~1% where a blue moved ~10%),
  while an OKLab step reads as the same contrast on every hue. `'auto'`
  picks the direction per point from its own lightness so the step always
  has room; hue is preserved by reducing chroma only where the shifted
  color leaves the sRGB gamut. The conversions live in a reusable luma
  `ShaderModule` (`Shared/oklab-module.ts`) and run once per point in the
  vertex stage, on the greyed color, so greyed points get a matching stroke.

Storybook: Examples/Points → Point Stroke, with a static size grid and a
simulated set of overlapping clusters plus controls for width, lightness
step, direction and background.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Nikita Rokotyan <nikita@rokotyan.com>
rokotyan and others added 3 commits September 21, 2026 13:57
…come defaults

"Stroke" promises a color you can set, and the first cut fixed it by rule
(`pointStrokeMode` / `pointStrokeIntensity`). Making the promise true means
the stroke is a per-point channel like color and size: a flat typed array
aligned to the point index space, a config default behind it, and `NaN`
resolving to that default at read time — the rule colors and sizes already
follow. So a few points can carry an explicit stroke while the rest keep the
derived shade, in one array.

- `setPointStrokeColors(Float32Array)` (RGBA) and
  `setPointStrokeWidths(Float32Array)` (CSS px), with `NaN` = default. The
  channels ride the existing transitions: colors join `PointColors`, widths
  join `PointSizes`, each with a source/target buffer pair.
- The config keys are named as the defaults they are, mirroring
  `pointDefaultColor` / `setPointColors`: `pointDefaultStrokeWidth`,
  `pointDefaultStrokeColor` — a color used as is, or one of the derivation
  rules `'auto'` | `'darken'` | `'lighten'` — and `pointStrokeContrast`, the
  OKLab lightness step the rules use (contrast says what intensity measured).
  The explicit default color is parsed once per config change in the store,
  not per frame.
- Resolution moves fully into the vertex stage: an explicit per-point color
  wins, else an explicit default color, else the shade derived from the fill;
  the resolved color and the width in device pixels reach the fragment as
  varyings, so the fragment uniform goes away. A greyed-out point always gets
  the derived shade of its greyed fill, explicit color or not, so the stroke
  fades with the point instead of staying a bright rim.

Story: a per-point overrides toggle strokes one group white and gives another
a 2 px stroke through the two setters, leaving the rest on the defaults.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Nikita Rokotyan <nikita@rokotyan.com>
…ange replayed the fill's

Toggling per-point stroke overrides flashed the fills of several clusters
through old colors. `setPointStrokeColors` queued the shared `PointColors`
transition, which turns on `animateColors` for the fill too — and the fill's
source buffer holds the colors before the fill's *last* change (the previous
layout's), not the current ones, so the fill animated from stale colors to
its unchanged target. The same would have happened to strokes on any fill
change.

Invariant: a transition property is started only by the channel whose
source buffer it interpolates. Stroke colors and widths get their own
properties (`PointStrokeColors`, `PointStrokeWidths`) and their own animate
flags through `setTransitionProgress` and the vertex uniforms; the shader
mixes each channel only under its own flag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Nikita Rokotyan <nikita@rokotyan.com>
…rence the two in the docs

The inset stroke and the selection rings (`outlinedPointIndices`, the hover
ring, the focused point) are both outline-shaped, so a reader meeting one
looks for the other. The new story puts them on one canvas: six overlapping
clumps with the stroke on every point, rings on a chosen set in one uniform
color, and a highlight toggle that shows the difference under greyout — the
greyed points keep a greyed stroke while their ring is skipped. The
`outlinedPointIndices` doc now says what it is (a selection mark drawn outside
the body in one color) and points to the stroke for a per-point edge color.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Nikita Rokotyan <nikita@rokotyan.com>

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