Concepts
Planes and queries
Fix every channel but two, then ask the plane questions. Answers come back as geometry you can draw.
A plane is two axes and a fixed rest
A color picker's 2D area, a gamut diagram and a contrast map are all the same
object: two channels of one model on x and y, every other channel held
still. definePlane describes one.
import { definePlane, planeToColor } from '@pbroom/color-kit/plane';
const plane = definePlane({ fixed: { h: 264 } });
plane.x; // → { channel: 'l', range: [0, 1] }
plane.y; // → { channel: 'c', range: [0.4, 0] }
planeToColor(plane, { x: 0.5, y: 0.5 }); // → { l: 0.5, c: 0.2, h: 264, alpha: 1 }The default is OKLCH lightness by chroma. Coordinates are normalized:
{ x: 0, y: 0 } is the start of each range and { x: 1, y: 1 } the end. The
chroma range runs [0.4, 0], so high chroma sits at y = 0, the top of an
SVG. Other models are rgb, hsl, hsv, oklab, hct and display-p3;
channel names are type-checked per model.
definePlaneFromColor fills the fixed
channels from an anchor color.
Ask with sense
sense(plane) binds the query helpers to a plane. Each
returns a typed result whose kind matches the query:
| Query | Answers |
|---|---|
gamutRegion | the in-gamut area visible in the plane window, as paths |
gamutBoundary | the max-chroma edge of a gamut, from black to white |
contrastRegion | the area that meets a contrast level against a reference |
contrastBoundary | the threshold contour on its own |
chromaBand | a tonal strip at one requested chroma |
fallbackPoint | where an out-of-gamut color lands once mapped |
gradient | evenly spaced points between two colors, projected |
gamutBoundary, contrastBoundary, contrastRegion and chromaBand answer
on OKLCH lightness-by-chroma planes and return empty geometry on others.
gamutRegion, fallbackPoint and gradient work on every model.
The same queries exist as data: runPlaneQuery
takes { kind: 'gamutRegion', gamut: 'srgb' }, which can be cached with
PlaneQueryCache, sent to a worker, or batched
through @pbroom/color-kit/compute.
SVG output
Query points are normalized plane coordinates.
toSvgPath and
toSvgCompoundPath scale them by 100 by
default, so the result drops into viewBox="0 0 100 100".
import { definePlane, sense, toSvgCompoundPath } from '@pbroom/color-kit/plane';
const region = sense(definePlane({ fixed: { h: 264 } })).gamutRegion({
gamut: 'srgb',
});
const d = toSvgCompoundPath(region.visibleRegion.paths, { closeLoop: true });
<svg viewBox="0 0 100 100">
<path d={d} />
</svg>;Tracing
inspectPlaneQuery runs a query and returns
{ result, trace }. The summary names the solver ('analytic-lc' for an
OKLCH gamut region, 'contrast-rays' for contrast), counts samples and
points, records the sampling it ran with under fidelity, and times the run
in totalTimeMs. trace.stages lists each solver step. Use it to debug a
surprising path and to budget interactive work.
Try it
The filled shape is gamutRegion for sRGB, the dashed line gamutBoundary
for Display P3, and the heavy line the contrastRegion edge for WCAG AA
against white: every in-gamut color to its left passes.
- solver
- contrast-rays
- sampling
- adaptive
- points
- 53
- dropped
- 0
import { useState } from 'react';
import { parse } from '@pbroom/color-kit';
import {
definePlane,
inspectPlaneQuery,
sense,
toSvgCompoundPath,
toSvgPath,
} from '@pbroom/color-kit/plane';
import { Range, Readout } from './controls';
const WHITE = parse('#ffffff');
export default function PlaneQueries() {
const [hue, setHue] = useState(264);
// OKLCH by default: x is lightness [0, 1], y is chroma [0.4, 0].
const plane = definePlane({ fixed: { h: hue } });
const query = sense(plane);
const srgb = query.gamutRegion({ gamut: 'srgb' });
const p3 = query.gamutBoundary({ gamut: 'display-p3' });
const { result: aa, trace } = inspectPlaneQuery(plane, {
kind: 'contrastRegion',
reference: WHITE,
level: 'AA',
});
return (
<div className="grid gap-4">
<Range
label="Hue"
value={hue}
min={0}
max={359}
step={1}
format={(v) => `${v}°`}
onChange={setHue}
/>
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
role="img"
aria-label={`Lightness by chroma plane at hue ${hue}°`}
className="h-56 w-full rounded-sm border border-border text-foreground"
>
<title>
sRGB region (filled), Display P3 boundary (dashed) and the region that
passes WCAG AA against white (outlined)
</title>
<path
d={toSvgCompoundPath(srgb.visibleRegion.paths, { closeLoop: true })}
fill="currentColor"
fillOpacity={0.12}
/>
<path
d={toSvgPath(p3.points)}
fill="none"
stroke="currentColor"
strokeDasharray="2 2"
vectorEffect="non-scaling-stroke"
/>
<path
d={toSvgCompoundPath(aa.paths)}
fill="none"
stroke="currentColor"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
/>
</svg>
<Readout
rows={[
['solver', trace.summary.solver ?? 'n/a'],
['sampling', trace.summary.samplingMode ?? 'n/a'],
['points', String(trace.summary.resultPointCount)],
['dropped', String(trace.summary.droppedPieceCount ?? 0)],
]}
/>
</div>
);
}