#Intensity and colour
#Anchors, not gradients
You do not describe a gradient. You place anchors - a sparse map of level to colour - and every level between them is interpolated:
import { createIntensityScale } from "@gravn/muscle-highlighter";
const scale = createIntensityScale({
1: "#22c55e",
5: "#eab308",
10: "#ef4444",
});
scale(1); // "#22c55e" exactly the anchor
scale(5); // "#eab308" exactly the anchor
scale(7); // interpolated between the level-5 and level-10 anchorsTwo anchors give a plain low-to-high ramp. Ten let you hand-pick every step. Anchors need not start at 1, need not be evenly spaced, and need not be contiguous.
Interpolation happens per segment, which is what makes a mid-ramp anchor actually bend the ramp rather than being averaged away. Levels outside the anchor range clamp to the nearest anchor rather than extrapolating into nonsense.
- 1
#22c55e - 2
#78c338 - 3
#a8be00 - 4
#cdb900 - 5
#eab308 - 6
#f19e00 - 7
#f68800 - 8
#f77200 - 9
#f55b28 - 10
#ef4444
#Why OKLCH
Because sRGB interpolation ruins the middle of a ramp.
Blend #22c55e and #ef4444 channel-wise in sRGB and you land on #888451 - a muddy olive.
It is not a slightly-worse green-red midpoint; it reads as a different, drabber colour entirely,
because sRGB's channels are not perceptually uniform and the path between two saturated hues dips
straight through grey.
OKLab is perceptually uniform. Interpolating there keeps chroma roughly double what sRGB manages at the same point, and rotates hue cleanly through the intermediate hues instead of collapsing through the middle. An even step in level reads as an even step in colour.
The implementation is dependency-free - about 150 lines of matrix maths in
src/color/oklch.ts. Hue takes the shortest arc around the wheel, so
green to red travels through yellow rather than the long way through blue. When one endpoint is
effectively grey its hue is meaningless, so the chromatic endpoint's hue is carried across -
otherwise fading to grey would swing through an arbitrary colour.
One honest caveat: converting back to a hex string clips out-of-gamut results per channel. The ideal midpoint orange of a green-to-red ramp falls slightly outside sRGB, so it comes back a little less saturated than pure interpolation would suggest. It still beats sRGB blending by roughly 2×.
#Driving it from real data
normalizeToIntensity maps an arbitrary measure - set volume, tonnage, a per-muscle volume view
from your backend - onto the 1–10 scale:
import { normalizeToIntensity } from "@gravn/muscle-highlighter";
const peak = Math.max(...Object.values(volumeByMuscle));
const intensities = Object.fromEntries(
Object.entries(volumeByMuscle)
.map(([code, volume]) => [code, normalizeToIntensity(volume, peak)])
.filter(([, level]) => level !== null),
);The important detail is the null. A measure of zero returns null, meaning "not worked" -
which renders as the base colour. If it returned 1 instead, every muscle in the body would light
up faintly after a single set of curls, and the map would stop meaning anything.
#Per-side intensities
Left and right are separate paths in the geometry, so unilateral work and imbalance displays need no special handling:
<MuscleMap intensities={{ BIC: { left: 9, right: 2 } }} />A region drawn as a single centred shape - the head, for instance - has no halves. A per-side value there falls back to the stronger of the two, so asking for "left head at 6" still lights the head rather than doing nothing at all.
#Legends
buildIntensityRamp materialises every integer step, which is all a legend needs:
{
buildIntensityRamp(anchors).map(({ level, color }) => (
<li key={level}>
<span style={{ background: color }} />
{level}
</li>
));
}