#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 anchors

Two 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. 1#22c55e
  2. 2#78c338
  3. 3#a8be00
  4. 4#cdb900
  5. 5#eab308
  6. 6#f19e00
  7. 7#f68800
  8. 8#f77200
  9. 9#f55b28
  10. 10#ef4444
The default ramp, generated from the package's own scale. Anchors at 1, 5 and 10; the rest interpolated.

#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>
  ));
}