Skip to content

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

Draft
rokotyan wants to merge 1 commit into
mainfrom
feat/point-stroke
Draft

rokotyan wants to merge 1 commit into
mainfrom
feat/point-stroke

Conversation

@rokotyan

@rokotyan rokotyan commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

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

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

Key Meaning Default
pointStrokeWidth Stroke width in CSS px, inset so the point never grows; constant across zoom. Points narrower than the width get no stroke. 0
pointStrokeIntensity Step in OKLab lightness L (0..1) between fill and stroke. 0.1
pointStrokeMode 'auto' (per point: darken light points, lighten dark ones), 'darken', 'lighten'. 'auto'

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 signed distance as the fill, converted to device pixels, so it anti-aliases like the fill; all shape SDFs now have unit gradient (the diamond was normalised, its edge does not move).
  • With the stroke on, the fill edge is anti-aliased in pixel space (constant 1 px band). With it off, the original edge code runs 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 width, lightness step, direction and light/dark background.

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

Screenshots

Stroke width 0.75 px, lightness step 0.10, 'auto' direction, 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 modes work; the small-point cutoff shows at the grid's left edge; width 0 restores the original rendering; no console errors.

🤖 Generated with Claude Code

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` rendering is byte-identical to before.

- The stroke is a second coverage band read off the same signed distance
  as the fill, converted to device pixels (`sd * shapeSize / 2`), so it
  anti-aliases like the fill and a 0.5 px stroke means 0.5 px at any point
  size and zoom, including with `scalePointsOnZoom`. Every shape SDF has
  unit gradient for that conversion to be exact; the diamond's L1 form was
  the one exception and is normalised (its edge does not move).
- The band is inset (`d ∈ [-w, 0]`) so the point never grows and the
  sprite needs no padding; the fill's edge sits half a pixel inside the
  nominal boundary so the anti-alias band cannot cross the sprite bounds.
- 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.
- When the stroke is on, the fill's edge is also anti-aliased in pixel
  space (a constant 1 px band): a pixel-width stroke is invisible inside a
  size-proportional blur on large points. With the stroke off the original
  edge code runs unchanged.

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>
@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.

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