Plank help · updated 2026-10-29
Charts: the method
How to make a chart that is right by construction: pick the form from the job, give each colour one job, run the colour-blind validator baked at /opt/plank/design, mark sizes, hover, accessibility, and render it before you deliver.
Agents: fetch the raw markdown of this page at /en/help/charts.md
Charts: the method
A chart in a Plank deliverable is read by a person who did not make it, often on a phone, often in the dark theme, sometimes printed in black and white, and sometimes by someone who cannot tell red from green. This page is the procedure that makes a chart work for all of them by construction — every step has a check, so nothing here is a matter of taste.
Charts for dashboards is the companion: copy-ready components (KPI tile, bars, donut, line, gauge, funnel…) that already follow this method. Use them when one fits; use this page when you draw something they do not cover, change their colours, or need to judge whether a chart is right.
Colour comes last. Most bad charts start by choosing colours.
1. Pick the form from the job
Ask what the reader must take away, then pick the form that shows exactly that.
| The reader needs | Form | Not |
|---|---|---|
| One number and whether it is good | a stat tile: the number, its unit, the comparison ("+4.2% vs September"), maybe a sparkline | a chart with one bar |
| Exact values to look up or copy | a table | a chart with a number on every bar |
| Compare amounts across categories | horizontal bars, sorted, from a zero baseline | a pie, a 3-D anything |
| Change over time | a line (an area only for one series) | bars for 30+ periods |
| Parts of one total, ≤ 5 parts | a proportion bar; a donut when the total sits in the middle | a pie with 9 slices |
| Parts of a total across categories | stacked bars — at most 4 segments | stacked area of 8 series |
| How a start value became an end value | a waterfall | two bars and a paragraph |
| Above / below a reference (budget, plan, last year) | diverging bars from the reference line | two overlaid colours |
| Two measures of different scale | two charts, side by side, same x-axis — or index both to 100 | a second y-axis, ever |
| Order, handoff, what contains what | a diagram — see Diagrams | a chart |
If two forms fit, take the one with fewer marks. If the answer is a sentence ("revenue grew every month"), write the sentence and put the chart under it as evidence, not instead of it.
More than six series is never a seventh colour. Fold the tail into "Other", split into small multiples (one small chart per series, shared scale), or highlight the one that matters in --c1 and draw the rest in --c-muted.
2. Give each colour one job
There are four jobs. Decide which one each colour on the chart is doing before picking any value.
- Identity (which series is this?) — the categorical slots
--c1 … --c6, assigned in order and by entity: "Almaty" is--c2on every chart in the file, and stays--c2when a filter hides "Astana". Never pick a colour per chart, never cycle back to--c1for a seventh series. - Magnitude (how much?) — one hue, light to dark:
--seq-1 … --seq-5. Heatmaps, choropleths, a value-shaded table. Never a rainbow. - Polarity (above or below a reference?) — two hues around a neutral grey:
--div-cool-3 … --div-mid … --div-warm-3. The midpoint is grey, never a colour. - State (is it OK?) —
--st-good,--st-warn,--st-bad,--st-neutral. Reserved: never "series 4". Always with an icon and a word (Icons hascircle-check,triangle-alert,circle-x).
Text never wears a series colour. Values, labels and legend text stay in the page's ink tokens; the coloured swatch or line beside them carries the identity. Coloured text fails contrast for half the palette and turns a legend into a rainbow.
Plank's palette
Validated in both modes against the chart surface (#ffffff light, #16161d dark) and the page grounds. Slot 1 is Plank blue. The dark values are their own steps, chosen and checked against the dark surface — not an automatic inversion of the light ones.
| Token | Hue | Light | Dark |
|---|---|---|---|
--c1 | blue | #4f6df5 | #4b8eff |
--c2 | ochre | #c2701f | #c08a1f |
--c3 | teal | #0e9f9f | #028f96 |
--c4 | green | #4c7a2e | #30ae40 |
--c5 | violet | #9b5de5 | #9b5cd7 |
--c6 | rose | #c2456b | #de5077 |
Paste these three rules into the page's <style> (they are /opt/plank/design/palette.css; copy the text — a deliverable cannot link a file):
:root{--chart-surface:#ffffff;--c1:#4f6df5;--c2:#c2701f;--c3:#0e9f9f;--c4:#4c7a2e;--c5:#9b5de5;--c6:#c2456b;--c-muted:#b4b6be;--seq-1:#a2b0d8;--seq-2:#7c91cf;--seq-3:#5971c4;--seq-4:#3950b7;--seq-5:#2028aa;--div-cool-1:#92adff;--div-cool-2:#6a84d8;--div-cool-3:#465cad;--div-mid:#d6d7da;--div-warm-1:#ed9b56;--div-warm-2:#c2732a;--div-warm-3:#934f01;--st-good:#1f7a45;--st-warn:#a35a00;--st-bad:#c0392b;--st-neutral:#6b7080}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--chart-surface:#16161d;--c1:#4b8eff;--c2:#c08a1f;--c3:#028f96;--c4:#30ae40;--c5:#9b5cd7;--c6:#de5077;--c-muted:#4a4b55;--seq-1:#3f4b71;--seq-2:#55669c;--seq-3:#6b82ca;--seq-4:#839ffa;--seq-5:#adc2fe;--div-cool-1:#405395;--div-cool-2:#6379bf;--div-cool-3:#89a1ea;--div-mid:#414246;--div-warm-1:#834602;--div-warm-2:#ad6c33;--div-warm-3:#d8935b;--st-good:#4cc38a;--st-warn:#f0a93a;--st-bad:#ff6b6b;--st-neutral:#9a9aa6}}
:root[data-theme="dark"]{--chart-surface:#16161d;--c1:#4b8eff;--c2:#c08a1f;--c3:#028f96;--c4:#30ae40;--c5:#9b5cd7;--c6:#de5077;--c-muted:#4a4b55;--seq-1:#3f4b71;--seq-2:#55669c;--seq-3:#6b82ca;--seq-4:#839ffa;--seq-5:#adc2fe;--div-cool-1:#405395;--div-cool-2:#6379bf;--div-cool-3:#89a1ea;--div-mid:#414246;--div-warm-1:#834602;--div-warm-2:#ad6c33;--div-warm-3:#d8935b;--st-good:#4cc38a;--st-warn:#f0a93a;--st-bad:#ff6b6b;--st-neutral:#9a9aa6}
The first rule is light; the second follows the OS dark setting unless the page was switched to light; the third follows the theme the Plank app pushes to the file viewer as data-theme. Define the rest of the page's colours the same three-state way — HTML pages shows the pattern.
3. Run the validator — do not judge colours by eye
Whether two colours stay apart for a colour-blind reader is arithmetic, not taste. The image has the tool:
python3 -I /opt/plank/design/validate_palette.py --plank # Plank's palette, both modes
python3 -I /opt/plank/design/validate_palette.py --mode light --surface "#ffffff" "#4f6df5,#c2701f,#0e9f9f"
python3 -I /opt/plank/design/validate_palette.py --mode dark --surface "#16161d" "#4b8eff,#c08a1f,#028f96"
python3 -I /opt/plank/design/validate_palette.py --pairs all --mode light "<hex,…>" # dots, lines, map regions
python3 -I /opt/plank/design/validate_palette.py --ordinal --mode light "<ramp, light→dark>"
Run it whenever you use any colours other than the tokens above unchanged — a client's brand colours, a seventh slot, a ramp of your own — and run it once per mode with that mode's surface. What it checks, for each list:
| Check | Pass | Why |
|---|---|---|
| Lightness band | each colour in a mid band (light ≈ 0.43–0.77, dark ≈ 0.48–0.67 OKLab L) | too pale vanishes on the surface, too dark reads as text |
| Chroma floor | each colour visibly coloured | a grey slot is indistinguishable from the axes |
| Colour-blind separation | neighbours ≥ 8 apart (OKLab ×100) under protan and deutan simulation; 6–8 is a WARN | the most common colour blindness merges red/green pairs |
| Normal-vision floor | neighbours ≥ 15 apart | two "different" colours anybody would confuse |
| Contrast | each colour ≥ 3:1 against the surface; below is a WARN | a mark the eye cannot find |
| Tritan | reported, not gated | rare; noted so you can add a second channel if it is low |
--pairs all checks every pair instead of neighbours: use it when marks of any two series can touch (scatter points, crossing lines, map regions). --ordinal checks a magnitude ramp instead: one hue, lightness strictly ordered, even steps, the light end still visible.
Exit code: 0 no FAIL, 1 a FAIL, 2 bad input. A FAIL is fixed before you go on — move the colour's lightness or swap it for another hue, and re-run. A WARN is legal only with its relief: visible direct labels, a texture on one series, or a table view of the same numbers. A normal-vision FAIL has no relief — the pair is too close for anyone.
4. Draw the marks
Thin, quiet marks let the data be the loudest thing on the page.
The short way, inside Plank: python3 -I /opt/plank/design/chart.py bar|hbar|line|donut|kpi --data '{"labels":[…],"values":[…]}' --title "The finding" --unit "млн ₸" (or --csv FILE; --size slide|page|phone) prints an inline SVG that already follows this section and the next: palette tokens only, so it switches theme by itself; bars from zero with the data end rounded; values where they fit; a legend from two series; 1 240 000 and 4,2 number format; a <title> and a <desc> that reads the figures out. It passes plank_deck_qa.py's diagram checks. Hover and the phone-width redraw below are still yours when the page is interactive.
No figures given? Never invent them. chart.py <kind> --example --title "The claim" (--data becomes optional) draws the same chart on plausible example data with a visible badge «пример — замените своими данными» and data-plank-example="1" on the root <svg>. plank_check_canvas lists those boards (WARN example-data); name them in the reply («на слайдах 4 и 6 — пример, замените своими цифрами») and never remove the badge while the numbers are still examples. Every chart's root carries data-plank-visual="chart", which is how the deck check counts a slide as carrying a visual (text-only-deck, Design canvas).
- Bars: thickness ≈ 60% of the slot, never more than 24px. Round only the end that carries the value (radius 4px); the end on the baseline stays square. Bars start at zero, always.
- Gaps: 2px of surface colour between adjacent filled shapes — stacked segments, neighbouring bars, donut slices. Draw it as a stroke in
var(--chart-surface), not as transparent space. Overlapping dots get a 2px ring in the same colour. - Lines: 2px, round joins. Markers, when used, ≥ 8px across. An area under a single line is a ~10% wash of the line's colour.
- Grid and axes: recessive. Solid 1px hairlines in a grid token, horizontal only for bars and lines; no chart border, no tick on every gridline, no axis line where the grid already shows zero. Axis labels in the muted ink token, 11–12px, numbers in tabular figures (
font-variant-numeric: tabular-nums). - Phone width: an SVG scales its text with the drawing, so a 640-wide
viewBoxon a 340px phone turns 11px axis labels into 6px ones. Draw the chart at the width it actually has — read the container'sclientWidthand build the SVG for it, again onresize(the hover script already runs, so this costs nothing) — and drop to fewer ticks or a horizontal bar layout when narrow. A table beside a chart scrolls inside its ownoverflow-x:autobox, never the page. - Labels: selective. Label the last point of each line, the largest bar, the one value the heading talks about — not every mark. A number on every bar is a table drawn badly.
- Units and formats: Kazakh/Russian readers expect a space as the thousands separator and a comma for decimals (
12 450,5), and the currency after the number (1,2 млн ₸). UseIntl.NumberFormat('ru-RU')and put the unit in the axis title or the heading once, not on every tick. - Heading = the finding. "Payroll is 54% of September spend", not "Spend by category".
5. Make every mark answer on hover
An HTML chart is interactive by default. Every plotted mark carries its exact value, label and unit in a tooltip — one shared tooltip element, one listener, data-tip on each mark (the dashboard skeleton has it). Lines and areas get a vertical crosshair that snaps to the nearest x and lists every series at that x. Make the hover target larger than the mark: an invisible full-height rect per bar slot, an invisible 12px-wide stroke over a 2px line. On a phone a tap shows the tooltip; nothing important may live only in a hover.
Filters (period, branch, category) sit in one row above the charts they affect, and changing one never repaints the surviving series in new colours — identity follows the entity (step 2).
Only a bare stat tile with no plot skips this step.
6. Accessibility pass
- Never colour alone. ≥ 2 series → a legend, always; ≤ 4 series → also a direct label at the end of each line or on each segment. One series → no legend; the heading names it.
- A table view. Every chart offers the same numbers as a table — a
<details>under it is enough. It is also what a screen reader reads: give the<svg>role="img"and anaria-labelstating the finding, and put the table in the page. - Dark mode is its own design. Check the chart in both themes, not just the light one; use the dark tokens, never
filter: invert(). - Texture for print. If the deliverable may be printed, or a pair only WARNs, give one series a diagonal hatch (an SVG
<pattern>at 45°, 1.5px lines, in the series colour) so it survives greyscale. - Reduced motion. Animate nothing that carries data; respect
prefers-reduced-motionfor any transition you add.
7. Render it and look
The validator checks colour, not layout. Render the page and look at it before you deliver — both themes and a phone width:
chrome-headless-shell --no-sandbox --hide-scrollbars --window-size=1200,900 \
--screenshot=/tmp/chart-light.png "file://$PWD/report.html"
chrome-headless-shell --no-sandbox --hide-scrollbars --window-size=1200,900 \
--blink-settings=preferredColorScheme=0 --screenshot=/tmp/chart-dark.png "file://$PWD/report.html"
chrome-headless-shell --no-sandbox --hide-scrollbars --window-size=390,844 \
--screenshot=/tmp/chart-phone.png "file://$PWD/report.html"
Then read the PNGs. Look for: labels that collide or are clipped, a legend that wraps under the chart, a tooltip that would cover the mark it explains, a bar that does not start at zero, text that disappears in dark mode, a page that scrolls sideways on the phone. A board on a canvas is checked by plank_check_canvas instead (Canvas).
What goes wrong — check every chart against this
- Two y-axes. Any two lines can be made to "correlate" by choosing the scales. Two charts, or index both to 100.
- A rainbow ramp for a quantity. Lightness must carry the order; hue cannot.
- Colours by rank — the biggest series is always blue, so "Almaty" changes colour when the month changes.
- A seventh generated colour. Fold, split, or highlight instead.
- Red/green as the only difference between good and bad. Add the icon and the word.
- Status colours as series colours. Red as "series 3" reads as an alarm.
- Coloured text for values and legends.
- A pie with more than five slices, a 3-D chart, a donut used for a single number.
- Bars from a non-zero baseline. It turns a 3% difference into a 3× one.
- A number on every mark, and a gridline under each.
- Thick bars, heavy borders, drop shadows, gradients on data marks.
- Hover-only information with no table view.
- The dark theme as an inversion — colours that were never checked against the dark surface.
- Never rendered. The chart that "should look fine" is the one with the clipped legend.
Non-negotiables
- Form from the job; a stat tile or a table when that is the answer.
- One y-axis.
- Categorical colours in fixed order, by entity, ≤ 6; never a generated seventh.
- Magnitude = one hue light→dark; polarity = two hues around grey.
- Status colours are reserved and always come with an icon and a word.
- Any palette other than the tokens above unchanged goes through
validate_palette.py, in both modes, before it ships. A FAIL is fixed; a WARN gets its relief. - Text wears ink tokens, never a series colour.
- A legend for ≥ 2 series, a tooltip on every mark, a table view.
- Both themes and a phone width, rendered and looked at.