diff --git a/.serena/memories/style_guide.md b/.serena/memories/style_guide.md index a00ee203b9..bc5036bb25 100644 --- a/.serena/memories/style_guide.md +++ b/.serena/memories/style_guide.md @@ -15,24 +15,29 @@ Frontend tokens: `app/src/theme/index.ts` + `app/src/main.tsx` (MUI theme). - **Exception**: ErrorBoundary uses raw `'monospace'` (crash-safe fallback). - `fontSize.micro` (0.5rem) and `fontSize.xxs` (0.625rem) restricted to data-dense pages (StatsPage, DebugPage). Public pages: `fontSize.xs` (0.75rem) minimum. -## Color — Okabe-Ito palette -Colorblind-safe categorical palette; first 7 fixed across themes, 8th is adaptive neutral. +## Color — imprint palette +Colourblind-safe categorical palette (8 hues, hybrid-v3 sort) plus 3 semantic anchors outside the pool. All 8 categorical slots stay fixed across themes; the neutral and muted anchors flip per theme. Full rationale: `docs/reference/palette-variants-v3/decision-rationale.md`. ``` -1 #009E73 bluish green ★ brand -2 #D55E00 vermillion (errors, destructive) -3 #0072B2 blue (info links, footnotes) -4 #CC79A7 reddish purple PLOT-ONLY -5 #E69F00 orange ("new" badges, secondary hover) -6 #56B4E9 sky blue PLOT-ONLY -7 #F0E442 yellow PLOT-ONLY -8 adaptive neutral: #1A1A1A light / #E8E8E0 dark +slot 0 #009E73 brand green ★ first series — logo, primary CTAs +slot 1 #C475FD lavender (creative / artistic) PLOT-ONLY +slot 2 #4467A3 blue (info links, footnotes) +slot 3 #BD8233 ochre (hover accent, earth / commodity) +slot 4 #AE3030 matte red (errors, destructive — deferred semantic anchor) +slot 5 #2ABCCD cyan (sky / tech-cool) PLOT-ONLY +slot 6 #954477 rose (wellness / health) PLOT-ONLY +slot 7 #99B314 lime (growth / nature) PLOT-ONLY + +semantic anchors (outside the categorical pool): + amber #DDCC77 warning / caution (fixed) + neutral #1A1A17 / #F0EFE8 totals / baseline / outline (theme-adaptive) + muted #6B6A63 / #A8A79F other / rest / disabled (theme-adaptive) ``` -**Brand green `#009E73` appears in UI ONLY in 8 contexts:** logo dot, italic accent words in headlines, hero terminal cursor, active nav item (dot prefix + underline), code-action button hover, code-block syntax (strings), palette strip, small status indicators. +**Brand green `#009E73` appears in UI ONLY in 8 contexts:** logo dot, italic accent words in headlines, hero terminal cursor, active nav item (dot prefix + underline), code-action button hover, code-block syntax (comments), palette strip, small status indicators. **Never**: backgrounds, regular card borders, body text emphasis, non-logo icons, static decorative dots. -**Plot-only colors** (purple/sky/yellow) must never appear in UI chrome. +**Plot-only colours** (lavender, cyan, rose, lime) must never appear in UI chrome — they are reserved for data marks so categorical plots retain visual impact. ## Surfaces — warm, not clinical Pure `#FFFFFF` is out — makes saturated palette colors look harsh. @@ -70,15 +75,15 @@ Highlight treatments: `colors.highlight.bg`/`colors.highlight.text`, `colors.too ## Section-header pattern (shell-prompt prefixes) - `❯` navigation/categorical · `$` action/list · `~/path/` hierarchical/about -- Title in italic + ss02 (script), `--ok-green`, underlined with 1px `--rule`. +- Title in italic + ss02 (script), `--imprint-green`, underlined with 1px `--rule`. ## Buttons — method-call doctrine -- **Action** (primary affordance): `.copy()`, `.open()`, `.download()` — `::before { content: "." }`, hover flips to `--ok-green`. +- **Action** (primary affordance): `.copy()`, `.open()`, `.download()` — `::before { content: "." }`, hover flips to `--imprint-green`. - **Hero CTA** (filled, landing only): dark pill, 4px radius, hover → green. - **Ghost** (rare): transparent, `--rule` border. ## Logo `any.plot()` -MonoLisa Bold, letters `--ink`, `.` `--ok-green` scaled 1.3× (circle), `()` `--ink` weight 400 at 45% opacity. Favicon reduces to `a.p`. Clear space `1em`. +MonoLisa Bold, letters `--ink`, `.` `--imprint-green` scaled 1.3× (circle), `()` `--ink` weight 400 at 45% opacity. Favicon reduces to `a.p`. Clear space `1em`. ## Voice Precise · understated · curious · respectful · slightly playful · code-native · AI-honest. @@ -92,6 +97,6 @@ Gradients (esp. purple-blue SaaS), glass/backdrop-blur, isometric illustrations, ## Plot defaults - First series **always** `#009E73`. - Neutral (pos 8) reserved for aggregates/residuals/reference lines. -- Yellow `#F0E442` poor on white — position 7+ only, never thin lines/small markers. -- Non-categorical: sequential → `viridis`/`cividis`; diverging → `BrBG`; heatmaps → `viridis` or single-polarity `Reds`/`Blues`. +- 5 lighter members (lavender, ochre, cyan, lime, amber) fall under WCAG 3:1 on cream bg. Add a 1px ink stroke on affected series when the chart is small or accessibility-strict — see outline pattern in `docs/reference/palette-variants-v3/decision-rationale.md`. +- Non-categorical: sequential → `imprint_seq` (green→blue); diverging → `imprint_div` (red↔theme-adaptive-midpoint↔blue). Never substitute viridis/cividis/BrBG/jet/hsv/rainbow — palette identity is part of the brand. - Plot-internal typography (ticks/labels/legends): MonoLisa, 10–13px. diff --git a/app/src/components/CodeHighlighter.tsx b/app/src/components/CodeHighlighter.tsx index 51b475c8c1..dba7089782 100644 --- a/app/src/components/CodeHighlighter.tsx +++ b/app/src/components/CodeHighlighter.tsx @@ -16,10 +16,10 @@ const PRISM_LANGUAGE: Record = { julia: 'julia', }; -// Theme-aware Okabe-Ito syntax theme. All colors come from CSS variables in +// Theme-aware imprint syntax theme. All colors come from CSS variables in // tokens.css so the block adapts to light (paper) and dark modes. Comments // use the brand green as a deliberate editorial accent. -const okabeItoTheme: Record = { +const imprintTheme: Record = { 'pre[class*="language-"]': { color: 'var(--code-text)', background: 'var(--code-bg)', @@ -65,7 +65,7 @@ export default function CodeHighlighter({ code, language = 'python' }: CodeHighl return ( Same palette,
every library. @@ -39,7 +39,7 @@ export function CodeShowcase() { color: 'var(--ink-soft)', mb: 2.5, }}> - every example in the catalogue uses the same Okabe-Ito palette. switch libraries + every example in the catalogue uses the same imprint palette. switch libraries without losing your color grammar — a gentoo penguin is always blue, whether you draw it in matplotlib or plotly. @@ -79,40 +79,40 @@ export function CodeShowcase() { {'# pick any library. the palette travels with you.\n'} - import{' anyplot '} - as{' ap\n\n'} + import{' anyplot '} + as{' ap\n\n'} {'data = ap.'} - load + load {'('} - "penguins" + "penguins" {')\n\n'} {'# matplotlib\n'} {'ap.'} - mpl + mpl {'.'} - scatter + scatter {'(data, x='} - "bill" + "bill" {', y='} - "flipper" + "flipper" {',\n hue='} - "species" + "species" {')\n\n'} {'# plotly — same colors, interactive\n'} {'ap.'} - plotly + plotly {'.'} - scatter + scatter {'(data, x='} - "bill" + "bill" {', y='} - "flipper" + "flipper" {',\n hue='} - "species" + "species" {')'} diff --git a/app/src/components/PaletteStrip.tsx b/app/src/components/PaletteStrip.tsx index ec081d23fb..06839e7d08 100644 --- a/app/src/components/PaletteStrip.tsx +++ b/app/src/components/PaletteStrip.tsx @@ -1,8 +1,9 @@ import Box from '@mui/material/Box'; -const SWATCHES = [ - '#009E73', '#D55E00', '#0072B2', '#CC79A7', - '#E69F00', '#56B4E9', '#F0E442', 'var(--ink)', +// imprint palette — 8 categorical hues in hybrid-v3 sort order +const DEFAULT_SWATCHES = [ + '#009E73', '#C475FD', '#4467A3', '#BD8233', + '#AE3030', '#2ABCCD', '#954477', '#99B314', ]; interface PaletteStripProps { @@ -12,9 +13,12 @@ interface PaletteStripProps { height?: number; /** Top margin (MUI spacing units, default 5). */ mt?: number; + /** Override the default 8-hex imprint set (e.g. to render an alternate sort). */ + hexes?: string[]; } -export function PaletteStrip({ maxWidth = 400, height = 40, mt = 5 }: PaletteStripProps = {}) { +export function PaletteStrip({ maxWidth = 400, height = 40, mt = 5, hexes }: PaletteStripProps = {}) { + const SWATCHES = hexes ?? DEFAULT_SWATCHES; return ( - A palette proposed as unambiguous to both colorblind and non-colorblind - viewers, with vivid colors that stay recognizable on screen and in print. + A palette unambiguous to colourblind and non-colourblind viewers alike, + warm-tinted to stay legible on screen and in print. - — Okabe & Ito, Color Universal Design (2008) + — anyplot imprint, design rationale diff --git a/app/src/pages/AboutPage.tsx b/app/src/pages/AboutPage.tsx index 739078ad34..c0f9d6d0c6 100644 --- a/app/src/pages/AboutPage.tsx +++ b/app/src/pages/AboutPage.tsx @@ -95,10 +95,12 @@ export function AboutPage() { palette} /> - every plot uses the Okabe-Ito palette, designed to stay distinguishable under the main - forms of color vision deficiency. Masataka Okabe and Kei Ito published it on the Color - Universal Design page in 2002 (revised 2008). about 8% of men have some form of color - vision deficiency — most plotting libraries ignore this entirely. we make it the default. + every plot uses imprint, our own colourblind-safe categorical palette — + 8 hues plus 3 semantic anchors, tuned for warm-paper rendering and validated against the + three main forms of colour vision deficiency. it sits in the same neighbourhood as + Okabe-Ito, Paul Tol's “muted”, and ColorBrewer Set2 — about 8% of men + have some form of CVD, and most plotting libraries ignore this entirely. we make it + the default. see the{' '} diff --git a/app/src/pages/LandingPage.test.tsx b/app/src/pages/LandingPage.test.tsx index 062e35a5c7..a8dd0e17a8 100644 --- a/app/src/pages/LandingPage.test.tsx +++ b/app/src/pages/LandingPage.test.tsx @@ -104,15 +104,12 @@ describe('LandingPage', () => { expect(trackEvent).toHaveBeenCalledWith('nav_click', { source: 'specs_more_link', target: '/specs' }); }); - it('tracks the suggest_spec link and the okabe-ito reference', async () => { + it('tracks the suggest_spec link', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByText(/suggest/)); expect(trackEvent).toHaveBeenCalledWith('nav_click', expect.objectContaining({ source: 'suggest_spec_link' })); - - await user.click(screen.getByText(/Okabe/)); - expect(trackEvent).toHaveBeenCalledWith('nav_click', expect.objectContaining({ source: 'palette_okabe_ito' })); }); it('tracks the map teaser visual click', async () => { diff --git a/app/src/pages/LandingPage.tsx b/app/src/pages/LandingPage.tsx index b64f211c7b..96620105f9 100644 --- a/app/src/pages/LandingPage.tsx +++ b/app/src/pages/LandingPage.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Helmet } from 'react-helmet-async'; import Box from '@mui/material/Box'; import { Link as RouterLink, useNavigate } from 'react-router-dom'; @@ -80,7 +80,7 @@ export function LandingPage() { * left, decorative SVG cluster preview on the right. The preview is purely * static (no data fetch, no force simulation) so it stays cheap on the * landing page; it only hints at the real map's clustering aesthetic using - * the same Okabe-Ito palette. + * the same imprint palette. */ function MapSection({ specCount }: { specCount?: number }) { const { trackEvent } = useAnalytics(); @@ -157,8 +157,8 @@ function MapSection({ specCount }: { specCount?: number }) { } /** - * Decorative SVG mini-cluster — three loose groups of circles in Okabe-Ito - * cluster colours, connected by hairline edges. Static (no force simulation, + * Decorative SVG mini-cluster — three loose groups of circles in the + * imprint cluster colours, connected by hairline edges. Static (no force simulation, * no data fetch) so it's cheap to render; the aspect ratio matches the * featured-thumb cards (16:10) for visual rhythm. Positions are hand-picked * to read as "three blobs gently bridged" — like the real map at low zoom. @@ -171,7 +171,7 @@ const MAP_PREVIEW_NODES: Array<{ x: number; y: number; r: number; cluster: 0 | 1 { x: 95, y: 160, r: 6, cluster: 0 }, { x: 135, y: 95, r: 6, cluster: 0 }, { x: 60, y: 100, r: 5, cluster: 0 }, - // Cluster B — top-right, vermillion + // Cluster B — top-right, matte red { x: 320, y: 70, r: 9, cluster: 1 }, { x: 350, y: 100, r: 7, cluster: 1 }, { x: 295, y: 105, r: 6, cluster: 1 }, @@ -200,7 +200,7 @@ const MAP_PREVIEW_LINKS: Array<[number, number]> = [ [2, 16], [16, 8], [3, 17], [17, 11], [16, 17], [17, 18], [18, 3], ]; -const CLUSTER_PALETTE = ['#009E73', '#D55E00', '#0072B2'] as const; +const CLUSTER_PALETTE = ['#009E73', '#AE3030', '#4467A3'] as const; function MapClusterPreview() { return ( @@ -248,7 +248,6 @@ function MapClusterPreview() { * the left, labelled palette strip on the right. */ function PaletteSection() { - const { trackEvent } = useAnalytics(); return ( palette} linkText="palette.explore()" linkTo="/palette" /> @@ -272,24 +271,10 @@ function PaletteSection() { maxWidth: '52ch', }} > - a colourblind-safe set of eight, proposed by{' '} - trackEvent('nav_click', { source: 'palette_okabe_ito', target: 'jfly_uni_koeln' })} - sx={{ - color: 'var(--ink)', - textDecoration: 'none', - borderBottom: '1px dotted currentColor', - '&:hover': { color: colors.primary }, - }} - > - Okabe & Ito (2002, rev. 2008) - - . every plot in the catalogue picks from it — and so does this - site, accent for accent. + imprint, a colourblind-safe set of eight plus three semantic anchors. + tuned for warm-paper rendering, validated against the three main forms of colour vision + deficiency. every plot in the catalogue picks from it — and so does this site, accent + for accent. @@ -300,17 +285,32 @@ function PaletteSection() { } const PALETTE = [ - { background: '#009E73', hex: '#009E73', name: 'bluish green' }, - { background: '#D55E00', hex: '#D55E00', name: 'vermillion' }, - { background: '#0072B2', hex: '#0072B2', name: 'blue' }, - { background: '#CC79A7', hex: '#CC79A7', name: 'reddish purple' }, - { background: '#E69F00', hex: '#E69F00', name: 'orange' }, - { background: '#56B4E9', hex: '#56B4E9', name: 'sky blue' }, - { background: '#F0E442', hex: '#F0E442', name: 'yellow' }, - { background: 'var(--ink)', hex: 'adaptive', name: 'ink' }, + { background: '#009E73', hex: '#009E73', name: 'brand green' }, + { background: '#C475FD', hex: '#C475FD', name: 'lavender' }, + { background: '#4467A3', hex: '#4467A3', name: 'blue' }, + { background: '#BD8233', hex: '#BD8233', name: 'ochre' }, + { background: '#AE3030', hex: '#AE3030', name: 'matte red' }, + { background: '#2ABCCD', hex: '#2ABCCD', name: 'cyan' }, + { background: '#954477', hex: '#954477', name: 'rose' }, + { background: '#99B314', hex: '#99B314', name: 'lime' }, ] as const; function LabelledPaletteStrip() { + const [copiedHex, setCopiedHex] = useState(null); + const handleCopy = (hex: string) => { + // Clipboard API can throw (insecure context, denied permission, unsupported + // browser). Wrap so we only flip the "copied" state when the write actually + // succeeded, and we don't leak an unhandled promise rejection on failure. + void navigator.clipboard + .writeText(hex) + .then(() => { + setCopiedHex(hex); + setTimeout(() => setCopiedHex((c) => (c === hex ? null : c)), 1500); + }) + .catch(() => { + // Silently ignore — the user can still read the hex on the swatch. + }); + }; return ( - {PALETTE.map(({ background, hex }) => ( - + {PALETTE.map(({ background, hex }) => { + const copied = copiedHex === hex; + return ( handleCopy(hex)} + className="swatch" sx={{ - fontFamily: typography.mono, + flex: 1, + height: 120, + background, + transition: 'flex 0.3s', + position: 'relative', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + border: 'none', + padding: 0, + cursor: 'pointer', + '& .hex-label': { opacity: copied ? 1 : 0, transition: 'opacity 0.2s' }, + '&:hover .hex-label': { opacity: 1 }, + }} + > + - {hex} + {copied ? 'copied ✓' : hex} - ))} + ); + })} {PALETTE.map(({ background, name }) => ( diff --git a/app/src/pages/MapPage.tsx b/app/src/pages/MapPage.tsx index cc3314ddf3..7ad267d75d 100644 --- a/app/src/pages/MapPage.tsx +++ b/app/src/pages/MapPage.tsx @@ -102,19 +102,19 @@ const visuallyHiddenSx = { border: 0, }; -// Top-N most frequent plot_types each get a distinct Okabe-Ito border color +// Top-N most frequent plot_types each get a distinct imprint border color // so the catalog's biggest categories (line, scatter, bar, …) stand out at // a glance. Specs that don't fall into the top-N keep a neutral border. -// The palette has 7 categorical colors + an adaptive neutral as the 8th — -// here we use the 7 categorical ones; everything else stays uncolored. +// 8 categorical hues in imprint's hybrid-v3 sort order. const CLUSTER_COLORS = [ - '#009E73', // brand green - '#D55E00', // vermillion - '#0072B2', // blue - '#CC79A7', // reddish purple - '#E69F00', // orange - '#56B4E9', // sky blue - '#F0E442', // yellow + '#009E73', // slot 0 — brand green + '#C475FD', // slot 1 — lavender + '#4467A3', // slot 2 — blue + '#BD8233', // slot 3 — ochre + '#AE3030', // slot 4 — matte red + '#2ABCCD', // slot 5 — cyan + '#954477', // slot 6 — rose + '#99B314', // slot 7 — lime ] as const; function colorFor(bucket: string | null, topTypes: string[]): string | null { diff --git a/app/src/pages/PalettePage.tsx b/app/src/pages/PalettePage.tsx index e14b653564..ea87593f5f 100644 --- a/app/src/pages/PalettePage.tsx +++ b/app/src/pages/PalettePage.tsx @@ -1,83 +1,796 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Helmet } from 'react-helmet-async'; import Box from '@mui/material/Box'; -import { PaletteStrip } from '../components/PaletteStrip'; +import Collapse from '@mui/material/Collapse'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import IconButton from '@mui/material/IconButton'; +import Switch from '@mui/material/Switch'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import Tooltip from '@mui/material/Tooltip'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { SectionHeader } from '../components/SectionHeader'; import { useAnalytics } from '../hooks'; import { colors, typography, textStyle } from '../theme'; -const SWATCHES = [ - { name: 'brand (bluish green)', hex: '#009E73', role: 'first series, primary CTAs, brand anchor' }, - { name: 'vermillion', hex: '#D55E00', role: 'second series, error states, warm contrast' }, - { name: 'blue', hex: '#0072B2', role: 'third series, informational links, cool anchor' }, - { name: 'reddish purple', hex: '#CC79A7', role: 'fourth series, distinctive accent' }, - { name: 'orange', hex: '#E69F00', role: 'fifth series, highlights, hover states' }, - { name: 'sky blue', hex: '#56B4E9', role: 'sixth series, info states' }, - { name: 'yellow', hex: '#F0E442', role: 'seventh series — poor on white, use sparingly' }, - { name: 'neutral (adaptive)', hex: '#1A1A1A / #E8E8E0', role: 'text, gridlines, totals — adapts to theme' }, +// ───────────────────────────────────────────────────────────────────────────── +// Imprint palette — 8 categorical hues + OKLCH coords for the wheel +// ───────────────────────────────────────────────────────────────────────────── + +type Swatch = { + hex: string; + name: string; + family: string; + role: string; + L: number; + C: number; + H: number; + oklch: string; + /** Per-hex min ΔE to any other imprint member under normal vision. */ + minNorm: number; + /** Per-hex min ΔE to any other imprint member under worst CVD sim (deuter / protan / tritan). */ + minCvd: number; + /** WCAG contrast on cream bg #FAF8F1. */ + wcagL: number; + /** WCAG contrast on warm near-black bg #121210. */ + wcagD: number; +}; + +const PALETTE: Swatch[] = [ + { hex: '#009E73', name: 'brand green', family: 'green', role: 'first series', L: 0.620, C: 0.130, H: 165.5, oklch: 'oklch(0.620 0.130 165.5)', minNorm: 22.51, minCvd: 13.70, wcagL: 3.08, wcagD: 5.48 }, + { hex: '#C475FD', name: 'lavender', family: 'purple', role: 'creative', L: 0.704, C: 0.202, H: 308.9, oklch: 'oklch(0.704 0.202 308.9)', minNorm: 30.03, minCvd: 13.81, wcagL: 2.59, wcagD: 6.53 }, + { hex: '#4467A3', name: 'blue', family: 'blue', role: 'cool / info', L: 0.516, C: 0.104, H: 260.6, oklch: 'oklch(0.516 0.104 260.6)', minNorm: 32.35, minCvd: 10.70, wcagL: 5.09, wcagD: 3.32 }, + { hex: '#BD8233', name: 'ochre', family: 'yellow', role: 'earth / commodity', L: 0.652, C: 0.118, H: 70.5, oklch: 'oklch(0.652 0.118 70.5)', minNorm: 24.00, minCvd: 8.81, wcagL: 2.95, wcagD: 5.72 }, + { hex: '#AE3030', name: 'matte red', family: 'red', role: 'bad / loss / error', L: 0.503, C: 0.163, H: 25.2, oklch: 'oklch(0.503 0.163 25.2)', minNorm: 20.73, minCvd: 15.20, wcagL: 5.79, wcagD: 2.92 }, + { hex: '#2ABCCD', name: 'cyan', family: 'cyan', role: 'sky / tech-cool', L: 0.730, C: 0.117, H: 207.1, oklch: 'oklch(0.730 0.117 207.1)', minNorm: 22.51, minCvd: 13.70, wcagL: 2.06, wcagD: 8.19 }, + { hex: '#954477', name: 'rose', family: 'purple', role: 'wellness / health', L: 0.508, C: 0.125, H: 343.9, oklch: 'oklch(0.508 0.125 343.9)', minNorm: 20.73, minCvd: 10.70, wcagL: 5.61, wcagD: 3.01 }, + { hex: '#99B314', name: 'lime', family: 'green', role: 'growth / nature', L: 0.722, C: 0.167, H: 119.8, oklch: 'oklch(0.722 0.167 119.8)', minNorm: 24.00, minCvd: 8.81, wcagL: 2.15, wcagD: 7.87 }, +]; + +/** Pure-CVD-greedy max-min sort — picks each next slot to maximise ΔE under CVD + * against the already-placed set. Best discrimination at n=3..6 but groups + * two greens (slot 7 lime + slot 0 brand) and two purples (slot 1 lavender + + * slot 3 rose) close together. Order: [brand, lavender, lime, rose, red, cyan, blue, ochre]. */ +const CVD_OPTIMAL_ORDER: number[] = [0, 1, 7, 6, 4, 5, 2, 3]; + +type CompPalette = { id: string; name: string; href?: string; hexes: { hex: string; L: number; C: number; H: number }[] }; + +const OKABE_ITO: CompPalette = { + id: 'okabe', + name: 'Okabe-Ito', + href: 'https://jfly.uni-koeln.de/color/', + hexes: [ + { hex: '#009E73', L: 0.620, C: 0.130, H: 165.5 }, + { hex: '#D55E00', L: 0.621, C: 0.170, H: 47.5 }, + { hex: '#0072B2', L: 0.532, C: 0.131, H: 244.0 }, + { hex: '#CC79A7', L: 0.679, C: 0.118, H: 346.3 }, + { hex: '#E69F00', L: 0.753, C: 0.158, H: 76.8 }, + { hex: '#56B4E9', L: 0.735, C: 0.117, H: 236.2 }, + { hex: '#F0E442', L: 0.902, C: 0.172, H: 105.0 }, + ], +}; + +const TOL_MUTED: CompPalette = { + id: 'tol', + name: 'Tol muted', + href: 'https://personal.sron.nl/~pault/', + hexes: [ + { hex: '#332288', L: 0.347, C: 0.159, H: 281.8 }, + { hex: '#88CCEE', L: 0.812, C: 0.084, H: 230.8 }, + { hex: '#44AA99', L: 0.674, C: 0.098, H: 180.4 }, + { hex: '#117733', L: 0.499, C: 0.135, H: 148.6 }, + { hex: '#999933', L: 0.663, C: 0.123, H: 109.3 }, + { hex: '#DDCC77', L: 0.841, C: 0.108, H: 98.3 }, + { hex: '#CC6677', L: 0.635, C: 0.130, H: 11.0 }, + { hex: '#882255', L: 0.432, C: 0.145, H: 354.5 }, + { hex: '#AA4499', L: 0.553, C: 0.167, H: 334.6 }, + ], +}; + +const SET2: CompPalette = { + id: 'set2', + name: 'ColorBrewer Set2', + href: 'https://colorbrewer2.org', + hexes: [ + { hex: '#66C2A5', L: 0.749, C: 0.099, H: 170.8 }, + { hex: '#FC8D62', L: 0.755, C: 0.147, H: 41.5 }, + { hex: '#8DA0CB', L: 0.707, C: 0.067, H: 266.1 }, + { hex: '#E78AC3', L: 0.748, C: 0.132, H: 343.3 }, + { hex: '#A6D854', L: 0.821, C: 0.169, H: 127.3 }, + { hex: '#FFD92F', L: 0.892, C: 0.173, H: 95.2 }, + { hex: '#E5C494', L: 0.837, C: 0.073, H: 76.8 }, + { hex: '#B3B3B3', L: 0.767, C: 0.000, H: 89.9 }, + ], +}; + +const ANYPLOT_PREV: CompPalette = { + id: 'prev', + name: 'anyplot (previous)', + hexes: [ + { hex: '#009E73', L: 0.620, C: 0.130, H: 165.5 }, + { hex: '#9418DB', L: 0.529, C: 0.259, H: 307.4 }, + { hex: '#B71D27', L: 0.503, C: 0.187, H: 24.7 }, + { hex: '#16B8F3', L: 0.735, C: 0.145, H: 231.0 }, + { hex: '#99B314', L: 0.722, C: 0.167, H: 119.8 }, + { hex: '#D359A7', L: 0.643, C: 0.176, H: 344.0 }, + { hex: '#BA843E', L: 0.654, C: 0.109, H: 70.8 }, + ], +}; + +const COMPARISONS: CompPalette[] = [OKABE_ITO, TOL_MUTED, SET2, ANYPLOT_PREV]; + +type Anchor = { + key: string; + hexLight: string; + hexDark?: string; + role: string; + hint: string; +}; + +const ANCHORS: Anchor[] = [ + { + key: 'amber', + hexLight: '#DDCC77', + role: 'warning / caution', + hint: 'Chosen for max ΔE under CVD against lime — distinct under deuteranopia (the two more saturated amber options collapse against lime).', + }, + { + key: 'neutral', + hexLight: '#1A1A17', + hexDark: '#F0EFE8', + role: 'totals / baseline / outline', + hint: 'Theme-adaptive ink. Same hex as text and gridlines — the series reads as part of the chart\'s structural layer.', + }, + { + key: 'muted', + hexLight: '#6B6A63', + hexDark: '#A8A79F', + role: 'other / rest / disabled', + hint: 'Theme-adaptive ink-muted. For "other" / "rest" slices, disabled series, confidence-band fills.', + }, +]; + +const HYBRID_V3_CVD: number[] = [36.19, 16.34, 13.98, 13.98, 13.70, 10.70, 8.81]; +const PURE_CVD_GREEDY: number[] = [36.19, 21.45, 19.81, 15.20, 13.70, 10.70, 8.81]; + +const WCAG_TABLE: { hex: string; name: string; light: number; dark: number }[] = [ + { hex: '#009E73', name: 'brand-green', light: 3.08, dark: 5.48 }, + { hex: '#AE3030', name: 'matte-red', light: 5.79, dark: 2.92 }, + { hex: '#C475FD', name: 'lavender', light: 2.59, dark: 6.53 }, + { hex: '#99B314', name: 'lime', light: 2.15, dark: 7.87 }, + { hex: '#4467A3', name: 'blue', light: 5.09, dark: 3.32 }, + { hex: '#2ABCCD', name: 'cyan', light: 2.06, dark: 8.19 }, + { hex: '#954477', name: 'rose', light: 5.61, dark: 3.01 }, + { hex: '#BD8233', name: 'ochre', light: 2.95, dark: 5.72 }, + { hex: '#DDCC77', name: 'amber', light: 1.46, dark: 11.59 }, +]; + +type HistoryEntry = { + id: string; + title: string; + hexes: string[]; + summary: string; + href?: string; + hrefLabel?: string; +}; + +const HISTORY: HistoryEntry[] = [ + { + id: 'v3', + title: 'v3 — imprint (current)', + hexes: ['#009E73', '#C475FD', '#4467A3', '#BD8233', '#AE3030', '#2ABCCD', '#954477', '#99B314'], + summary: '8 hues with first 4 slots from distinct hue families; semantic-red deferred to slot 4 so it stays a free anchor for bad / loss / error. Plus 3 anchors outside the pool (amber, neutral, muted).', + }, + { + id: 'v2', + title: 'v2 — D1-8 (8-hex expansion)', + hexes: ['#009E73', '#AE3030', '#C475FD', '#99B314', '#4467A3', '#2ABCCD', '#954477', '#BD8233'], + summary: 'Eight-hex expansion of variant D. Picked over a vivid-8 alternative by 5 expert reviewers — muted and CVD-safe, but had two greens and two purples next to each other in the canonical order.', + }, + { + id: 'v1', + title: 'v1 — variant D ("balanced")', + hexes: ['#009E73', '#9418DB', '#B71D27', '#16B8F3', '#99B314', '#D359A7', '#BA843E'], + summary: 'anyplot\'s first custom palette. 7 hues + adaptive neutral. Brand green inherited from Okabe-Ito; positions 2–7 from a Petroff-style max-min ΔE search in the muted-paper chroma envelope.', + href: 'https://arxiv.org/abs/2107.02270', + hrefLabel: 'Petroff 2021 (arXiv)', + }, + { + id: 'v0', + title: 'v0 — Okabe-Ito', + hexes: ['#009E73', '#D55E00', '#0072B2', '#CC79A7', '#E69F00', '#56B4E9', '#F0E442'], + summary: 'The original colorblind-safe categorical palette (Wong 2011, Nature Methods). 7 hues + adaptive neutral, brand green at slot 0.', + href: 'https://jfly.uni-koeln.de/color/', + hrefLabel: 'jfly.uni-koeln.de', + }, +]; + +// ───────────────────────────────────────────────────────────────────────────── +// Code snippets per language +// ───────────────────────────────────────────────────────────────────────────── + +type Lang = 'python' | 'r' | 'julia' | 'js'; +const LANG_LABELS: Record = { + python: 'Python', r: 'R', julia: 'Julia', js: 'JavaScript', +}; + +function snippet(lang: Lang, oklch: boolean, sortedPalette: Swatch[]): string { + const values = oklch ? sortedPalette.map(s => s.oklch) : sortedPalette.map(s => s.hex); + const amberVal = oklch ? 'oklch(0.841 0.108 98.3)' : '#DDCC77'; + const seqStart = oklch ? 'oklch(0.620 0.130 165.5)' : '#009E73'; + const seqEnd = oklch ? 'oklch(0.516 0.104 260.6)' : '#4467A3'; + const divStart = oklch ? 'oklch(0.503 0.163 25.2)' : '#AE3030'; + const divEnd = oklch ? 'oklch(0.516 0.104 260.6)' : '#4467A3'; + switch (lang) { + case 'python': + return `ANYPLOT_PALETTE = [ +${values.map(v => ` "${v}"`).join(',\n')}, +] +ANYPLOT_AMBER = "${amberVal}" # warning / caution + +# first series is ALWAYS slot 0 (brand green) +color = ANYPLOT_PALETTE[0] + +# continuous data — sequential + diverging cmaps +from matplotlib.colors import LinearSegmentedColormap +imprint_seq = LinearSegmentedColormap.from_list("imprint_seq", ["${seqStart}", "${seqEnd}"]) +midpoint = "#FAF8F1" if THEME == "light" else "#1A1A17" # theme-adaptive +imprint_div = LinearSegmentedColormap.from_list("imprint_div", ["${divStart}", midpoint, "${divEnd}"])`; + case 'r': + return `ANYPLOT_PALETTE <- c( +${values.map(v => ` "${v}"`).join(',\n')} +) +ANYPLOT_AMBER <- "${amberVal}" # warning / caution + +# first series is ALWAYS slot 0 (brand green) +color <- ANYPLOT_PALETTE[1] + +# continuous data — ggplot2 gradient scales +midpoint <- if (theme == "light") "#FAF8F1" else "#1A1A17" +scale_color_gradient(low = "${seqStart}", high = "${seqEnd}") # sequential +scale_color_gradient2(low = "${divStart}", mid = midpoint, high = "${divEnd}", midpoint = 0) # diverging`; + case 'julia': + return `const ANYPLOT_PALETTE = [ +${values.map(v => oklch ? ` "${v}"` : ` colorant"${v}"`).join(',\n')}, +] +const ANYPLOT_AMBER = ${oklch ? `"${amberVal}"` : `colorant"${amberVal}"`} # warning + +# first series is ALWAYS slot 0 (brand green) +color = ANYPLOT_PALETTE[1] + +# continuous data — Makie cgrad +using ColorSchemes +midpoint = theme == "light" ? colorant"#FAF8F1" : colorant"#1A1A17" +const ANYPLOT_SEQ = cgrad([colorant"${seqStart}", colorant"${seqEnd}"]) +const ANYPLOT_DIV = cgrad([colorant"${divStart}", midpoint, colorant"${divEnd}"])`; + case 'js': + return `const ANYPLOT_PALETTE = [ +${values.map(v => ` "${v}"`).join(',\n')}, ]; +const ANYPLOT_AMBER = "${amberVal}"; // warning / caution + +// first series is ALWAYS slot 0 (brand green) +const color = ANYPLOT_PALETTE[0]; + +// continuous data — two-stop / three-stop gradients +const midpoint = theme === "light" ? "#FAF8F1" : "#1A1A17"; // theme-adaptive +const IMPRINT_SEQ = [[0, "${seqStart}"], [1, "${seqEnd}"]]; +const IMPRINT_DIV = [[0, "${divStart}"], [0.5, midpoint], [1, "${divEnd}"]];`; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Inline sub-components +// ───────────────────────────────────────────────────────────────────────────── + +const MAX_WHEEL_CHROMA = 0.28; // clamp for radial position normalisation across palettes + +type WheelDot = { hex: string; L: number; C: number; H: number }; + +/** Pick a readable text color (ink-dark or ink-light) for a hex bg via WCAG luminance. */ +function textOn(hex: string): string { + const s = hex.replace('#', ''); + const r = parseInt(s.slice(0, 2), 16) / 255; + const g = parseInt(s.slice(2, 4), 16) / 255; + const b = parseInt(s.slice(4, 6), 16) / 255; + const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); + const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + return L > 0.5 ? '#1A1A17' : '#F0EFE8'; +} + +function ChromaWheel({ + size = 320, + imprintDots, + overlay, +}: { + size?: number; + imprintDots: WheelDot[]; + overlay?: WheelDot[]; +}) { + const cx = size / 2; + const cy = size / 2; + const outerR = (size / 2) - 12; + const dotR = 8; + + // Project (hue°, chroma) → (x, y). Hue 0° at right (3 o'clock), increment CCW + // so the resulting layout matches the CSS conic-gradient ring rendered below. + const project = (H: number, C: number): { x: number; y: number } => { + const rad = (H * Math.PI) / 180; + const r = Math.min(C / MAX_WHEEL_CHROMA, 1) * outerR; + return { + x: cx + Math.cos(rad) * r, + y: cy - Math.sin(rad) * r, // negate because screen y grows downward + }; + }; + + // Build the conic gradient OKLCH stops every 15° so the wheel reads as a + // proper colour wheel. CSS conic-gradient starts at 12 o'clock and goes + // clockwise, but our hue convention has 0° at 3 o'clock going counter- + // clockwise (sin/cos math). Map: cssAngle = (90 - hue + 360) mod 360. + const gradStops = Array.from({ length: 25 }, (_, i) => { + const cssAngle = i * 15; + const hue = (90 - cssAngle + 360) % 360; + return `oklch(0.72 0.18 ${hue}deg) ${cssAngle}deg`; + }).join(', '); + + return ( + + {/* Hue ring background (CSS conic-gradient + radial-gradient overlay for chroma fade to centre) */} + + {/* SVG overlay — dots with hover tooltips for the hex */} + + {/* Overlay palette (hollow rings) — render BELOW imprint dots so imprint stays visually dominant */} + {overlay && overlay.map((d, i) => { + const { x, y } = project(d.H, d.C); + return ( + + {d.hex} + + + ); + })} + {/* Imprint dots (filled) */} + {imprintDots.map((d, i) => { + const { x, y } = project(d.H, d.C); + return ( + + {d.hex} + + + ); + })} + + + ); +} + +function CodeBlock({ code }: { code: string }) { + const [copied, setCopied] = useState(false); + const handleCopy = () => { + void navigator.clipboard + .writeText(code) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }) + .catch(() => { + // Silently ignore — clipboard API can fail in insecure contexts. + }); + }; + return ( + + + {code} + + + + + + + + ); +} + +function CollapsibleSection({ title, defaultOpen = false, children }: { title: string; defaultOpen?: boolean; children: React.ReactNode }) { + const [open, setOpen] = useState(defaultOpen); + return ( + + setOpen(o => !o)} + sx={{ + width: '100%', + display: 'flex', alignItems: 'center', gap: 1, + background: 'none', border: 'none', cursor: 'pointer', + fontFamily: typography.mono, + fontSize: '13px', + color: 'var(--ink)', + py: 1, + textAlign: 'left', + }} + > + + {title} + + + {children} + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Layout +// ───────────────────────────────────────────────────────────────────────────── const sectionSx = { py: { xs: 2, md: 3 } }; -const proseColumnSx = { maxWidth: 760, mx: 'auto' }; +const proseColumnSx = { maxWidth: 880, mx: 'auto' }; + +// ───────────────────────────────────────────────────────────────────────────── +// Main page +// ───────────────────────────────────────────────────────────────────────────── export function PalettePage() { const { trackPageview } = useAnalytics(); + const [lang, setLang] = useState('python'); + const [oklch, setOklch] = useState(false); + const [compareId, setCompareId] = useState(null); + /** `imprint` = hybrid-v3 (default, 4 hue families up front). + * `cvd` = pure-CVD-greedy max-min (best per-n ΔE under CVD, but groups + * two greens + two purples close together in the first 4 slots). */ + const [sort, setSort] = useState<'imprint' | 'cvd'>('imprint'); + const [copiedHex, setCopiedHex] = useState(null); + + const copyHex = (hex: string) => { + void navigator.clipboard + .writeText(hex) + .then(() => { + setCopiedHex(hex); + setTimeout(() => setCopiedHex((c) => (c === hex ? null : c)), 1500); + }) + .catch(() => { + // Silently ignore — clipboard API can fail in insecure contexts. + }); + }; useEffect(() => { trackPageview('/palette'); }, [trackPageview]); + const sortedPalette: Swatch[] = useMemo( + () => (sort === 'imprint' ? PALETTE : CVD_OPTIMAL_ORDER.map(i => PALETTE[i])), + [sort] + ); + + // Wheel dots are positioned by hue + chroma, independent of sort order — + // we keep the canonical PALETTE order so the brand-green outline stays on + // the same identity dot regardless of sort. + const imprintDots: WheelDot[] = useMemo( + () => PALETTE.map(s => ({ hex: s.hex, L: s.L, C: s.C, H: s.H })), + [] + ); + const overlayDots: WheelDot[] | undefined = useMemo( + () => COMPARISONS.find(c => c.id === compareId)?.hexes, + [compareId] + ); + return ( <> palette | anyplot.ai - + + {/* 1. HERO */} - the Okabe-Ito palette} /> - - - every plot on anyplot.ai uses the Okabe-Ito palette, a set of 8 colors designed to stay - distinguishable under the main forms of color vision deficiency. Masataka Okabe and Kei Ito - proposed it on their Color Universal Design page (2002, revised 2008), addressing - protanopia, deuteranopia, and tritanopia — conditions that affect roughly 8% of men. + anyplot's imprint palette} /> + + + + every plot on anyplot uses imprint, a categorical palette of 8 colours plus 3 + semantic anchors. low-chroma, warm-tinted for cream paper, validated against + deuteranopia / protanopia / tritanopia. designed to be easier on the eyes and let the data + speak. + + + compare with other categorical palettes: + + + {COMPARISONS.map((c) => { + const active = compareId === c.id; + return ( + setCompareId(active ? null : c.id)} + sx={{ + background: active ? 'var(--bg-surface)' : 'none', + border: `1px solid ${active ? colors.primary : 'var(--rule)'}`, + borderRadius: 1, + padding: '6px 10px', + fontFamily: typography.mono, + fontSize: '11px', + color: active ? colors.primary : 'var(--ink-soft)', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: 1, + '&:hover': { borderColor: colors.primary, color: colors.primary }, + }} + > + {/* Mini swatch strip for the comparison palette */} + + {c.hexes.map((d) => ( + + ))} + + {c.name} + + ); + })} + - - - a palette proposed as unambiguous to both colorblind and non-colorblind viewers, with - vivid colors that stay recognizable on screen and in print. - - — Okabe & Ito, Color Universal Design (2008) + + + + hue clockwise · chroma = distance from centre + {compareId && ( + + rings = {COMPARISONS.find(c => c.id === compareId)?.name} + + )} + + + {/* 2. PALETTE GRID + SORT TOGGLE */} + + the 8 categorical hues} /> + + + + sort: + {(['imprint', 'cvd'] as const).map((s) => ( + setSort(s)} + sx={{ + background: 'none', border: 'none', padding: 0, ml: 1, + fontFamily: typography.mono, fontSize: '11px', + color: sort === s ? colors.primary : 'var(--ink-soft)', + textDecoration: 'underline', + textDecorationColor: sort === s ? colors.primary : 'var(--rule)', + cursor: 'pointer', + }} + > + {s === 'imprint' ? 'imprint (default)' : 'CVD-optimal (max ΔE under colorblindness)'} + + ))} + + + {sort === 'cvd' && ( + + + why use CVD-optimal sort? + + + this ordering picks each next slot by maximising ΔE under the worst of deuteranopia / + protanopia / tritanopia simulation against the already-placed set. it's the best choice + when colour alone has to carry the categorical encoding for users with red-green colour + vision deficiency — e.g. small-n charts in publications targeting CVD-first audiences, + status-only legends, or signage-style summary tiles. + - - + {/* Compact per-n table — only shown in CVD mode since that's where the trade-off matters */} + + + + + n series + imprint default + CVD-optimal (active) + + + + {HYBRID_V3_CVD.map((de, i) => { + const altDe = PURE_CVD_GREEDY[i]; + const gain = altDe - de; + return ( + + {i + 2} + {de.toFixed(2)} + 0 ? '#007A59' : 'var(--ink-muted)', fontWeight: gain > 0 ? 600 : 400 }}> + {altDe.toFixed(2)}{gain > 0 ? ` (+${gain.toFixed(2)})` : gain < 0 ? ` (${gain.toFixed(2)})` : ' (=)'} + + + ); + })} + + + + + + trade-off: the first 4 slots end + up with two greens (brand at slot 0 + lime at slot 2) and two purples (lavender at slot 1 + + rose at slot 3) side by side — the eye loses the “four distinct hue families” + cue that imprint default gives you. n=2..6 gains 0.71–5.83 ΔE under CVD; n=7..8 are + identical because both sortings ship the same 8 hexes. + + + when to switch: CVD-first + audience + small-n (n ≤ 4) + colour is the only encoding. otherwise the imprint default is + the better balance — and at n = 7..8 you should add a marker shape or linestyle either way. + + + )} + + {sortedPalette.map((s, i) => { + const copied = copiedHex === s.hex; + return ( + copyHex(s.hex)} + sx={{ + display: 'flex', flexDirection: 'column', + background: 'none', border: 'none', padding: 0, + cursor: 'pointer', + textAlign: 'left', + '&:hover .v3-sw': { transform: 'scale(1.02)' }, + }} + > + + {copied ? 'copied ✓' : s.hex} + + + + slot {i}{i === 0 ? ' ★' : ''} + H={s.H.toFixed(0)}° + + {s.name} + {s.role} + + + + norm {s.minNorm.toFixed(1)} + + + + + cvd {s.minCvd.toFixed(1)} + + + + + + = 3 ? '#007A59' : '#b62d2d', cursor: 'help' }}> + ☼ {s.wcagL.toFixed(1)}:1 + + + + = 3 ? '#007A59' : '#b62d2d', cursor: 'help' }}> + ☽ {s.wcagD.toFixed(1)}:1 + + + + + + ); + })} + + {/* 3. SEMANTIC ANCHORS */} - color reference} /> + semantic anchors} /> - - {SWATCHES.map((swatch, i) => ( - - - - - {swatch.hex} + + three additional anchors that live outside the categorical pool. they are never + returned by palette[:n] — reached only by name. two of them flip per theme. + + + {ANCHORS.map((a) => ( + + {a.hexDark ? ( + + + {a.hexLight} + + + {a.hexDark} + + + ) : ( + + )} + + + {a.role} + + + palette.{a.key}{a.hexDark ? ' — adaptive' : ''} - - {swatch.name} — {swatch.role} + + {a.hint} @@ -86,20 +799,185 @@ export function PalettePage() { + {/* 3b. CONTINUOUS CMAPS — sequential + diverging */} - usage rules} /> + continuous cmaps} /> - the first series in every plot is always the brand color (#009E73). the neutral (position 8) - is reserved for aggregates and reference lines. yellow (#F0E442) has poor contrast on white - backgrounds — use it only for position 7 or later. + for continuous data the categorical pool is replaced with two palette-derived colormaps. + + {/* Sequential */} + + + + imprint_seq + + + single-polarity · density / magnitude + + + + + #009E73 brand-green + #4467A3 blue + + + {/* Diverging */} + + + + imprint_div + + + diverging · correlations / signed deviations + + + + + #AE3030 red + theme-adaptive midpoint + #4467A3 blue + + + + + the diverging midpoint flips per theme — #FAF8F1 on cream bg / #1A1A17 + on warm-near-black — so the zero point reads as part of the page rather than as a grey blob. + + + - - for sequential data, use viridis or cividis. for diverging data, use BrBG from ColorBrewer. - don't use Okabe-Ito for continuous data — a categorical palette on continuous data produces - misleading banding. + {/* 4. COPY SNIPPETS */} + + copy & paste} /> + + + setLang(v as Lang)} + variant="scrollable" + scrollButtons="auto" + sx={{ + minHeight: 36, + '& .MuiTab-root': { fontFamily: typography.mono, fontSize: '11px', minHeight: 36, py: 0, textTransform: 'none', color: 'var(--ink-muted)' }, + '& .Mui-selected': { color: 'var(--ink)' }, + '& .MuiTabs-indicator': { backgroundColor: colors.primary }, + }} + > + {(Object.keys(LANG_LABELS) as Lang[]).map(l => ( + + ))} + + setOklch(e.target.checked)} sx={{ '& .MuiSwitch-switchBase.Mui-checked': { color: colors.primary }, '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { backgroundColor: colors.primary } }} />} + label={OKLCH} + /> + + + + + {/* 5. WCAG AUDIT (collapsible) — was section 6 before "best variant for CVD users" was folded into the sort toggle expanded view */} + + + + + muted palettes share a known limit: the lighter members carry their distinguishability through + chroma, not L-spread. On cream bg #FAF8F1, five categorical hues + amber fall under + WCAG 2.1 SC 1.4.11's 3:1 minimum. The industry-standard fix is a 1px ink-color + stroke on affected series — but it's a renderer judgment call, not a hard rule. + + + + + + hex + name + on cream + on dark + + + + {WCAG_TABLE.map((row) => ( + + + + {row.hex} + + {row.name} + + {row.light.toFixed(2)}:1 {row.light < 3 ? '✗' : '✓'} + + + {row.dark.toFixed(2)}:1 {row.dark < 3 ? '✗' : '✓'} + + + ))} + + + + + + + + {/* 6. HISTORY (collapsible) */} + + + + + {HISTORY.map((entry) => ( + + + + {entry.title} + + {entry.href && ( + + {entry.hrefLabel ?? entry.href} + + )} + + + {entry.hexes.map((hx) => ( + + ))} + + + {entry.summary} + + + ))} + + diff --git a/app/src/styles/tokens.css b/app/src/styles/tokens.css index 37be1675ea..954fb444ec 100644 --- a/app/src/styles/tokens.css +++ b/app/src/styles/tokens.css @@ -2,19 +2,27 @@ * Design Tokens — anyplot.ai * * CSS custom properties for the editorial/paper aesthetic. - * Based on Okabe-Ito colorblind-safe palette. + * Based on the anyplot imprint palette — a colorblind-safe 8-hue + * categorical palette plus 3 semantic anchors. See + * docs/reference/palette-variants-v3/decision-rationale.md for the full design. * Dark mode via [data-theme="dark"] on . */ :root { - /* Okabe-Ito Palette */ - --ok-green: #009E73; - --ok-vermillion: #D55E00; - --ok-blue: #0072B2; - --ok-purple: #CC79A7; - --ok-orange: #E69F00; - --ok-sky: #56B4E9; - --ok-yellow: #F0E442; + /* imprint palette — 8 categorical hues in hybrid-v3 sort order */ + --imprint-green: #009E73; /* slot 0 — brand */ + --imprint-lavender: #C475FD; /* slot 1 */ + --imprint-blue: #4467A3; /* slot 2 */ + --imprint-ochre: #BD8233; /* slot 3 */ + --imprint-red: #AE3030; /* slot 4 — deferred semantic anchor */ + --imprint-cyan: #2ABCCD; /* slot 5 */ + --imprint-rose: #954477; /* slot 6 */ + --imprint-lime: #99B314; /* slot 7 */ + + /* Semantic anchors outside the categorical pool */ + --imprint-amber: #DDCC77; /* warning / caution (fixed hex) */ + /* --imprint-neutral and --imprint-muted are aliased to --ink / --ink-muted + so they automatically flip per theme (totals/baseline → ink, other/rest → ink-muted) */ /* Surfaces — warm off-white, not pure #fff */ --bg-page: #F5F3EC; @@ -25,7 +33,7 @@ --ink: #1A1A17; --ink-soft: #4A4A44; --ink-muted: #6B6A63; - --rule: rgba(26, 26, 23, 0.10); + --rule: rgba(26, 26, 23, 0.15); /* Typography stacks — MonoLisa-only experiment (serif/sans aliased to mono) */ --serif: 'MonoLisa', 'MonoLisa Fallback', Consolas, Menlo, Monaco, 'DejaVu Sans Mono', monospace; @@ -45,16 +53,16 @@ --code-text: #1A1A17; --code-border: rgba(26, 26, 23, 0.10); - /* Syntax tokens — tuned for cream paper background. + /* Syntax tokens — tuned for cream paper background using imprint palette. Brand green is reserved for comments (italic) so it reads as a deliberate accent against neutral ink body text. */ --code-comment: #007A59; /* darker brand green, italic — passes AA on cream */ - --code-keyword: #0072B2; /* blue */ - --code-string: #D55E00; /* vermillion */ + --code-keyword: #4467A3; /* imprint blue */ + --code-string: #AE3030; /* imprint matte red */ --code-function: #1A1A17; /* ink (bold) */ - --code-number: #B85400; /* darker vermillion for legibility */ + --code-number: #954477; /* imprint rose — darker for legibility */ --code-variable: #4A4A44; /* ink-soft */ - --code-constant: #CC79A7; /* purple */ + --code-constant: #C475FD; /* imprint lavender */ --code-punctuation: #6B6A63; /* ink-muted */ --code-operator: #1A1A17; /* ink */ } @@ -100,21 +108,21 @@ html, body { overflow-x: clip; } --ink: #F0EFE8; --ink-soft: #B8B7B0; --ink-muted: #A8A79F; - --rule: rgba(240, 239, 232, 0.10); + --rule: rgba(240, 239, 232, 0.15); /* Code: warm near-black surface, cream text */ --code-bg: #1A1A17; --code-text: #E8E8E0; --code-border: rgba(240, 239, 232, 0.08); - /* Syntax tokens — tuned for warm near-black background */ + /* Syntax tokens — tuned for warm near-black background using imprint palette */ --code-comment: #009E73; /* brand green, italic */ - --code-keyword: #56B4E9; /* sky */ - --code-string: #E69F00; /* orange */ - --code-function: #F0E442; /* yellow */ - --code-number: #F0E442; /* yellow */ - --code-variable: #CC79A7; /* purple */ - --code-constant: #D55E00; /* vermillion */ + --code-keyword: #2ABCCD; /* imprint cyan */ + --code-string: #DDCC77; /* imprint amber — high contrast on dark */ + --code-function: #99B314; /* imprint lime */ + --code-number: #99B314; /* imprint lime */ + --code-variable: #C475FD; /* imprint lavender */ + --code-constant: #AE3030; /* imprint matte red */ --code-punctuation: #8A8A82; --code-operator: #E8E8E0; } diff --git a/app/src/theme/index.ts b/app/src/theme/index.ts index d05ed171bc..f10d538db3 100644 --- a/app/src/theme/index.ts +++ b/app/src/theme/index.ts @@ -1,9 +1,11 @@ /** * Theme constants for anyplot frontend. * - * Editorial/paper aesthetic with Okabe-Ito colorblind-safe palette. - * Brand color: #009E73 (bluish green). - * Warm off-white backgrounds, three font families. + * Editorial/paper aesthetic with the anyplot imprint palette — a + * colorblind-safe 8-hue categorical palette plus 3 semantic anchors + * (amber for warning, theme-adaptive neutral and muted). + * Brand color: #009E73 (bluish green, slot 0 — always first series). + * Full design rationale: docs/reference/palette-variants-v3/decision-rationale.md */ export const typography = { @@ -15,19 +17,23 @@ export const typography = { } as const; export const colors = { - // Brand — Okabe-Ito palette - primary: '#009E73', // Bluish green — brand anchor - accent: '#E69F00', // Orange — badges, highlights - - // Okabe-Ito full palette (for direct reference) - okabe: { - green: '#009E73', - vermillion: '#D55E00', - blue: '#0072B2', - purple: '#CC79A7', - orange: '#E69F00', - sky: '#56B4E9', - yellow: '#F0E442', + // Brand — imprint palette + primary: '#009E73', // Brand green — slot 0, always first series + accent: '#BD8233', // Ochre — badges, highlights (warm-neutral, plays well with brand green) + + // imprint full palette — 8 categorical hues in hybrid-v3 sort order + imprint: { + green: '#009E73', // slot 0 — brand + lavender: '#C475FD', // slot 1 + blue: '#4467A3', // slot 2 + ochre: '#BD8233', // slot 3 + red: '#AE3030', // slot 4 — deferred semantic anchor for bad / loss / error + cyan: '#2ABCCD', // slot 5 + rose: '#954477', // slot 6 + lime: '#99B314', // slot 7 + // Semantic anchors outside the categorical pool + amber: '#DDCC77', // warning / caution (fixed hex) + // neutral + muted are theme-adaptive — exposed as CSS vars below }, // Gray scale — warm-tinted @@ -44,11 +50,11 @@ export const colors = { 900: '#121210', }, - // Semantic colors — Okabe-Ito mapped - success: '#009E73', // Green - error: '#D55E00', // Vermillion - warning: '#E69F00', // Orange - info: '#0072B2', // Blue + // Semantic colors — imprint mapped + success: '#009E73', // brand green + error: '#AE3030', // matte red (slot 4 — the deferred semantic anchor) + warning: '#DDCC77', // amber (semantic anchor, outside pool) + info: '#4467A3', // blue (slot 2) // Background — warm off-white background: '#F5F3EC', @@ -62,7 +68,7 @@ export const colors = { bg: 'rgba(0, 158, 115, 0.12)', // Green-tinted highlight bg text: '#007A59', // Dark green text for highlighted chips }, - tooltipLight: '#56B4E9', // Sky blue on dark tooltip backgrounds + tooltipLight: '#2ABCCD', // Cyan on dark tooltip backgrounds // Code blocks — values come from CSS vars so they adapt with [data-theme] codeBlock: { diff --git a/core/images.py b/core/images.py index 8f4a836d14..c5fdada743 100644 --- a/core/images.py +++ b/core/images.py @@ -22,6 +22,31 @@ from PIL import Image, ImageDraw, ImageFont +# anyplot categorical palette — "imprint" (v3 hybrid-v3 ordering). +# Defined as a separate module so the project's named-API (palette.green, +# palette.semantic.bad, etc.) plus sequential / diverging cmaps live in one +# place. Full design rationale: +# docs/reference/palette-variants-v3/decision-rationale.md +from .palette import AMBER as ANYPLOT_AMBER # noqa: F401 (re-exported public API) +from .palette import BLUE as ANYPLOT_BLUE +from .palette import CYAN as ANYPLOT_CYAN +from .palette import GREEN as ANYPLOT_GREEN +from .palette import IMPRINT as ANYPLOT_PALETTE # noqa: F401 (re-exported public API) +from .palette import LAVENDER as ANYPLOT_LAVENDER +from .palette import LIME as ANYPLOT_LIME +from .palette import OCHRE as ANYPLOT_OCHRE +from .palette import RED as ANYPLOT_RED +from .palette import ROSE as ANYPLOT_ROSE +from .palette import palette # noqa: F401 (re-exported named API) + + +# Note: imprint_seq / imprint_div_{light,dark} are NOT auto-registered with +# matplotlib when this module is imported — core/images.py is PIL-only and +# importing it should not pull matplotlib in as a side effect. Callers that +# render with matplotlib should call +# ``from core.palette import register_with_matplotlib; register_with_matplotlib()`` +# themselves before referring to the cmaps by string name. + logger = logging.getLogger(__name__) @@ -42,25 +67,9 @@ # # Theme dicts so OG images can be rendered light or dark from the same code. # Token names mirror the CSS custom properties defined in the React app -# (`--bg-page`, `--ink`, `--ok-green`, etc.) so the OG cards read as a direct -# translation of the in-product surfaces. - -# anyplot categorical palette — variant D ("balanced") from the palette -# exploration in #5817. Position 1 (#009E73) carries the same bluish green -# as Okabe-Ito's first slot so the brand identity (`any.plot()` dot, first -# series) is preserved; positions 2–7 are selected via Petroff-style -# max-min ΔE search in the CAM02-UCS paper-ink corridor (J' ∈ [45,72], -# C ∈ [22,36]). See docs/reference/palette-variants/D-balanced.html for -# the derivation and CVD analysis. -ANYPLOT_GREEN = "#009E73" # brand anchor — the dot in any.plot(); ALWAYS first series -ANYPLOT_PURPLE = "#9418DB" -ANYPLOT_RED = "#B71D27" -ANYPLOT_SKY = "#16B8F3" -ANYPLOT_LIME = "#99B314" -ANYPLOT_PINK = "#D359A7" -ANYPLOT_TAN = "#BA843E" - -ANYPLOT_PALETTE = [ANYPLOT_GREEN, ANYPLOT_PURPLE, ANYPLOT_RED, ANYPLOT_SKY, ANYPLOT_LIME, ANYPLOT_PINK, ANYPLOT_TAN] +# (`--bg-page`, `--ink`, `--imprint-green`, etc.) so the OG cards read as a direct +# translation of the in-product surfaces. The imprint palette itself is +# re-exported from core/palette at the top of this module. LIGHT_THEME: dict[str, str] = { "bg_page": "#F5F3EC", # warm cream — matches `--bg-page` in app/src/styles/tokens.css @@ -68,7 +77,7 @@ "ink": "#1A1A17", # primary text — `--ink` "ink_soft": "#4A4A44", # secondary text — `--ink-soft` "ink_muted": "#6B6A63", # tertiary / meta — `--ink-muted` - "rule": "#DFDDD6", # ~rgba(26,26,23,0.10) flattened on bg_page + "rule": "#D4D2CC", # ~rgba(26,26,23,0.15) flattened on bg_page "card_shadow": "#D9D5C8", } @@ -78,24 +87,26 @@ "ink": "#F0EFE8", "ink_soft": "#B8B7B0", "ink_muted": "#A8A79F", - "rule": "#1E1E1B", # ~rgba(240,239,232,0.10) flattened on bg_page + "rule": "#33332F", # ~rgba(240,239,232,0.15) flattened on bg_page "card_shadow": "#000000", } # Library → anyplot palette accent color used for the colored 8x8 chip square # next to library.method() callouts. Falls back to brand green for unknowns. -# Mapping is preserved by palette position from the original Okabe-Ito assignment. +# Mapping picks the imprint hue that best matches each library's own brand +# identity rather than a fixed slot position — so plotly stays blue-ish, +# bokeh stays purple-ish, matplotlib keeps its red accent, etc. LIBRARY_COLORS: dict[str, str] = { - "matplotlib": ANYPLOT_RED, # pos 3 - "seaborn": ANYPLOT_PINK, # pos 6 - "plotly": ANYPLOT_SKY, # pos 4 - "bokeh": ANYPLOT_PURPLE, # pos 2 - "altair": ANYPLOT_GREEN, # pos 1 - "plotnine": ANYPLOT_LIME, # pos 5 - "pygal": ANYPLOT_TAN, # pos 7 - "highcharts": ANYPLOT_RED, # pos 3 - "letsplot": ANYPLOT_GREEN, # pos 1 - "lets-plot": ANYPLOT_GREEN, # pos 1 + "matplotlib": ANYPLOT_RED, # matplotlib logo's red accent + "seaborn": ANYPLOT_ROSE, # warm statistical-plot mood + "plotly": ANYPLOT_BLUE, # plotly's brand blue + "bokeh": ANYPLOT_LAVENDER, # bokeh's purple brand + "altair": ANYPLOT_GREEN, # altair has green, also brand anchor + "plotnine": ANYPLOT_LIME, # ggplot/plotnine green family + "pygal": ANYPLOT_OCHRE, # pygal's warm yellow logo + "highcharts": ANYPLOT_CYAN, # highcharts cyan-blue brand — distinct from plotly + "letsplot": ANYPLOT_GREEN, + "lets-plot": ANYPLOT_GREEN, } # Library → typical method call hint shown inside `library.method()` chips. @@ -472,7 +483,7 @@ def _draw_anyplot_wordmark( - `any` and `plot` in `--ink`, MonoLisa Bold - `.` is the actual MonoLisa period glyph recolored to brand green (matches - the website where the dot is a `.` character with `color: var(--ok-green)`) + the website where the dot is a `.` character with `color: var(--imprint-green)`) - `()` in `--ink` at 45% opacity, normal weight (not bold) Args: @@ -500,7 +511,7 @@ def _draw_anyplot_wordmark( cursor_x += _text_advance(draw, "any", bold_font) # "." — the actual MonoLisa period glyph, recolored brand green. Matches the - # website where the dot is a `.` character with `color: var(--ok-green)` and + # website where the dot is a `.` character with `color: var(--imprint-green)` and # a 1.3× scale, NOT a filled circle (which used to read way too heavy). draw.text((cursor_x, y), ".", fill=ANYPLOT_GREEN, font=bold_font) cursor_x += _text_advance(draw, ".", bold_font) diff --git a/core/palette.py b/core/palette.py new file mode 100644 index 0000000000..33666594a0 --- /dev/null +++ b/core/palette.py @@ -0,0 +1,212 @@ +"""anyplot imprint palette — v3 hybrid-v3 ordering. + +Single source of truth for the 8 categorical hues, 3 semantic anchors, +sequential cmap, and diverging cmap used across the project. + +Design rationale: docs/reference/palette-variants-v3/decision-rationale.md + +Why "imprint": the palette is tuned for warm-cream paper backgrounds with +matte ink-like categorical hues — the academic-publishing-imprint mood +(Penguin Classics, FT Books, Nature Methods). It deliberately sits in the +Okabe-Ito / Paul Tol "muted" / ColorBrewer Set2 family. + +Why 8 (not 7): closes the cyan-or-lavender gap in the previous 7-hue +palette; matches the academic-publishing-family pool size; sits at the +ΔE_CVD discrimination sweet spot before n=9+ breaks down. + +Layout +------ +- 8 categorical hexes in slot order 0..7 (hybrid-v3 sort: brand anchor, + then hue-family-diverse first 4, then pure-CVD-greedy tail). Reached + by position via ``IMPRINT[:n]`` or by name via ``palette.green`` etc. +- 3 semantic anchors OUTSIDE the categorical pool. Never returned by + ``IMPRINT[:n]``. Reached only by name: ``palette.amber``, + ``palette.neutral(theme)``, ``palette.muted(theme)``. The neutrals are + theme-adaptive — they flip between light and dark themes by design, + same pattern as Apple HIG / Material Design / GitHub Primer. +- Sequential cmap (``imprint_seq``): brand-green → blue. +- Diverging cmap (``imprint_div_light`` / ``imprint_div_dark``): matte-red + ↔ near-neutral ↔ blue. Theme-adaptive via ``diverging(theme)`` factory. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +from matplotlib.colors import LinearSegmentedColormap + + +# ───────────────────────────────────────────────────────────────────────────── +# Identity +# ───────────────────────────────────────────────────────────────────────────── + +NAME: str = "imprint" + +# ───────────────────────────────────────────────────────────────────────────── +# Categorical hues — slot order from hybrid-v3 sort +# ───────────────────────────────────────────────────────────────────────────── + +GREEN: str = "#009E73" # slot 0 — brand anchor (Okabe-Ito's bluish green) +LAVENDER: str = "#C475FD" # slot 1 +BLUE: str = "#4467A3" # slot 2 +OCHRE: str = "#BD8233" # slot 3 +RED: str = "#AE3030" # slot 4 — semantic anchor for bad/loss/error (deferred past first-4) +CYAN: str = "#2ABCCD" # slot 5 +ROSE: str = "#954477" # slot 6 +LIME: str = "#99B314" # slot 7 + +IMPRINT: list[str] = [GREEN, LAVENDER, BLUE, OCHRE, RED, CYAN, ROSE, LIME] + +# ───────────────────────────────────────────────────────────────────────────── +# Semantic anchors OUTSIDE the categorical pool +# ───────────────────────────────────────────────────────────────────────────── + +# Fixed hex — never theme-adaptive. Chosen for max ΔE_CVD against the 8 +# categorical members (min ΔE_CVD = 14.52 to lime — the two more saturated +# amber candidates #D4A017 and #D4AF37 both collapse to ΔE_CVD ≈ 2.3 against +# lime under deuteranopia). +AMBER: str = "#DDCC77" # warning / caution + +# Theme-adaptive neutrals. Same hex pair as LIGHT_THEME["ink"] / DARK_THEME["ink"] +# and LIGHT_THEME["ink_muted"] / DARK_THEME["ink_muted"] in scripts/_palette_common.py +# (kept duplicated here so this module doesn't depend on the scripts/ tree). +_INK_LIGHT: str = "#1A1A17" # full-contrast neutral on cream bg +_INK_DARK: str = "#F0EFE8" # full-contrast neutral on warm near-black bg +_INK_MUTED_LIGHT: str = "#6B6A63" # soft-contrast neutral on cream bg +_INK_MUTED_DARK: str = "#A8A79F" # soft-contrast neutral on warm near-black bg + + +def neutral_for(theme: str = "light") -> str: + """Theme-adaptive ink: totals / baseline / outline. + + Same hex as the chart's text and gridlines, so "totals" / "baseline" + series read as part of the chart's structural layer rather than as + "just another category". + + Parameters + ---------- + theme : "light" | "dark", default "light" + """ + return _INK_LIGHT if theme == "light" else _INK_DARK + + +def muted_for(theme: str = "light") -> str: + """Theme-adaptive ink-muted: other / rest / disabled. + + Soft-contrast rather than full-contrast. Meant for "other" / "rest" + slices in stacked charts, disabled / inactive series, confidence + bands, and annotations that should sit behind the data. + + Parameters + ---------- + theme : "light" | "dark", default "light" + """ + return _INK_MUTED_LIGHT if theme == "light" else _INK_MUTED_DARK + + +# ───────────────────────────────────────────────────────────────────────────── +# Named API — by hue, by semantic role +# ───────────────────────────────────────────────────────────────────────────── + +# Slot order and named access are independent. Callers who want +# "the loss colour" reach for ``palette.red`` or ``palette.semantic.bad``; +# callers who just need n distinct series reach for ``palette.as_list[:n]``. +palette = SimpleNamespace( + name=NAME, + as_list=IMPRINT, + # by hue + green=GREEN, + red=RED, + blue=BLUE, + cyan=CYAN, + lime=LIME, + ochre=OCHRE, + lavender=LAVENDER, + rose=ROSE, + # semantic anchors outside the categorical pool + amber=AMBER, + neutral=neutral_for, # call with "light" / "dark" + muted=muted_for, + # role-based aliases — semantic.warning maps to amber (NOT ochre — ochre + # is the "earth / commodity" categorical hue, not a caution signal) + semantic=SimpleNamespace(good=GREEN, bad=RED, warning=AMBER, info=CYAN, baseline=neutral_for, other=muted_for), +) + + +# ───────────────────────────────────────────────────────────────────────────── +# Sequential + diverging cmaps +# ───────────────────────────────────────────────────────────────────────────── + +# Sequential: brand-green → blue. Endpoints picked so the warm end is the +# brand identity and the cool end is the deepest blue in the palette. +imprint_seq: LinearSegmentedColormap = LinearSegmentedColormap.from_list("imprint_seq", [GREEN, BLUE]) + + +def diverging(theme: str = "light") -> LinearSegmentedColormap: + """Diverging cmap: matte-red ↔ near-neutral ↔ blue. + + The midpoint flips per theme to keep the diverging "zero" reading as + part of the chart bg rather than as an opaque grey blob. + + Parameters + ---------- + theme : "light" | "dark", default "light" + On light bg, midpoint = warm cream #FAF8F1; on dark bg, midpoint + = warm near-black #1A1A17. + """ + midpoint = "#FAF8F1" if theme == "light" else "#1A1A17" + return LinearSegmentedColormap.from_list(f"imprint_div_{theme}", [RED, midpoint, BLUE]) + + +imprint_div_light: LinearSegmentedColormap = diverging("light") +imprint_div_dark: LinearSegmentedColormap = diverging("dark") + + +# ───────────────────────────────────────────────────────────────────────────── +# Matplotlib registration — opt-in side effect, called by core.images +# ───────────────────────────────────────────────────────────────────────────── + + +def register_with_matplotlib() -> None: + """Register imprint_seq + both diverging cmap variants with matplotlib. + + Idempotent — re-registering an existing cmap is silently skipped. + """ + import matplotlib + + for cm in (imprint_seq, imprint_div_light, imprint_div_dark): + try: + matplotlib.colormaps.register(cm) + except ValueError: + # Already registered (e.g. when this module is re-imported in + # the same Python process). + pass + + +__all__ = [ + # identity + "NAME", + # categorical pool + "IMPRINT", + "GREEN", + "LAVENDER", + "BLUE", + "OCHRE", + "RED", + "CYAN", + "ROSE", + "LIME", + # semantic anchors + "AMBER", + "neutral_for", + "muted_for", + # named API + "palette", + # cmaps + "imprint_seq", + "imprint_div_light", + "imprint_div_dark", + "diverging", + # mpl integration + "register_with_matplotlib", +] diff --git a/docs/reference/anyplot-landing-mockup.html b/docs/reference/anyplot-landing-mockup.html index ca5a5365e6..31dbdcb130 100644 --- a/docs/reference/anyplot-landing-mockup.html +++ b/docs/reference/anyplot-landing-mockup.html @@ -1,4 +1,13 @@ + diff --git a/docs/reference/palette-variants-v1/D-baseline.html b/docs/reference/palette-variants-v1/D-baseline.html new file mode 100644 index 0000000000..cd669e34dd --- /dev/null +++ b/docs/reference/palette-variants-v1/D-baseline.html @@ -0,0 +1,760 @@ + + + + + +D · baseline (live anyplot palette) — anyplot palette v1 + + + +
+

any.plot() — D · baseline (live anyplot palette)

+
CAM02-UCS · v1 · #5817
+ +
+ + + +
+ strategy: the palette currently shipping in core/images.py as ANYPLOT_PALETTE — kept here as the bar every v1 candidate is measured against. all v1 first-4 scores are reported as a delta against this row.
+ paper-ink corridor: J' ∈ [45, 72], C ∈ [22, 36]. + first-4 reordered to maximise min worst-CVD ΔE within {1..4}, pairwise hue gap ≥60°. +
+ first-4 worst-CVD min ΔE15.61 (this is the bar) + all-pairs normal min ΔE24.00 +
+
+ +
+

palette

+

7 hues + 2 adaptive neutrals. positions 1–4 are the "first-4 most beautiful" subset chosen to maximise min worst-CVD ΔE. positions 5–7 follow in descending min-distance-to-the-first-4. neutrals stay theme-adaptive (same as today's design tokens).

+
#009E73cyan
#9418DBpurple
#B71D27orange
#16B8F3azure
#99B314green
#D359A7pink
#BA843Eyellow
#1A1A17neutral·light
#F0EFE8neutral·dark
+
+ +
+

color wheel

+

CAM02-UCS hue ring at L=60, C=40. each palette dot sits at its actual (C, H) coordinates — angle is the hue, distance from centre is the chroma. dashed circles mark this variant's chroma corridor. the brand-anchor green is marked with a star.

+
90°180°270°1·#009E73 (brand anchor)#009E732·#9418DB#9418DB3·#B71D27#B71D274·#16B8F3#16B8F35·#99B314#99B3146·#D359A7#D359A77·#BA843E#BA843E
+
+ +
+

sample & first-n

+

first-4 chart on both production bg-page surfaces. the first-n table reads as "if you only use the first n positions, what's the weakest pair under normal vision vs. worst CVD".

+
light · lines — bg-page #F5F3EC0255075100dark · lines — bg-page #1212100255075100light — barsQ1Q2Q3Q4dark — barsQ1Q2Q3Q4light — scatterdark — scatter
+
worst pair if you use only positions 1..n
addednormalworst-cvd
n=2+ purple58.941.5
n=3+ orange46.519.1
n=4+ azure32.015.6
n=5+ green28.215.6
n=6+ pink28.215.6
n=7+ yellow24.09.7
+
+ +
+

ΔE matrix

+

normal vision left, worst-of-3-cvd right. cells coloured by the 4-step Petroff-2021 scale: ≥15 optimal, 10–15 okay, 5–10 marginal, <5 confusable.

+
normal vision
cyanpurpleorangeazuregreenpinkyellowneutral·lightneutral·dark
cyan58.959.532.028.255.235.552.043.2
purple58.946.547.972.628.555.149.663.3
orange59.546.569.554.828.231.448.462.2
azure32.047.969.552.451.652.065.939.2
green28.272.654.852.457.124.065.638.2
pink55.228.528.251.657.135.757.546.8
yellow35.555.131.452.024.035.754.538.3
neutral·light52.049.648.465.965.657.554.582.5
neutral·dark43.263.362.239.238.246.838.382.5
worst of 3 cvd (deuter · protan · tritan)
cyanpurpleorangeazuregreenpinkyellowneutral·lightneutral·dark
cyan41.519.115.621.917.311.846.032.9
purple41.533.226.135.017.424.538.852.5
orange19.133.251.825.919.317.323.051.9
azure15.626.151.825.817.047.363.231.4
green21.935.025.925.835.19.758.525.0
pink17.317.419.317.035.111.445.836.4
yellow11.824.517.347.39.711.450.536.9
neutral·light46.038.823.063.258.545.850.582.4
neutral·dark32.952.551.931.425.036.436.982.4
+
ΔE ≥ 15 — optimal (Petroff 2021 target)10 ≤ ΔE < 15 — okay, below comfort threshold5 ≤ ΔE < 10 — marginalΔE < 5 — confusable
+
+ +
+

continuous colormaps

+

two cmaps derived from this variant's palette: a sequential (brand-green → dark blue-zone palette member) and a diverging (warmest palette member ↔ coolest palette member through a near-neutral). hues come from the palette so the cmap reads as the same identity; J' and C are tuned for monotonic lightness descent (sequential) or symmetric weight (diverging). below each gradient: MATLAB's peaks surface rendered with that cmap.

+
green → dark azure
worst Δ: 0.29 (protanopia)
+
light — bg-page #F5F3EC
continuous palette applied to the peaks bivariate function
dark — bg-page #121210
continuous palette applied to the peaks bivariate function
sequential · green → dark azure
+
orange ↔ azure diverging
worst Δ: 0.61 (tritanopia)
+
light — bg-page #F5F3EC
continuous palette applied to the peaks bivariate function
dark — bg-page #121210
continuous palette applied to the peaks bivariate function
diverging · orange ↔ azure diverging
+
+ +
+

on the website

+

hero mockup pair using this variant's brand position-1 colour as the green-dot anchor. wcag badges live-update against the production bg-page surfaces.

+
+
+
light — bg-page #F5F3EC
+ +
+ + — the open plot catalogue 4.89:1 AA +
+ +
+ anyplot() + 3.08:1 AA +
+
— any library. 15.71:1 AAA 15.71:1 AAA
+ +
one spec · every library · always current. 15.71:1 AAA
+ +

+ every plot begins as a library-agnostic spec. 8.03:1 AAA +

+ +
+ steal like an artist. + 15.71:1 AAA +
+ +
+ .browse() + .browse() hover + or connect via mcp → +
+
+ filled cta: 15.71:1 AAA + hover green: 3.42:1 AA + secondary link: 8.03:1 AAA +
+ +
+
bg-page #F5F3EC
+
bg-surface #FAF8F1
+
bg-elevated #FFFDF6
+
+
+ +
+
dark — bg-page #121210
+ +
+ + — the open plot catalogue 7.76:1 AAA +
+ +
+ anyplot() + 5.48:1 AAA +
+
— any library. 16.27:1 AAA 16.27:1 AAA
+ +
one spec · every library · always current. 16.27:1 AAA
+ +

+ every plot begins as a library-agnostic spec. 9.32:1 AAA +

+ +
+ steal like an artist. + 16.27:1 AAA +
+ +
+ .browse() + .browse() hover + or connect via mcp → +
+
+ filled cta: 16.27:1 AAA + hover green: 3.42:1 AA + secondary link: 9.32:1 AAA +
+ +
+
bg-page #121210
+
bg-surface #1A1A17
+
bg-elevated #242420
+
+
+
+
+ + + + diff --git a/docs/reference/palette-variants-v1/D1-8-tight-chroma-8.html b/docs/reference/palette-variants-v1/D1-8-tight-chroma-8.html new file mode 100644 index 0000000000..c73fce3878 --- /dev/null +++ b/docs/reference/palette-variants-v1/D1-8-tight-chroma-8.html @@ -0,0 +1,760 @@ + + + + + +variant D1-8. d-tight-chroma-8 — anyplot palette v1 + + + +
+

any.plot() — variant D1-8. d-tight-chroma-8

+
CAM02-UCS · v1 · #5817
+ +
+ + + +
+ strategy: D1's tight chroma corridor (C ∈ [24, 32]) expanded to 8 hues — D1's 7 picks leave a 75° purple→red back-gap, the 8th slot is greedy-picked there for a matte rosé that bridges purple and red while staying inside the corridor. direct 8↔8 counterpart to D3.
+ paper-ink corridor: J' ∈ [45, 72], C ∈ [24, 32]. + first-4 reordered to maximise min worst-CVD ΔE within {1..4}, pairwise hue gap ≥60°. +
+ first-4 worst-CVD min ΔE17.44 (+1.84 vs live D 15.61) + all-pairs normal min ΔE20.73 +
+
+ +
+

palette

+

8 hues + 2 adaptive neutrals. positions 1–4 are the "first-4 most beautiful" subset chosen to maximise min worst-CVD ΔE. positions 5–8 follow in descending min-distance-to-the-first-4. neutrals stay theme-adaptive (same as today's design tokens).

+
#009E73cyan
#AE3030orange
#C475FDpurple
#99B314green
#4467A3blue
#2ABCCDazure
#954477magenta
#BD8233lime
#1A1A17neutral·light
#F0EFE8neutral·dark
+
+ +
+

color wheel

+

CAM02-UCS hue ring at L=60, C=40. each palette dot sits at its actual (C, H) coordinates — angle is the hue, distance from centre is the chroma. dashed circles mark this variant's chroma corridor. the brand-anchor green is marked with a star. toggle the overlay to see live D's dot positions for comparison.

+
90°180°270°1·#009E73 (brand anchor)#009E732·#AE3030#AE30303·#C475FD#C475FD4·#99B314#99B3145·#4467A3#4467A36·#2ABCCD#2ABCCD7·#954477#9544778·#BD8233#BD8233
+
+ +
+

sample & first-n

+

first-4 chart on both production bg-page surfaces. the first-n table reads as "if you only use the first n positions, what's the weakest pair under normal vision vs. worst CVD".

+
light · lines — bg-page #F5F3EC0255075100dark · lines — bg-page #1212100255075100light — barsQ1Q2Q3Q4dark — barsQ1Q2Q3Q4light — scatterdark — scatter
+
worst pair if you use only positions 1..n
addednormalworst-cvd
n=2+ orange55.617.4
n=3+ purple46.717.4
n=4+ green28.217.4
n=5+ blue28.216.3
n=6+ azure22.513.7
n=7+ magenta20.710.7
n=8+ lime20.78.8
+
+ +
+

ΔE matrix

+

normal vision left, worst-of-3-cvd right. cells coloured by the 4-step Petroff-2021 scale: ≥15 optimal, 10–15 okay, 5–10 marginal, <5 confusable.

+
normal vision
cyanorangepurplegreenblueazuremagentalimeneutral·lightneutral·dark
cyan55.654.228.236.922.551.036.752.043.2
orange55.646.752.150.963.520.728.845.459.8
purple54.246.763.933.143.330.050.063.844.6
green28.252.163.958.642.757.124.065.638.2
blue36.950.933.158.632.334.051.141.156.3
azure22.563.543.342.732.353.147.663.935.4
magenta51.020.730.057.134.053.137.140.856.6
lime36.728.850.024.051.147.637.155.139.5
neutral·light52.045.463.865.641.163.940.855.182.5
neutral·dark43.259.844.638.256.335.456.639.582.5
worst of 3 cvd (deuter · protan · tritan)
cyanorangepurplegreenblueazuremagentalimeneutral·lightneutral·dark
cyan17.436.221.916.313.719.814.046.032.9
orange17.436.526.936.142.415.218.224.251.3
purple36.236.521.518.513.826.316.757.231.7
green21.926.921.533.124.837.68.858.525.0
blue16.336.118.533.127.410.747.038.953.9
azure13.742.413.824.827.426.239.660.423.9
magenta19.815.226.337.610.726.218.631.951.2
lime14.018.216.78.847.039.618.650.938.0
neutral·light46.024.257.258.538.960.431.950.982.4
neutral·dark32.951.331.725.053.923.951.238.082.4
+
ΔE ≥ 15 — optimal (Petroff 2021 target)10 ≤ ΔE < 15 — okay, below comfort threshold5 ≤ ΔE < 10 — marginalΔE < 5 — confusable
+
+ +
+

continuous colormaps

+

two cmaps derived from this variant's palette: a sequential (brand-green → dark blue-zone palette member) and a diverging (warmest palette member ↔ coolest palette member through a near-neutral). hues come from the palette so the cmap reads as the same identity; J' and C are tuned for monotonic lightness descent (sequential) or symmetric weight (diverging). below each gradient: MATLAB's peaks surface rendered with that cmap.

+
green → dark blue
worst Δ: 0.35 (protanopia)
+
light — bg-page #F5F3EC
continuous palette applied to the peaks bivariate function
dark — bg-page #121210
continuous palette applied to the peaks bivariate function
sequential · green → dark blue
+
orange ↔ blue diverging
worst Δ: 0.63 (tritanopia)
+
light — bg-page #F5F3EC
continuous palette applied to the peaks bivariate function
dark — bg-page #121210
continuous palette applied to the peaks bivariate function
diverging · orange ↔ blue diverging
+
+ +
+

on the website

+

hero mockup pair using this variant's brand position-1 colour as the green-dot anchor. wcag badges live-update against the production bg-page surfaces.

+
+
+
light — bg-page #F5F3EC
+ +
+ + — the open plot catalogue 4.89:1 AA +
+ +
+ anyplot() + 3.08:1 AA +
+
— any library. 15.71:1 AAA 15.71:1 AAA
+ +
one spec · every library · always current. 15.71:1 AAA
+ +

+ every plot begins as a library-agnostic spec. 8.03:1 AAA +

+ +
+ steal like an artist. + 15.71:1 AAA +
+ +
+ .browse() + .browse() hover + or connect via mcp → +
+
+ filled cta: 15.71:1 AAA + hover green: 3.42:1 AA + secondary link: 8.03:1 AAA +
+ +
+
bg-page #F5F3EC
+
bg-surface #FAF8F1
+
bg-elevated #FFFDF6
+
+
+ +
+
dark — bg-page #121210
+ +
+ + — the open plot catalogue 7.76:1 AAA +
+ +
+ anyplot() + 5.48:1 AAA +
+
— any library. 16.27:1 AAA 16.27:1 AAA
+ +
one spec · every library · always current. 16.27:1 AAA
+ +

+ every plot begins as a library-agnostic spec. 9.32:1 AAA +

+ +
+ steal like an artist. + 16.27:1 AAA +
+ +
+ .browse() + .browse() hover + or connect via mcp → +
+
+ filled cta: 16.27:1 AAA + hover green: 3.42:1 AA + secondary link: 9.32:1 AAA +
+ +
+
bg-page #121210
+
bg-surface #1A1A17
+
bg-elevated #242420
+
+
+
+
+ + + + diff --git a/docs/reference/palette-variants-v1/D1-tight-chroma.html b/docs/reference/palette-variants-v1/D1-tight-chroma.html new file mode 100644 index 0000000000..4037144bb3 --- /dev/null +++ b/docs/reference/palette-variants-v1/D1-tight-chroma.html @@ -0,0 +1,760 @@ + + + + + +variant D1. d-tight-chroma — anyplot palette v1 + + + +
+

any.plot() — variant D1. d-tight-chroma

+
CAM02-UCS · v1 · #5817
+ +
+ + + +
+ strategy: live D's max-min ΔE selection but with the paper-ink chroma corridor narrowed to C ∈ [24, 32] — predicts cleaner co-existence in dense charts at the cost of some headroom. live D's semantic red #B71D27 is pinned at position 1 so loss/error/bad can map to the expected colour rather than a tight-corridor brown.
+ paper-ink corridor: J' ∈ [45, 72], C ∈ [24, 32]. + first-4 reordered to maximise min worst-CVD ΔE within {1..4}, pairwise hue gap ≥60°. +
+ first-4 worst-CVD min ΔE17.44 (+1.84 vs live D 15.61) + all-pairs normal min ΔE22.51 +
+
+ +
+

palette

+

7 hues + 2 adaptive neutrals. positions 1–4 are the "first-4 most beautiful" subset chosen to maximise min worst-CVD ΔE. positions 5–7 follow in descending min-distance-to-the-first-4. neutrals stay theme-adaptive (same as today's design tokens).

+
#009E73cyan
#AE3030orange
#C475FDpurple
#99B314green
#4467A3blue
#2ABCCDazure
#BD8233lime
#1A1A17neutral·light
#F0EFE8neutral·dark
+
+ +
+

color wheel

+

CAM02-UCS hue ring at L=60, C=40. each palette dot sits at its actual (C, H) coordinates — angle is the hue, distance from centre is the chroma. dashed circles mark this variant's chroma corridor. the brand-anchor green is marked with a star. toggle the overlay to see live D's dot positions for comparison.

+
90°180°270°1·#009E73 (brand anchor)#009E732·#AE3030#AE30303·#C475FD#C475FD4·#99B314#99B3145·#4467A3#4467A36·#2ABCCD#2ABCCD7·#BD8233#BD8233
+
+ +
+

sample & first-n

+

first-4 chart on both production bg-page surfaces. the first-n table reads as "if you only use the first n positions, what's the weakest pair under normal vision vs. worst CVD".

+
light · lines — bg-page #F5F3EC0255075100dark · lines — bg-page #1212100255075100light — barsQ1Q2Q3Q4dark — barsQ1Q2Q3Q4light — scatterdark — scatter
+
worst pair if you use only positions 1..n
addednormalworst-cvd
n=2+ orange55.617.4
n=3+ purple46.717.4
n=4+ green28.217.4
n=5+ blue28.216.3
n=6+ azure22.513.7
n=7+ lime22.58.8
+
+ +
+

ΔE matrix

+

normal vision left, worst-of-3-cvd right. cells coloured by the 4-step Petroff-2021 scale: ≥15 optimal, 10–15 okay, 5–10 marginal, <5 confusable.

+
normal vision
cyanorangepurplegreenblueazurelimeneutral·lightneutral·dark
cyan55.654.228.236.922.536.752.043.2
orange55.646.752.150.963.528.845.459.8
purple54.246.763.933.143.350.063.844.6
green28.252.163.958.642.724.065.638.2
blue36.950.933.158.632.351.141.156.3
azure22.563.543.342.732.347.663.935.4
lime36.728.850.024.051.147.655.139.5
neutral·light52.045.463.865.641.163.955.182.5
neutral·dark43.259.844.638.256.335.439.582.5
worst of 3 cvd (deuter · protan · tritan)
cyanorangepurplegreenblueazurelimeneutral·lightneutral·dark
cyan17.436.221.916.313.714.046.032.9
orange17.436.526.936.142.418.224.251.3
purple36.236.521.518.513.816.757.231.7
green21.926.921.533.124.88.858.525.0
blue16.336.118.533.127.447.038.953.9
azure13.742.413.824.827.439.660.423.9
lime14.018.216.78.847.039.650.938.0
neutral·light46.024.257.258.538.960.450.982.4
neutral·dark32.951.331.725.053.923.938.082.4
+
ΔE ≥ 15 — optimal (Petroff 2021 target)10 ≤ ΔE < 15 — okay, below comfort threshold5 ≤ ΔE < 10 — marginalΔE < 5 — confusable
+
+ +
+

continuous colormaps

+

two cmaps derived from this variant's palette: a sequential (brand-green → dark blue-zone palette member) and a diverging (warmest palette member ↔ coolest palette member through a near-neutral). hues come from the palette so the cmap reads as the same identity; J' and C are tuned for monotonic lightness descent (sequential) or symmetric weight (diverging). below each gradient: MATLAB's peaks surface rendered with that cmap.

+
green → dark blue
worst Δ: 0.35 (protanopia)
+
light — bg-page #F5F3EC
continuous palette applied to the peaks bivariate function
dark — bg-page #121210
continuous palette applied to the peaks bivariate function
sequential · green → dark blue
+
orange ↔ blue diverging
worst Δ: 0.63 (tritanopia)
+
light — bg-page #F5F3EC
continuous palette applied to the peaks bivariate function
dark — bg-page #121210
continuous palette applied to the peaks bivariate function
diverging · orange ↔ blue diverging
+
+ +
+

on the website

+

hero mockup pair using this variant's brand position-1 colour as the green-dot anchor. wcag badges live-update against the production bg-page surfaces.

+
+
+
light — bg-page #F5F3EC
+ +
+ + — the open plot catalogue 4.89:1 AA +
+ +
+ anyplot() + 3.08:1 AA +
+
— any library. 15.71:1 AAA 15.71:1 AAA
+ +
one spec · every library · always current. 15.71:1 AAA
+ +

+ every plot begins as a library-agnostic spec. 8.03:1 AAA +

+ +
+ steal like an artist. + 15.71:1 AAA +
+ +
+ .browse() + .browse() hover + or connect via mcp → +
+
+ filled cta: 15.71:1 AAA + hover green: 3.42:1 AA + secondary link: 8.03:1 AAA +
+ +
+
bg-page #F5F3EC
+
bg-surface #FAF8F1
+
bg-elevated #FFFDF6
+
+
+ +
+
dark — bg-page #121210
+ +
+ + — the open plot catalogue 7.76:1 AAA +
+ +
+ anyplot() + 5.48:1 AAA +
+
— any library. 16.27:1 AAA 16.27:1 AAA
+ +
one spec · every library · always current. 16.27:1 AAA
+ +

+ every plot begins as a library-agnostic spec. 9.32:1 AAA +

+ +
+ steal like an artist. + 16.27:1 AAA +
+ +
+ .browse() + .browse() hover + or connect via mcp → +
+
+ filled cta: 16.27:1 AAA + hover green: 3.42:1 AA + secondary link: 9.32:1 AAA +
+ +
+
bg-page #121210
+
bg-surface #1A1A17
+
bg-elevated #242420
+
+
+
+
+ + + + diff --git a/docs/reference/palette-variants-v1/D3-expand-8.html b/docs/reference/palette-variants-v1/D3-expand-8.html new file mode 100644 index 0000000000..f09ccba297 --- /dev/null +++ b/docs/reference/palette-variants-v1/D3-expand-8.html @@ -0,0 +1,760 @@ + + + + + +variant D3. expand-8 — anyplot palette v1 + + + +
+

any.plot() — variant D3. expand-8

+
CAM02-UCS · v1 · #5817
+ +
+ + + +
+ strategy: all 7 of live D's hues are pinned and the algorithm picks one extra 8th hue freely in the largest remaining wheel gap — tan (#BA843E ≈ H70°) and the new pick (indigo ≈ H270°) sit diametrically opposite, filling both remaining slots without forcing a swap.
+ paper-ink corridor: J' ∈ [45, 72], C ∈ [22, 36]. + first-4 reordered to maximise min worst-CVD ΔE within {1..4}, pairwise hue gap ≥60°. +
+ first-4 worst-CVD min ΔE15.61 (+0.00 vs live D 15.61) + all-pairs normal min ΔE23.49 +
+
+ +
+

palette

+

8 hues + 2 adaptive neutrals. positions 1–4 are the "first-4 most beautiful" subset chosen to maximise min worst-CVD ΔE. positions 5–8 follow in descending min-distance-to-the-first-4. neutrals stay theme-adaptive (same as today's design tokens).

+
#009E73cyan
#9418DBpurple
#B71D27orange
#16B8F3azure
#99B314green
#D359A7pink
#7981FDindigo
#BA843Eyellow
#1A1A17neutral·light
#F0EFE8neutral·dark
+
+ +
+

color wheel

+

CAM02-UCS hue ring at L=60, C=40. each palette dot sits at its actual (C, H) coordinates — angle is the hue, distance from centre is the chroma. dashed circles mark this variant's chroma corridor. the brand-anchor green is marked with a star. toggle the overlay to see live D's dot positions for comparison.

+
90°180°270°1·#009E73 (brand anchor)#009E732·#9418DB#9418DB3·#B71D27#B71D274·#16B8F3#16B8F35·#99B314#99B3146·#D359A7#D359A77·#7981FD#7981FD8·#BA843E#BA843E
+
+ +
+

sample & first-n

+

first-4 chart on both production bg-page surfaces. the first-n table reads as "if you only use the first n positions, what's the weakest pair under normal vision vs. worst CVD".

+
light · lines — bg-page #F5F3EC0255075100dark · lines — bg-page #1212100255075100light — barsQ1Q2Q3Q4dark — barsQ1Q2Q3Q4light — scatterdark — scatter
+
worst pair if you use only positions 1..n
addednormalworst-cvd
n=2+ purple58.941.5
n=3+ orange46.519.1
n=4+ azure32.015.6
n=5+ green28.215.6
n=6+ pink28.215.6
n=7+ indigo23.511.9
n=8+ yellow23.59.7
+
+ +
+

ΔE matrix

+

normal vision left, worst-of-3-cvd right. cells coloured by the 4-step Petroff-2021 scale: ≥15 optimal, 10–15 okay, 5–10 marginal, <5 confusable.

+
normal vision
cyanpurpleorangeazuregreenpinkindigoyellowneutral·lightneutral·dark
cyan58.959.532.028.255.245.335.552.043.2
purple58.946.547.972.628.526.055.149.663.3
orange59.546.569.554.828.259.531.448.462.2
azure32.047.969.552.451.623.552.065.939.2
green28.272.654.852.457.163.324.065.638.2
pink55.228.528.251.657.137.835.757.546.8
indigo45.326.059.523.563.337.853.358.748.3
yellow35.555.131.452.024.035.753.354.538.3
neutral·light52.049.648.465.965.657.558.754.582.5
neutral·dark43.263.362.239.238.246.848.338.382.5
worst of 3 cvd (deuter · protan · tritan)
cyanpurpleorangeazuregreenpinkindigoyellowneutral·lightneutral·dark
cyan41.519.115.621.917.311.911.846.032.9
purple41.533.226.135.017.414.224.538.852.5
orange19.133.251.825.919.353.617.323.051.9
azure15.626.151.825.817.013.247.363.231.4
green21.935.025.925.835.125.69.758.525.0
pink17.317.419.317.035.115.811.445.836.4
indigo11.914.253.613.225.615.843.353.940.7
yellow11.824.517.347.39.711.443.350.536.9
neutral·light46.038.823.063.258.545.853.950.582.4
neutral·dark32.952.551.931.425.036.440.736.982.4
+
ΔE ≥ 15 — optimal (Petroff 2021 target)10 ≤ ΔE < 15 — okay, below comfort threshold5 ≤ ΔE < 10 — marginalΔE < 5 — confusable
+
+ +
+

continuous colormaps

+

two cmaps derived from this variant's palette: a sequential (brand-green → dark blue-zone palette member) and a diverging (warmest palette member ↔ coolest palette member through a near-neutral). hues come from the palette so the cmap reads as the same identity; J' and C are tuned for monotonic lightness descent (sequential) or symmetric weight (diverging). below each gradient: MATLAB's peaks surface rendered with that cmap.

+
green → dark azure
worst Δ: 0.29 (protanopia)
+
light — bg-page #F5F3EC
continuous palette applied to the peaks bivariate function
dark — bg-page #121210
continuous palette applied to the peaks bivariate function
sequential · green → dark azure
+
orange ↔ azure diverging
worst Δ: 0.61 (tritanopia)
+
light — bg-page #F5F3EC
continuous palette applied to the peaks bivariate function
dark — bg-page #121210
continuous palette applied to the peaks bivariate function
diverging · orange ↔ azure diverging
+
+ +
+

on the website

+

hero mockup pair using this variant's brand position-1 colour as the green-dot anchor. wcag badges live-update against the production bg-page surfaces.

+
+
+
light — bg-page #F5F3EC
+ +
+ + — the open plot catalogue 4.89:1 AA +
+ +
+ anyplot() + 3.08:1 AA +
+
— any library. 15.71:1 AAA 15.71:1 AAA
+ +
one spec · every library · always current. 15.71:1 AAA
+ +

+ every plot begins as a library-agnostic spec. 8.03:1 AAA +

+ +
+ steal like an artist. + 15.71:1 AAA +
+ +
+ .browse() + .browse() hover + or connect via mcp → +
+
+ filled cta: 15.71:1 AAA + hover green: 3.42:1 AA + secondary link: 8.03:1 AAA +
+ +
+
bg-page #F5F3EC
+
bg-surface #FAF8F1
+
bg-elevated #FFFDF6
+
+
+ +
+
dark — bg-page #121210
+ +
+ + — the open plot catalogue 7.76:1 AAA +
+ +
+ anyplot() + 5.48:1 AAA +
+
— any library. 16.27:1 AAA 16.27:1 AAA
+ +
one spec · every library · always current. 16.27:1 AAA
+ +

+ every plot begins as a library-agnostic spec. 9.32:1 AAA +

+ +
+ steal like an artist. + 16.27:1 AAA +
+ +
+ .browse() + .browse() hover + or connect via mcp → +
+
+ filled cta: 16.27:1 AAA + hover green: 3.42:1 AA + secondary link: 9.32:1 AAA +
+ +
+
bg-page #121210
+
bg-surface #1A1A17
+
bg-elevated #242420
+
+
+
+
+ + + + diff --git a/docs/reference/palette-variants-v1/T-tetradic.html b/docs/reference/palette-variants-v1/T-tetradic.html new file mode 100644 index 0000000000..d9a4ece705 --- /dev/null +++ b/docs/reference/palette-variants-v1/T-tetradic.html @@ -0,0 +1,760 @@ + + + + + +variant T. tetradic — anyplot palette v1 + + + +
+

any.plot() — variant T. tetradic

+
CAM02-UCS · v1 · #5817
+ +
+ + + +
+ strategy: four hue anchors 90° apart starting at brand green (the tetradic rule), then three mid-quadrant fillers — forces opposite-axis coverage that balanced max-min sometimes skips.
+ paper-ink corridor: J' ∈ [45, 72], C ∈ [24, 38]. + first-4 reordered to maximise min worst-CVD ΔE within {1..4}, pairwise hue gap ≥60°. +
+ first-4 worst-CVD min ΔE10.94 (-4.67 vs live D 15.61) + all-pairs normal min ΔE23.56 +
+
+ +
+

palette

+

7 hues + 2 adaptive neutrals. positions 1–4 are the "first-4 most beautiful" subset chosen to maximise min worst-CVD ΔE. positions 5–7 follow in descending min-distance-to-the-first-4. neutrals stay theme-adaptive (same as today's design tokens).

+
#009E73cyan
#4C65A5indigo
#DD85C1magenta
#B4882Elime
#B2282Corange
#B162FEpurple
#2CB5CEazure
#1A1A17neutral·light
#F0EFE8neutral·dark
+
+ +
+

color wheel

+

CAM02-UCS hue ring at L=60, C=40. each palette dot sits at its actual (C, H) coordinates — angle is the hue, distance from centre is the chroma. dashed circles mark this variant's chroma corridor. the brand-anchor green is marked with a star. toggle the overlay to see live D's dot positions for comparison.

+
90°180°270°1·#009E73 (brand anchor)#009E732·#4C65A5#4C65A53·#DD85C1#DD85C14·#B4882E#B4882E5·#B2282C#B2282C6·#B162FE#B162FE7·#2CB5CE#2CB5CE
+
+ +
+

sample & first-n

+

first-4 chart on both production bg-page surfaces. the first-n table reads as "if you only use the first n positions, what's the weakest pair under normal vision vs. worst CVD".

+
light · lines — bg-page #F5F3EC0255075100dark · lines — bg-page #1212100255075100light — barsQ1Q2Q3Q4dark — barsQ1Q2Q3Q4light — scatterdark — scatter
+
worst pair if you use only positions 1..n
addednormalworst-cvd
n=2+ indigo38.316.8
n=3+ magenta38.316.8
n=4+ lime33.810.9
n=5+ orange33.610.9
n=6+ purple24.110.9
n=7+ azure23.67.9
+
+ +
+

ΔE matrix

+

normal vision left, worst-of-3-cvd right. cells coloured by the 4-step Petroff-2021 scale: ≥15 optimal, 10–15 okay, 5–10 marginal, <5 confusable.

+
normal vision
cyanindigomagentalimeorangepurpleazureneutral·lightneutral·dark
cyan38.350.633.857.454.523.652.043.2
indigo38.340.251.351.627.030.641.256.3
magenta50.640.237.835.424.144.463.535.6
lime33.851.337.833.654.846.655.139.2
orange57.451.635.433.648.764.446.861.0
purple54.527.024.154.848.741.760.150.0
azure23.630.644.446.664.441.762.236.9
neutral·light52.041.263.555.146.860.162.282.5
neutral·dark43.256.335.639.261.050.036.982.5
worst of 3 cvd (deuter · protan · tritan)
cyanindigomagentalimeorangepurpleazureneutral·lightneutral·dark
cyan16.821.315.118.235.212.046.032.9
indigo16.819.742.537.515.725.038.353.7
magenta21.319.710.930.719.47.956.627.8
lime15.142.510.918.118.741.952.637.1
orange18.237.530.718.138.443.723.551.6
purple35.215.719.418.738.417.351.536.5
azure12.025.07.941.943.717.359.027.0
neutral·light46.038.356.652.623.551.559.082.4
neutral·dark32.953.727.837.151.636.527.082.4
+
ΔE ≥ 15 — optimal (Petroff 2021 target)10 ≤ ΔE < 15 — okay, below comfort threshold5 ≤ ΔE < 10 — marginalΔE < 5 — confusable
+
+ +
+

continuous colormaps

+

two cmaps derived from this variant's palette: a sequential (brand-green → dark blue-zone palette member) and a diverging (warmest palette member ↔ coolest palette member through a near-neutral). hues come from the palette so the cmap reads as the same identity; J' and C are tuned for monotonic lightness descent (sequential) or symmetric weight (diverging). below each gradient: MATLAB's peaks surface rendered with that cmap.

+
green → dark indigo
worst Δ: 0.36 (protanopia)
+
light — bg-page #F5F3EC
continuous palette applied to the peaks bivariate function
dark — bg-page #121210
continuous palette applied to the peaks bivariate function
sequential · green → dark indigo
+
orange ↔ indigo diverging
worst Δ: 0.63 (tritanopia)
+
light — bg-page #F5F3EC
continuous palette applied to the peaks bivariate function
dark — bg-page #121210
continuous palette applied to the peaks bivariate function
diverging · orange ↔ indigo diverging
+
+ +
+

on the website

+

hero mockup pair using this variant's brand position-1 colour as the green-dot anchor. wcag badges live-update against the production bg-page surfaces.

+
+
+
light — bg-page #F5F3EC
+ +
+ + — the open plot catalogue 4.89:1 AA +
+ +
+ anyplot() + 3.08:1 AA +
+
— any library. 15.71:1 AAA 15.71:1 AAA
+ +
one spec · every library · always current. 15.71:1 AAA
+ +

+ every plot begins as a library-agnostic spec. 8.03:1 AAA +

+ +
+ steal like an artist. + 15.71:1 AAA +
+ +
+ .browse() + .browse() hover + or connect via mcp → +
+
+ filled cta: 15.71:1 AAA + hover green: 3.42:1 AA + secondary link: 8.03:1 AAA +
+ +
+
bg-page #F5F3EC
+
bg-surface #FAF8F1
+
bg-elevated #FFFDF6
+
+
+ +
+
dark — bg-page #121210
+ +
+ + — the open plot catalogue 7.76:1 AAA +
+ +
+ anyplot() + 5.48:1 AAA +
+
— any library. 16.27:1 AAA 16.27:1 AAA
+ +
one spec · every library · always current. 16.27:1 AAA
+ +

+ every plot begins as a library-agnostic spec. 9.32:1 AAA +

+ +
+ steal like an artist. + 16.27:1 AAA +
+ +
+ .browse() + .browse() hover + or connect via mcp → +
+
+ filled cta: 16.27:1 AAA + hover green: 3.42:1 AA + secondary link: 9.32:1 AAA +
+ +
+
bg-page #121210
+
bg-surface #1A1A17
+
bg-elevated #242420
+
+
+
+
+ + + + diff --git a/docs/reference/palette-variants-v1/W-warm-pole.html b/docs/reference/palette-variants-v1/W-warm-pole.html new file mode 100644 index 0000000000..2d507e46d9 --- /dev/null +++ b/docs/reference/palette-variants-v1/W-warm-pole.html @@ -0,0 +1,760 @@ + + + + + +variant W. warm-pole — anyplot palette v1 + + + +
+

any.plot() — variant W. warm-pole

+
CAM02-UCS · v1 · #5817
+ +
+ + + +
+ strategy: live D's max-min ΔE selection plus a warm-pole scoring bonus centred at 55° (half-width 30°) — biases picks toward the red/orange/amber band for plots dominated by warm categorical data. live D's semantic red #B71D27 is pinned at position 1 so the warm pole has a true red anchor rather than only orange-browns.
+ paper-ink corridor: J' ∈ [45, 72], C ∈ [22, 36]. + first-4 reordered to maximise min worst-CVD ΔE within {1..4}, pairwise hue gap ≥60°. +
+ first-4 worst-CVD min ΔE15.61 (+0.00 vs live D 15.61) + all-pairs normal min ΔE23.87 +
+
+ +
+

palette

+

7 hues + 2 adaptive neutrals. positions 1–4 are the "first-4 most beautiful" subset chosen to maximise min worst-CVD ΔE. positions 5–7 follow in descending min-distance-to-the-first-4. neutrals stay theme-adaptive (same as today's design tokens).

+
#009E73cyan
#B71D27orange
#8E20E2purple
#16B8F3azure
#99B314green
#914975pink
#BA843Eyellow
#1A1A17neutral·light
#F0EFE8neutral·dark
+
+ +
+

color wheel

+

CAM02-UCS hue ring at L=60, C=40. each palette dot sits at its actual (C, H) coordinates — angle is the hue, distance from centre is the chroma. dashed circles mark this variant's chroma corridor. the brand-anchor green is marked with a star. toggle the overlay to see live D's dot positions for comparison.

+
90°180°270°1·#009E73 (brand anchor)#009E732·#B71D27#B71D273·#8E20E2#8E20E24·#16B8F3#16B8F35·#99B314#99B3146·#914975#9149757·#BA843E#BA843E
+
+ +
+

sample & first-n

+

first-4 chart on both production bg-page surfaces. the first-n table reads as "if you only use the first n positions, what's the weakest pair under normal vision vs. worst CVD".

+
light · lines — bg-page #F5F3EC0255075100dark · lines — bg-page #1212100255075100light — barsQ1Q2Q3Q4dark — barsQ1Q2Q3Q4light — scatterdark — scatter
+
worst pair if you use only positions 1..n
addednormalworst-cvd
n=2+ orange59.519.1
n=3+ purple48.819.1
n=4+ azure32.015.6
n=5+ green28.215.6
n=6+ pink23.915.6
n=7+ yellow23.99.7
+
+ +
+

ΔE matrix

+

normal vision left, worst-of-3-cvd right. cells coloured by the 4-step Petroff-2021 scale: ≥15 optimal, 10–15 okay, 5–10 marginal, <5 confusable.

+
normal vision
cyanorangepurpleazuregreenpinkyellowneutral·lightneutral·dark
cyan59.558.032.028.249.135.552.043.2
orange59.548.869.554.823.931.448.462.2
purple58.048.846.072.825.856.149.663.3
azure32.069.546.052.451.052.065.939.2
green28.254.872.852.455.524.065.638.2
pink49.123.925.851.055.534.839.855.6
yellow35.531.456.152.024.034.854.538.3
neutral·light52.048.449.665.965.639.854.582.5
neutral·dark43.262.263.339.238.255.638.382.5
worst of 3 cvd (deuter · protan · tritan)
cyanorangepurpleazuregreenpinkyellowneutral·lightneutral·dark
cyan19.138.915.621.919.011.846.032.9
orange19.138.251.825.919.917.323.051.9
purple38.938.226.033.918.827.438.051.8
azure15.651.826.025.830.347.363.231.4
green21.925.933.925.836.19.758.525.0
pink19.019.918.830.336.118.531.850.9
yellow11.817.327.447.39.718.550.536.9
neutral·light46.023.038.063.258.531.850.582.4
neutral·dark32.951.951.831.425.050.936.982.4
+
ΔE ≥ 15 — optimal (Petroff 2021 target)10 ≤ ΔE < 15 — okay, below comfort threshold5 ≤ ΔE < 10 — marginalΔE < 5 — confusable
+
+ +
+

continuous colormaps

+

two cmaps derived from this variant's palette: a sequential (brand-green → dark blue-zone palette member) and a diverging (warmest palette member ↔ coolest palette member through a near-neutral). hues come from the palette so the cmap reads as the same identity; J' and C are tuned for monotonic lightness descent (sequential) or symmetric weight (diverging). below each gradient: MATLAB's peaks surface rendered with that cmap.

+
green → dark azure
worst Δ: 0.29 (protanopia)
+
light — bg-page #F5F3EC
continuous palette applied to the peaks bivariate function
dark — bg-page #121210
continuous palette applied to the peaks bivariate function
sequential · green → dark azure
+
orange ↔ azure diverging
worst Δ: 0.61 (tritanopia)
+
light — bg-page #F5F3EC
continuous palette applied to the peaks bivariate function
dark — bg-page #121210
continuous palette applied to the peaks bivariate function
diverging · orange ↔ azure diverging
+
+ +
+

on the website

+

hero mockup pair using this variant's brand position-1 colour as the green-dot anchor. wcag badges live-update against the production bg-page surfaces.

+
+
+
light — bg-page #F5F3EC
+ +
+ + — the open plot catalogue 4.89:1 AA +
+ +
+ anyplot() + 3.08:1 AA +
+
— any library. 15.71:1 AAA 15.71:1 AAA
+ +
one spec · every library · always current. 15.71:1 AAA
+ +

+ every plot begins as a library-agnostic spec. 8.03:1 AAA +

+ +
+ steal like an artist. + 15.71:1 AAA +
+ +
+ .browse() + .browse() hover + or connect via mcp → +
+
+ filled cta: 15.71:1 AAA + hover green: 3.42:1 AA + secondary link: 8.03:1 AAA +
+ +
+
bg-page #F5F3EC
+
bg-surface #FAF8F1
+
bg-elevated #FFFDF6
+
+
+ +
+
dark — bg-page #121210
+ +
+ + — the open plot catalogue 7.76:1 AAA +
+ +
+ anyplot() + 5.48:1 AAA +
+
— any library. 16.27:1 AAA 16.27:1 AAA
+ +
one spec · every library · always current. 16.27:1 AAA
+ +

+ every plot begins as a library-agnostic spec. 9.32:1 AAA +

+ +
+ steal like an artist. + 16.27:1 AAA +
+ +
+ .browse() + .browse() hover + or connect via mcp → +
+
+ filled cta: 16.27:1 AAA + hover green: 3.42:1 AA + secondary link: 9.32:1 AAA +
+ +
+
bg-page #121210
+
bg-surface #1A1A17
+
bg-elevated #242420
+
+
+
+
+ + + + diff --git a/docs/reference/palette-variants-v1/compare.html b/docs/reference/palette-variants-v1/compare.html new file mode 100644 index 0000000000..5f50bbd7fb --- /dev/null +++ b/docs/reference/palette-variants-v1/compare.html @@ -0,0 +1,798 @@ + + + + + +palette variants v1 — side-by-side compare (#5817) + + + +
+

any.plot() — palette variants v1 · compare

+
CAM02-UCS · v1 · #5817
+ +
+ +
+

all candidates side-by-side against live D. each card shows the full 7-hue + 2-neutral palette (left to right), both palette-derived continuous colormaps (sequential green→dark blue-zone, diverging warmest↔coolest), and a peaks-function preview of each cmap. baseline live D first-4 worst-CVD ΔE = 15.61 — every candidate's Δ is reported against that.

+ +
+
+
+ D +

baseline

+
+
+ first-4 worst-CVD15.61 + ★ baseline + open full ↗ +
+
+

the palette currently shipping in core/images.py — every candidate below is measured against this row.

+
+
+
+
sequential — green → dark azure
+
+ peaks (sequential) +
+
+
diverging — orange ↔ azure diverging
+
+ peaks (diverging) +
+
+
+ +
+
+
+ D1 +

d-tight-chroma

+
+
+ first-4 worst-CVD17.44 + +1.84 vs live D + open full ↗ +
+
+

live D's max-min ΔE selection but with the paper-ink chroma corridor narrowed to C ∈ [24, 32] — predicts cleaner co-existence in dense charts at the cost of some headroom. live D's semantic red #B71D27 is pinned at position 1 so loss/error/bad can map to the expected colour rather than a tight-corridor brown.

+
+
+
+
sequential — green → dark blue
+
+ peaks (sequential) +
+
+
diverging — orange ↔ blue diverging
+
+ peaks (diverging) +
+
+
+ +
+
+
+ D1-8 +

d-tight-chroma-8

+
+
+ first-4 worst-CVD17.44 + +1.84 vs live D + open full ↗ +
+
+

D1's tight chroma corridor (C ∈ [24, 32]) expanded to 8 hues — D1's 7 picks leave a 75° purple→red back-gap, the 8th slot is greedy-picked there for a matte rosé that bridges purple and red while staying inside the corridor. direct 8↔8 counterpart to D3.

+
+
+
+
sequential — green → dark blue
+
+ peaks (sequential) +
+
+
diverging — orange ↔ blue diverging
+
+ peaks (diverging) +
+
+
+ +
+
+
+ D3 +

expand-8

+
+
+ first-4 worst-CVD15.61 + +0.00 vs live D + open full ↗ +
+
+

all 7 of live D's hues are pinned and the algorithm picks one extra 8th hue freely in the largest remaining wheel gap — tan (#BA843E ≈ H70°) and the new pick (indigo ≈ H270°) sit diametrically opposite, filling both remaining slots without forcing a swap.

+
+
+
+
sequential — green → dark azure
+
+ peaks (sequential) +
+
+
diverging — orange ↔ azure diverging
+
+ peaks (diverging) +
+
+
+ +
+
+
+ T +

tetradic

+
+
+ first-4 worst-CVD10.94 + -4.67 vs live D + open full ↗ +
+
+

four hue anchors 90° apart starting at brand green (the tetradic rule), then three mid-quadrant fillers — forces opposite-axis coverage that balanced max-min sometimes skips.

+
+
+
+
sequential — green → dark indigo
+
+ peaks (sequential) +
+
+
diverging — orange ↔ indigo diverging
+
+ peaks (diverging) +
+
+
+ +
+
+
+ W +

warm-pole

+
+
+ first-4 worst-CVD15.61 + +0.00 vs live D + open full ↗ +
+
+

live D's max-min ΔE selection plus a warm-pole scoring bonus centred at 55° (half-width 30°) — biases picks toward the red/orange/amber band for plots dominated by warm categorical data. live D's semantic red #B71D27 is pinned at position 1 so the warm pole has a true red anchor rather than only orange-browns.

+
+
+
+
sequential — green → dark azure
+
+ peaks (sequential) +
+
+
diverging — orange ↔ azure diverging
+
+ peaks (diverging) +
+
+
+ +
+ + + diff --git a/docs/reference/palette-variants-v1/index.html b/docs/reference/palette-variants-v1/index.html new file mode 100644 index 0000000000..7f0c3e6836 --- /dev/null +++ b/docs/reference/palette-variants-v1/index.html @@ -0,0 +1,1004 @@ + + + + + +palette variants v1 — anyplot #5817 + + + +
+

any.plot() — palette variants v1 (#5817)

+
CAM02-UCS · v1 challenges live D · Petroff target ≥ 15
+ +
+ + +
+

v0 (palette-variants/) explored 6 candidates against Okabe-Ito and led to + variant D being adopted as the live ANYPLOT_PALETTE in + core/images.py. v1 reverses the framing: every candidate here is measured against + live D, not Okabe-Ito. the bar is D's own first-4 worst-CVD ΔE + of 15.61. a candidate that doesn't measurably beat that is not + worth the migration cost.

+

v1 splits into refine (three D-family tweaks D1/D2/D3) and rethink + (two fresh strategies T tetradic and W warm-pole). same paper-ink corridor + (J' ∈ [45, 72], C ∈ [22, 50]) and same greedy max-min ΔE under + normal + 3 CVD conditions. each candidate page includes a CAM02-UCS color wheel + that places every hue at its actual (C, H) coordinates — toggle the overlay to + see how the candidate's geometry compares with live D.

+
+ +
+
90°180°270°1·#009E73 (brand anchor)#009E732·#9418DB#9418DB3·#B71D27#B71D274·#16B8F3#16B8F35·#99B314#99B3146·#D359A7#D359A77·#BA843E#BA843E
+
+

live D on the color wheel

+

each dot sits at its real (C, H) coordinates in CAM02-UCS — angle is the hue, + distance from centre is the chroma. dashed rings mark the paper-ink corridor + (C ∈ [22, 36]). brand anchor is the green star. click a candidate below to + overlay its dot positions as outlined circles — distance shifts visualise the + chroma/hue cost of each refinement.

+
+ + +
+
+
+ +
+ + +
+ +

D · baseline (live anyplot palette)

+
+

the palette currently shipping in core/images.py — the bar every v1 candidate is measured against. variant D came from the v0 round (Petroff max-min ΔE, paper-ink corridor C ∈ [22, 36]) and has been adopted as the active ANYPLOT_PALETTE.

+
+
+
+
+
+
+
+ first-4 worst-CVD15.61 + all-pairs normal24.00 + Δ-vs-D0.00 +
+
+
1·#009E73 (brand anchor)2·#9418DB3·#B71D274·#16B8F35·#99B3146·#D359A77·#BA843E
+
+
open diagnostic →
+
+ + + +
+ D1 +

d-tight-chroma

+
+

live D's max-min ΔE selection but with the paper-ink chroma corridor narrowed to C ∈ [24, 32] — predicts cleaner co-existence in dense charts at the cost of some headroom. live D's semantic red #B71D27 is pinned at position 1 so loss/error/bad can map to the expected colour rather than a tight-corridor brown.

+
+
+
+
+
+
+
+ first-4 worst-CVD17.44 + all-pairs normal22.51 + Δ-vs-D+1.84 +
+
+
1·#009E73 (brand anchor)2·#AE30303·#C475FD4·#99B3145·#4467A36·#2ABCCD7·#BD8233
+
+
open →
+
+ + +
+ D1-8 +

d-tight-chroma-8

+
+

D1's tight chroma corridor (C ∈ [24, 32]) expanded to 8 hues — D1's 7 picks leave a 75° purple→red back-gap, the 8th slot is greedy-picked there for a matte rosé that bridges purple and red while staying inside the corridor. direct 8↔8 counterpart to D3.

+
+
+
+
+
+
+
+ first-4 worst-CVD17.44 + all-pairs normal20.73 + Δ-vs-D+1.84 +
+
+
1·#009E73 (brand anchor)2·#AE30303·#C475FD4·#99B3145·#4467A36·#2ABCCD7·#9544778·#BD8233
+
+
open →
+
+ + +
+ D3 +

expand-8

+
+

all 7 of live D's hues are pinned and the algorithm picks one extra 8th hue freely in the largest remaining wheel gap — tan (#BA843E ≈ H70°) and the new pick (indigo ≈ H270°) sit diametrically opposite, filling both remaining slots without forcing a swap.

+
+
+
+
+
+
+
+ first-4 worst-CVD15.61 + all-pairs normal23.49 + Δ-vs-D+0.00 +
+
+
1·#009E73 (brand anchor)2·#9418DB3·#B71D274·#16B8F35·#99B3146·#D359A77·#7981FD8·#BA843E
+
+
open →
+
+ + +
+ T +

tetradic

+
+

four hue anchors 90° apart starting at brand green (the tetradic rule), then three mid-quadrant fillers — forces opposite-axis coverage that balanced max-min sometimes skips.

+
+
+
+
+
+
+
+ first-4 worst-CVD10.94 + all-pairs normal23.56 + Δ-vs-D-4.67 +
+
+
1·#009E73 (brand anchor)2·#4C65A53·#DD85C14·#B4882E5·#B2282C6·#B162FE7·#2CB5CE
+
+
open →
+
+ + +
+ W +

warm-pole

+
+

live D's max-min ΔE selection plus a warm-pole scoring bonus centred at 55° (half-width 30°) — biases picks toward the red/orange/amber band for plots dominated by warm categorical data. live D's semantic red #B71D27 is pinned at position 1 so the warm pole has a true red anchor rather than only orange-browns.

+
+
+
+
+
+
+
+ first-4 worst-CVD15.61 + all-pairs normal23.87 + Δ-vs-D+0.00 +
+
+
1·#009E73 (brand anchor)2·#B71D273·#8E20E24·#16B8F35·#99B3146·#9149757·#BA843E
+
+
open →
+
+ +
+ +
+

baseline (live D) first-4 worst-CVD min ΔE = 15.61 + — the bar these candidates try to clear. v0 round of variants A–F is preserved at + ../palette-variants/. references: petroff (2021) arXiv:2107.02270, + okabe & ito (2008), wong (2011), machado et al. (2009).

+
+ + + + diff --git a/docs/reference/palette-variants-v2/expert-reviews.md b/docs/reference/palette-variants-v2/expert-reviews.md new file mode 100644 index 0000000000..74a18fc23d --- /dev/null +++ b/docs/reference/palette-variants-v2/expert-reviews.md @@ -0,0 +1,305 @@ +# palette-variants-v2 — expert review synthesis + +Companion to [`index.html`](./index.html). Five independent Opus subagents +were asked to play domain experts from different fields, given only the +hex values, the CVD performance numbers, the warm-paper bg context, and a +pointer to the v2 comparison page. Each was instructed to pick a winner +(or argue for neither) and defend the choice in their domain's idiom. + +## Verdict + +**5 of 5 chose muted-8 (was D1-8).** Unanimous, across domains that +disagree on most other things. + +| Persona | Vote | One-line argument | +|---|---|---| +| Editorial (FT / Economist / NYT Upshot) | **muted-8** | "C ∈ [20–32] is editorial-standard. Vivid reads cheap on coated stock." | +| B2B consulting (McKinsey / BCG / Bain) | **muted-8** | "Chroma reads as opinion. CFOs disengage from 'designed' decks." | +| Scientific publishing (Nature / IEEE) | **muted-8** | "Paul Tol's muted, Okabe-Ito, ColorBrewer Set2 all live in B's range. B = 'OI rebalanced for n=8'." | +| Accessibility / CVD specialist | **muted-8** | "A's vivid red collapses worse under deuteranopia — L-drift narrows the gap with lime." | +| Brand / product design (Linear / Radix) | **muted-8** | "Vivid-8 = '2014 Tableau screenshot'. Muted = Radix / Linear / Notion mood." | + +## Recurring themes across the five reviews + +### 1. Vivid-8 is read as "dated dashboard" + +Every reviewer — independently — placed vivid-8 in roughly the +2014-Tableau era. The industry has spent ~5 years moving away from +high-chroma categorical palettes: Linear, Radix, Stripe, dracula-pro, +the BBC Visual Vocabulary post-2018, Notion's tag colors, Tailwind v4's +P3 accents all sit in muted-8's chroma range. Shipping vivid-8 today +signals "we have not been paying attention." + +### 2. The CVD numerical advantage is illusory + +vivid-8's nominally better worst-pair ΔE (n=4: 17.3 vs 15.2; n=8: +9.8 vs 8.8) is below pixel anti-aliasing resolution at typical chart +sizes and both palettes fall under the 10 ΔE "confident discrimination" +floor at n=8 anyway. The accessibility specialist was explicit: "the +1-point gap at n=8 is noise; the binding constraint is the lime-red +deuteranopia pair, which muted-8 actually handles better because its +matte red has less chroma to rotate toward yellow under simulation." + +### 3. Vivid red is a semantic resource that shouldn't be spent on series-3 + +Both the consulting and editorial reviewers raised this: a true vivid +red (`#B71D27`) is semantically loaded for loss / error / negative +signal. Burning it as the third categorical slot in every plot anyplot +generates wastes that semantic. This argument actually *validates* the +v1 design move that put `#AE3030` at pos 1 of muted-8 via hue-band +pinning instead of using live-D's vivid red. + +### 4. Tertiary tones (rosé, lavender, ochre) read as legitimate in muted-8 + +The scientific and brand reviewers both noted that muted-8's `#954477` +matte rosé, `#C475FD` lavender, and `#BD8233` ochre sit inside the +respected Paul Tol / ColorBrewer / Tableau-CB tradition of desaturated +tertiary tones. Vivid-8's `#D359A7` hot pink and `#7981FD` indigo +read as marketing/dashboard palette in the same contexts. + +## Recurring critique — the red anchor is too soft + +Three of five reviewers (editorial, brand, accessibility implicit) +flagged that muted-8's `#AE3030` is too weak for the semantic-red role +the rest of the argument relies on: + +- **Editorial:** under deuteranopia `#AE3030` sits too close to `#954477` + rosé; "would push to `#B71D27` or `#A41E22`." +- **Brand:** "`#AE3030` is brick, not red. On warm paper it reads + brown-ish. Anchor a true red around `#C8322C` or `#BE2B2B` — keep the + matte chroma envelope, push hue back toward 25°." +- **Accessibility:** corroborated indirectly — matte red has less + hue-rotation under CVD precisely because it has less chroma, which + is good for CVD safety but reduces semantic-red instant-readability. + +This corroborates the project memory note +[`palette_must_anchor_semantic_red`](file:///home/meake/.claude/projects/-home-meake-projects-anyplot/memory/feedback_palette_must_anchor_semantic_red.md) +— the rule that candidate palettes must allow a true red, via explicit +seeding if needed. + +## Recommended next steps + +1. **Re-anchor muted-8's pos-1 red.** Tighten the hue-band constraint + to ~22–26° and let the chroma corridor open to C ∈ [30, 38] *for + that one slot only* (the same kind of exception live-D already makes + for `#B71D27` at C≈44). Target value: around `#BE2B2B` or `#C8322C`. + +2. **Validate the matte rosé hasn't shifted.** A stronger red at pos 1 + changes the max-min CVD landscape — the greedy 8th pick (currently + `#954477`) may want to move; run `palette-variants-v1.py` after the + red fix and confirm the back-gap pick still bridges purple↔red + cleanly. + +3. **Document n>6 companion guidance — but only for unsorted-categorical + chart slots.** All reviewers implicitly or explicitly noted that 8 + categorical lines on a single chart is a worst case. The + recommendation stands for that case: "above 6 series in one chart, + add a redundant encoding (linestyle / marker shape) or switch to + small multiples." See the important caveat in the next section on + semantic picking — that recommendation is _not_ a vote against + having 8 colours in the pool, only against rendering 8 lines on top + of each other and expecting the eye to keep them apart. + +If those three land, muted-8 graduates from "compelling candidate" to +"defensible default upgrade over live-D" with five independent expert +endorsements behind it. + +## Further optimization levers (beyond the three above) + +The three recommended next-steps are the table stakes. Four additional +hooks would each push muted-8 from "good default" toward "state-of-the-art": + +### 4. Separate hex sets for light vs dark theme + +anyplot currently ships the same 8 hexes against both `#F5F3EC` light bg +and `#121210` dark bg. Modern systems (Radix 12-step scales, Apple +`UIColor.systemGreen` per appearance, Tailwind v4 P3) all define +per-theme variants. Some muted-8 hues sit awkwardly on one of the two: +`#4467A3` blue at L≈40 has only ~3 L-points separation from the dark bg +at L≈37. A dark-theme lift of L+12 on the cool half of the palette would +materially improve readability. Cost: a second optimization run + a +runtime mode-switcher; benefit: every chart on dark mode reads cleaner. + +### 5. Named sub-palettes with their own optimal sort per length + +Instead of one canonical 8-tuple from which "the first n" are taken, +ship `anyplot.palette.n3`, `n5`, `n8` as separately optimised slices +from the same hex pool. A chart with 3 series should get the 3 +CVD-most-distinct picks, not "the first 3" of an n=8-optimised +ordering. Concrete example: pure-CVD greedy for n=3 picks +`green / lavender / lime` instead of `green / red / lavender` — +because red↔green collapses under deuteranopia and the algorithm +correctly avoids that pair when it only has to satisfy 3 slots. +Reduces the "I'll just take the first 3" failure mode users fall into. + +### 6. Define the palette in OKLCH + ship a P3 variant + +CSS Color Level 4 (`oklch()`, `color(display-p3 ...)`) has broad +browser support. Defining muted-8 in OKLCH instead of sRGB hex means +modern P3 displays show ~15% wider chroma headroom at *identical* +perceived colour on sRGB displays — no sRGB clipping. Linear and +Stripe ship P3-aware palettes already. Cost: low (notation change, no +algorithm change); benefit: forward-compat with the next 5 years of +display hardware. + +### 7. Explicit grayscale-fallback ordering + +For S/W print (academic supplementary PDFs, fax-machine fallbacks) +only the L values distinguish series. muted-8's hues sorted by L: +`lime(68) ≈ cyan(68) > green(58) > lavender(56) > tan(53) > rosé(45) +> red(42) ≈ blue(40)`. Two pairs (lime/cyan and red/blue) collapse to +indistinguishable greys. A separate `palette.grayscale_order` index that +sorts by max L-spread (so consecutive picks always have ≥10 L apart) +costs a few lines in `_charts_stack` and rescues reproducibility for +scientific figures. None of the experts mentioned this explicitly but +the scientific reviewer's "print + grayscale fallback" remark hinted +at it. + +## Where does muted-8 sit among existing palettes? + +The scientific reviewer's framing — "muted-8 = Okabe-Ito rebalanced +for n=8 line charts" — is the most accurate one-liner, but it's worth +unpacking the broader landscape. muted-8 sits inside a respected +family, which is a feature, not a bug. + +``` +Paul Tol "muted" (9): #332288 #88CCEE #44AA99 #117733 #999933 + #DDCC77 #CC6677 #882255 #AA4499 + +ColorBrewer Set2 (8): #66C2A5 #FC8D62 #8DA0CB #E78AC3 #A6D854 + #FFD92F #E5C494 #B3B3B3 + +Okabe-Ito (8): #000000 #E69F00 #56B4E9 #009E73 #F0E442 + #0072B2 #D55E00 #CC79A7 + +muted-8 (anyplot): #009E73 #AE3030 #C475FD #99B314 #4467A3 + #2ABCCD #954477 #BD8233 +``` + +Distinguishing characteristics versus each: + +- **vs Paul Tol's "muted":** same dustier-tertiary family but muted-8 + is higher-chroma (C∈[24,32] vs Tol's ~C∈[15,25] — Tol is markedly + pastel-leaning), brand-anchored on `#009E73`, has a true semantic red + where Tol only has cool "wine" `#882255` that's too dark for + loss/error mapping. + +- **vs ColorBrewer Set2:** same general restraint but Set2 is genuinely + pastel (L∈[65,80]), reads as "data viz tutorial" rather than + "considered editorial". muted-8 spans a wider L range so dark hues + carry weight on light bg and lights hold up on dark bg. + +- **vs Okabe-Ito:** muted-8 inherits OI's green (`#009E73`) — the + single most-cited CVD-safe brand-green in scientific publishing — + but fixes OI's two known weak points: (1) the orange/vermillion + near-collision (OI's `#E69F00` and `#D55E00` sit only ~25° apart on + the hue wheel) is replaced by separating red (`#AE3030`) and tan + (`#BD8233`) onto opposite sides of the wheel, and (2) OI's yellow + `#F0E442` washes out on cream bg-page — muted-8 has no near-yellow, + using olive-lime `#99B314` instead, which holds up on warm paper. + +- **vs Tableau-10 / D3 schemeCategory10 (vivid-8's reference point):** + ~one chroma-corridor step lower. Same overall hue diversity, + different saturation register. Tableau-10 was designed for dashboard + exploration; muted-8 is designed for published figures + slide decks. + +### Is it too similar to anything specific? + +Honest answer: **no, but it's deliberately *adjacent* to Paul Tol's +"muted"**. Same neighbourhood, distinct hex set, brand-anchored, +better semantic-red handling. The kinship places anyplot in the +respected Tol / ColorBrewer / Okabe-Ito lineage rather than as a +snowflake — the family it's joining is the *right* family for a +generative plot tool whose output lands in mixed academic, editorial, +and consulting contexts. + +The defensible positioning if asked "why not just ship Okabe-Ito": +*OI is excellent but has a known orange-vermillion confusion and uses +a yellow that washes out on warm paper. muted-8 keeps the OI green +everyone already trusts and rebalances the rest of the palette to fix +those two specific weaknesses, while staying inside the same +publication-safe chroma envelope.* That's an upgrade story, not a +reinvention story. + +## Important caveat — the experts missed semantic picking + +The reviewers all treated the palette as a **categorical slot pool**: +"these 8 colours fill positions 0..7 of a chart with N series, and the +question is how well the first n hold up." Under that lens, the +"n>6 needs companion encoding" warning is correct. + +But anyplot's palette also serves a **second, equally important +use case**: **semantic named picking**. When a customer expects: + +- `green energy` → green, never pink +- `profit / gain` → green, never red or brown +- `loss / error / bad` → red, never matte rosé +- `warning` → amber / ochre, never cyan +- `water / cold` → blue, never lime +- `oil / commodity` → tan, never magenta + +…then the user reaches into the palette **by name, by hue family**, not +by position. The picked colour must EXIST in the palette and must be +**unambiguously identifiable as red/green/blue/etc** when grabbed. + +This is documented in the project memory +[`feedback_palette_semantic_exception`](file:///home/meake/.claude/projects/-home-meake-projects-anyplot/memory/feedback_palette_semantic_exception.md) +— the rule that conventions like "bad → red, good → green, +warning → amber" are first-class concerns, not categorical-distinctness +edge cases. + +### What this means for muted-8 + +The semantic-picking use case **argues FOR n=8 as a target, not against +it**. The 8 hues aren't 8 slots all expected to coexist in one chart — +they're 8 **named anchors** in a semantic pool: + +| Semantic role | muted-8 anchor | Why it works | +|---|---|---| +| `good / profit / energy-green` | `#009E73` brand-green | Okabe-Ito green; instant-readable as "green" | +| `bad / loss / error` | `#AE3030` → `#BE2B2B` (after fix) | Needs the red-anchor fix from next-steps #1 | +| `cold / water / cool` | `#4467A3` blue | Clearly readable as primary blue | +| `warning / commodity / earth` | `#BD8233` tan / ochre | Distinct from both red and green | +| `growth / nature / lime-energy` | `#99B314` lime | Distinct from brand-green | +| `info / sky / tech-cool` | `#2ABCCD` cyan | Distinct from blue | +| `creative / artistic / brand-secondary` | `#C475FD` lavender | Tertiary but unambiguous | +| `feminine-coded / health / wellness` | `#954477` matte rosé | Distinct from red AND from lavender | + +Each anchor needs to be **independently recognisable as its +hue-category** when picked solo onto a chart — even if it would never +appear alongside its 7 siblings in one chart. The Pure-CVD-greedy +ordering is the right *default* when the user doesn't care which colour +gets slot 1; but the named API is what actually serves the "green +energy" / "profit-green" / "loss-red" customer expectation. + +### Recommended additional design move + +Ship muted-8 with **both** access patterns documented: + +```python +# Position-based (current default — for "I just need 5 distinct lines") +anyplot.palette[:5] # first 5 by sort order + +# Semantic-named (new — for "I need the loss colour for this series") +anyplot.palette.green # → #009E73 +anyplot.palette.red # → #BE2B2B +anyplot.palette.blue # → #4467A3 +anyplot.palette.ochre # → #BD8233 +anyplot.palette.lime # → #99B314 +anyplot.palette.cyan # → #2ABCCD +anyplot.palette.lavender # → #C475FD +anyplot.palette.rose # → #954477 + +# Semantic-role aliases that map to the anchors above +anyplot.palette.semantic.good # → green +anyplot.palette.semantic.bad # → red +anyplot.palette.semantic.warning # → ochre +anyplot.palette.semantic.info # → cyan +``` + +This costs almost nothing in implementation (it's just dict access) +but turns the palette from "8 things in an array" into "a vocabulary +the customer can speak in." That second framing is what the experts' +"n>6 is bad" warning misses — the n=8 size isn't there to be packed +onto one chart, it's there so the customer never has to compromise on +which named colour they pick. diff --git a/docs/reference/palette-variants-v2/index.html b/docs/reference/palette-variants-v2/index.html new file mode 100644 index 0000000000..be3b55961f --- /dev/null +++ b/docs/reference/palette-variants-v2/index.html @@ -0,0 +1,619 @@ +palette variants v2 — vivid-8 vs muted-8

palette variants v2 — head-to-head

vivid-8 (was D3) vs muted-8 (was D1-8) under 4 different slot orderings. colours are identical between rows; only the position changes. each section is one sorting; compare per-n worst-pair ΔE under CVD against the live sample charts below it.

vivid-8

muted-8

wide chroma corridor C ∈ [22, 36] — live D's 7 hues plus a greedy 8th indigo pick that fills the wheel gap opposite tan. max CVD-headroom, the best worst-pair ΔE at small n.

tight chroma corridor C ∈ [24, 32] — D1's max-min selection plus a matte rosé filling the 75° back-gap between purple and red. lower per-pair ΔE ceiling but flatter co-existence inside dense small-multiple charts.

90°180°270°1·#009E73 (brand anchor)#009E732·#9418DB#9418DB3·#B71D27#B71D274·#16B8F3#16B8F35·#99B314#99B3146·#D359A7#D359A77·#7981FD#7981FD8·#BA843E#BA843E
90°180°270°1·#009E73 (brand anchor)#009E732·#AE3030#AE30303·#C475FD#C475FD4·#99B314#99B3145·#4467A3#4467A36·#2ABCCD#2ABCCD7·#954477#9544778·#BD8233#BD8233
#009E73
#16B8F3
#7981FD
#9418DB
#D359A7
#B71D27
#BA843E
#99B314
#009E73
#2ABCCD
#4467A3
#C475FD
#954477
#AE3030
#BD8233
#99B314

pure-CVD greedy max-min

only pos 0 (brand green) fixed; positions 1..n picked iteratively to maximise min worst-CVD ΔE against the already-placed set. No semantic anchors — pure algorithmic CVD optimisation. Designed to keep the per-n worst-pair curve as high as possible for chart series-count growth.

CVD
n=2n=3n=4n=5n=6n=7n=8
normal
n=2n=3n=4n=5n=6n=7n=8

vivid-8

muted-8

vivid-8 after pure-CVD greedy max-min

muted-8 after pure-CVD greedy max-min

#009E73
#9418DB
#99B314
#B71D27
#D359A7
#16B8F3
#7981FD
#BA843E
#009E73
#C475FD
#99B314
#954477
#AE3030
#2ABCCD
#4467A3
#BD8233
worst-pair ΔEn=2n=3n=4n=5n=6n=7n=8
normal vision58.9228.2028.2028.2028.2023.4923.49
under CVD (min)41.5421.8919.1117.3315.6111.919.75
worst-pair ΔEn=2n=3n=4n=5n=6n=7n=8
normal vision54.1528.2028.2020.7320.7320.7320.73
under CVD (min)36.1921.4519.8115.2013.7010.708.81

light theme

lines

vivid-8 — bg-page #F5F3EC0255075100

bars (all 8)

vivid-8 — barsQ1Q2Q3Q4

pie (first 4)

vivid-8 — pie (first 4)

stocks (first 4)

vivid-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

vivid-8 — scatter (edge-clusters, centre-overlap)

dark theme

lines

vivid-8 — bg-page #1212100255075100

bars (all 8)

vivid-8 — barsQ1Q2Q3Q4

pie (first 4)

vivid-8 — pie (first 4)

stocks (first 4)

vivid-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

vivid-8 — scatter (edge-clusters, centre-overlap)

light theme

lines

muted-8 — bg-page #F5F3EC0255075100

bars (all 8)

muted-8 — barsQ1Q2Q3Q4

pie (first 4)

muted-8 — pie (first 4)

stocks (first 4)

muted-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

muted-8 — scatter (edge-clusters, centre-overlap)

dark theme

lines

muted-8 — bg-page #1212100255075100

bars (all 8)

muted-8 — barsQ1Q2Q3Q4

pie (first 4)

muted-8 — pie (first 4)

stocks (first 4)

muted-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

muted-8 — scatter (edge-clusters, centre-overlap)

wheel-gap-first (v1 reorder_first_4)

v1 algorithm: among 3-tuples joining brand green, pick the one whose 4-set has the widest pairwise hue-gap at ≥60° (degrades in 5° steps if no quadruple satisfies); rest by descending min-distance to first-4. Trades CVD distinctness for visual wheel symmetry.

CVD
n=2n=3n=4n=5n=6n=7n=8
normal
n=2n=3n=4n=5n=6n=7n=8

vivid-8

muted-8

vivid-8 after wheel-gap-first (v1 reorder_first_4)

muted-8 after wheel-gap-first (v1 reorder_first_4)

#009E73
#9418DB
#B71D27
#16B8F3
#99B314
#D359A7
#7981FD
#BA843E
#009E73
#4467A3
#954477
#BD8233
#C475FD
#AE3030
#2ABCCD
#99B314
worst-pair ΔEn=2n=3n=4n=5n=6n=7n=8
normal vision58.9246.4931.9928.2028.2023.4923.49
under CVD (min)41.5419.1115.6115.6115.6111.919.75
worst-pair ΔEn=2n=3n=4n=5n=6n=7n=8
normal vision36.8933.9533.9530.0320.7320.7320.73
under CVD (min)16.3410.7010.7010.7010.7010.708.81

light theme

lines

vivid-8 — bg-page #F5F3EC0255075100

bars (all 8)

vivid-8 — barsQ1Q2Q3Q4

pie (first 4)

vivid-8 — pie (first 4)

stocks (first 4)

vivid-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

vivid-8 — scatter (edge-clusters, centre-overlap)

dark theme

lines

vivid-8 — bg-page #1212100255075100

bars (all 8)

vivid-8 — barsQ1Q2Q3Q4

pie (first 4)

vivid-8 — pie (first 4)

stocks (first 4)

vivid-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

vivid-8 — scatter (edge-clusters, centre-overlap)

light theme

lines

muted-8 — bg-page #F5F3EC0255075100

bars (all 8)

muted-8 — barsQ1Q2Q3Q4

pie (first 4)

muted-8 — pie (first 4)

stocks (first 4)

muted-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

muted-8 — scatter (edge-clusters, centre-overlap)

dark theme

lines

muted-8 — bg-page #1212100255075100

bars (all 8)

muted-8 — barsQ1Q2Q3Q4

pie (first 4)

muted-8 — pie (first 4)

stocks (first 4)

muted-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

muted-8 — scatter (edge-clusters, centre-overlap)

hue-order (rainbow)

pos 0 = brand green; remaining 7 hues sorted by hue angle going clockwise around the wheel. Pure “natural rainbow” order — ignores CVD distance entirely. Useful when the chart's series correspond to an ordered category (time, magnitude bins) and the rainbow conveys that order.

CVD
n=2n=3n=4n=5n=6n=7n=8
normal
n=2n=3n=4n=5n=6n=7n=8

vivid-8

muted-8

vivid-8 after hue-order (rainbow)

muted-8 after hue-order (rainbow)

#009E73
#16B8F3
#7981FD
#9418DB
#D359A7
#B71D27
#BA843E
#99B314
#009E73
#2ABCCD
#4467A3
#C475FD
#954477
#AE3030
#BD8233
#99B314
worst-pair ΔEn=2n=3n=4n=5n=6n=7n=8
normal vision31.9923.4923.4923.4923.4923.4923.49
under CVD (min)15.6111.9111.9111.9111.9111.379.75
worst-pair ΔEn=2n=3n=4n=5n=6n=7n=8
normal vision22.5122.5122.5122.5120.7320.7320.73
under CVD (min)13.7013.7013.7010.7010.7010.708.81

light theme

lines

vivid-8 — bg-page #F5F3EC0255075100

bars (all 8)

vivid-8 — barsQ1Q2Q3Q4

pie (first 4)

vivid-8 — pie (first 4)

stocks (first 4)

vivid-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

vivid-8 — scatter (edge-clusters, centre-overlap)

dark theme

lines

vivid-8 — bg-page #1212100255075100

bars (all 8)

vivid-8 — barsQ1Q2Q3Q4

pie (first 4)

vivid-8 — pie (first 4)

stocks (first 4)

vivid-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

vivid-8 — scatter (edge-clusters, centre-overlap)

light theme

lines

muted-8 — bg-page #F5F3EC0255075100

bars (all 8)

muted-8 — barsQ1Q2Q3Q4

pie (first 4)

muted-8 — pie (first 4)

stocks (first 4)

muted-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

muted-8 — scatter (edge-clusters, centre-overlap)

dark theme

lines

muted-8 — bg-page #1212100255075100

bars (all 8)

muted-8 — barsQ1Q2Q3Q4

pie (first 4)

muted-8 — pie (first 4)

stocks (first 4)

muted-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

muted-8 — scatter (edge-clusters, centre-overlap)

every-other-hue (interleaved)

Hue-order full set, then interleave: first-4 = every other wedge for maximally even wheel coverage; the in-between wedges follow as the second-4. The first-4 still spans the whole wheel symmetrically — like wheel-gap-first but constructed via interleaving instead of search.

CVD
n=2n=3n=4n=5n=6n=7n=8
normal
n=2n=3n=4n=5n=6n=7n=8

vivid-8

muted-8

vivid-8 after every-other-hue (interleaved)

muted-8 after every-other-hue (interleaved)

#009E73
#7981FD
#D359A7
#BA843E
#16B8F3
#9418DB
#B71D27
#99B314
#009E73
#4467A3
#954477
#BD8233
#2ABCCD
#C475FD
#AE3030
#99B314
worst-pair ΔEn=2n=3n=4n=5n=6n=7n=8
normal vision45.2737.7635.4823.4923.4923.4923.49
under CVD (min)11.9111.9111.3711.3711.3711.379.75
worst-pair ΔEn=2n=3n=4n=5n=6n=7n=8
normal vision36.8933.9533.9522.5122.5120.7320.73
under CVD (min)16.3410.7010.7010.7010.7010.708.81

light theme

lines

vivid-8 — bg-page #F5F3EC0255075100

bars (all 8)

vivid-8 — barsQ1Q2Q3Q4

pie (first 4)

vivid-8 — pie (first 4)

stocks (first 4)

vivid-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

vivid-8 — scatter (edge-clusters, centre-overlap)

dark theme

lines

vivid-8 — bg-page #1212100255075100

bars (all 8)

vivid-8 — barsQ1Q2Q3Q4

pie (first 4)

vivid-8 — pie (first 4)

stocks (first 4)

vivid-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

vivid-8 — scatter (edge-clusters, centre-overlap)

light theme

lines

muted-8 — bg-page #F5F3EC0255075100

bars (all 8)

muted-8 — barsQ1Q2Q3Q4

pie (first 4)

muted-8 — pie (first 4)

stocks (first 4)

muted-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

muted-8 — scatter (edge-clusters, centre-overlap)

dark theme

lines

muted-8 — bg-page #1212100255075100

bars (all 8)

muted-8 — barsQ1Q2Q3Q4

pie (first 4)

muted-8 — pie (first 4)

stocks (first 4)

muted-8 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

muted-8 — scatter (edge-clusters, centre-overlap)
\ No newline at end of file diff --git a/docs/reference/palette-variants-v3/decision-rationale.md b/docs/reference/palette-variants-v3/decision-rationale.md new file mode 100644 index 0000000000..afe24d7253 --- /dev/null +++ b/docs/reference/palette-variants-v3/decision-rationale.md @@ -0,0 +1,511 @@ +# palette-v3 — muted-8 finalist (decision rationale) + +> Generated by [`scripts/palette-variants-v3.py`](../../../scripts/palette-variants-v3.py). +> Companion to [`index.html`](./index.html) (live charts) and the v2 review at +> [`../palette-variants-v2/expert-reviews.md`](../palette-variants-v2/expert-reviews.md). + +## TL;DR + +After 5 independent expert reviewers unanimously picked **muted-8** over vivid-8, +only the slot ordering was still open. v3 introduces a **hybrid sort** that: + +1. pins brand green `#009E73` at slot 0, +2. fills slots 1..3 by greedy max-min worst-CVD ΔE **constrained to distinct + perceptual hue families** (red / yellow / green / cyan / blue / purple), +3. **defers semantic red `#AE3030` past the first-4 pool** so it's reached via + the named API rather than by position 1..3, and +4. fills slots 4..7 by pure-CVD greedy (no constraint) on the remainder + (deferred red re-enters here). + +The result: visually hue-diverse first 4 slots (no "two greens, two purples" +artefact from pure-CVD-greedy), red kept as a free semantic anchor, and +ΔE_CVD ≥ 14.0 through n=4. + +Semantic red `#AE3030` sits at slot 4, reachable via the +named API (`palette.red`) for loss / error / bad without polluting the +positional default for every chart with ≥ 3 series. + +## CVD prevalence — who are we actually designing for? + +The default CVD simulation (Coblis, color-blindness.com, this repo's +`worst_cvd_pairwise_delta_e`) shows **100% dichromacy**. That covers only the +fully-dichromatic 1–2% of the population, not the much larger anomalous-trichromacy +group. + +| Form | ♂ rate (NW Europe) | ♀ rate | Description | +|---|---|---|---| +| Deuteranomaly (M-cone shifted) | ~5.0% | ~0.35% | Reduced red/green discrimination, residual colour | +| Protanomaly (L-cone shifted) | ~1.0% | ~0.03% | Same, plus red appears darker | +| **Deuteranopia** (M-cone absent) | **~1.0%** | **~0.01%** | Red/green near-indistinguishable | +| **Protanopia** (L-cone absent) | **~1.0%** | **~0.02%** | Same, red looks near-black | +| Tritanopia / Tritanomaly | ~0.01% | ~0.01% | Blue/yellow confusion; usually acquired | +| **Σ all forms** | **~8%** | **~0.5%** | | + +Sources: Sharpe et al. 1999; Birch 2012 *Ophthalmic Physiol Opt* 32(5); Hood et al. 2024. + +**Key consequence:** the ~5% anomalous-trichromacy group has a *partial* cone shift, +not full loss. Population-mean severity for deuteranomaly is ~0.6 (Simunovic 2010, +*Eye* 24, 747–755). At severity 0.6, worst-pair ΔE_CVD is roughly **30-40% higher** +than at 1.0 simulation. + +A palette with worst-pair ΔE_CVD ≈ 10 at full-100% simulation translates to roughly +**14-16 ΔE** for the actual deuteranomaly population — clearly above the discrimination +floor. The 1-2% with full deuteranopia *will* still need a redundant encoding (line +style / marker / label) at n ≥ 6, regardless of which 8-hex palette ships. + +This is why every published categorical palette caps at "min ΔE_CVD ≈ 11" at n=8 +(Okabe-Ito, Paul Tol "muted", Petroff 2021's n=8 reach ~18) rather than chasing +the unreachable ΔE ≥ 15 ceiling. + +## Why n=8? + +The current live `ANYPLOT_PALETTE` in `core/images.py` ships **7 categorical +hues** plus 1 adaptive neutral — the classic Okabe-Ito layout. muted-8 adds +one categorical slot. Four reasons that's the right pool size: + +**1. The 7-hue palette has a documented hue-coverage gap.** With 7 slots you +must pick *either* lime or cyan, *either* lavender or rosé. anyplot live +chose lime + pink and gave up cyan + lavender — both of which are needed for +the named-API roles (`palette.cyan` → info / tech-cool, `palette.lavender` → +creative / brand-secondary). With 8 slots all six primary hue families +(red / yellow / green / cyan / blue / purple) plus two tertiary tones fit. + +**2. n=8 is the CVD-discrimination sweet spot.** The min ΔE_CVD floor falls +roughly geometrically with n in any well-spaced palette: + +| n | muted-8 hybrid-v3 min ΔE_CVD | discrimination floor | +|---|---|---| +| 2..4 | 14–36 | well above | +| 5..6 | 13.70 | safely above | +| 7 | 10.70 | borderline | +| 8 | 8.81 | marginal but usable | +| 9+ | extrapolated < 7 | unusable for CVD | + +Tableau-10 and D3 schemeCategory10 push past this boundary and are known to +fail CVD users (Petroff 2021 §3 measures this). 8 is the largest pool size +where every published categorical palette still clears the practical floor. + +**3. Lineage consistency.** Most academic-publishing categorical palettes sit +at n=7..9: + +| Palette | Slots | Year | +|---|---|---| +| Okabe-Ito (Wong 2011, *Nature Methods*) | 7 cat. + 1 neutral = 8 | 2011 | +| ColorBrewer Set2 | 8 (incl. grey) | 2003 | +| Paul Tol "muted" | 9 (incl. grey) | 2018 | +| Petroff 2021 (*arXiv*) | 8 | 2021 | +| **muted-8** (anyplot v3) | **8 + 3 anchors** | **2026** | + +**4. 360° / 45° = 8 — clean hue-wheel coverage.** With 8 slots every +categorical hex gets its own 45°-bin on the perceptual hue wheel. With 7 you +have one 51°+ gap (a family "missing"); with 9 you have to double up. muted-8 +distributes evenly: + +``` +hue: 25° 70° 115° 166° 209° 254° 305° 345° + red ochre lime green cyan blue lavnd rose +``` + +**What n=8 does NOT mean.** It does not mean "render 8 series in a single +chart." The n > 4 redundant-encoding guidance (linestyle / marker / small +multiples) still applies — the 8 are a *semantic pool*, not a stack-lines +ceiling. The slot-pool size lets the named API cover the standard semantic +roles without sacrificing the neutral; it doesn't encourage cramming. + +## Hybrid-v3 vs pure-CVD-greedy + +Both sortings use the **identical** 8 hexes of muted-8: + +``` +#009E73 #AE3030 #C475FD #99B314 #4467A3 #2ABCCD #954477 #BD8233 +brand-G matte-R lavender lime blue cyan rosé ochre +``` + +Only the **slot order** changes. Worst-pair ΔE under the min of the 3 CVD +simulations (deuteranopia / protanopia / tritanopia at 100% severity): + +| sort | n=2 | n=3 | n=4 | n=5 | n=6 | n=7 | n=8 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| hybrid-v3 (family-diverse first 4, red deferred, CVD-greedy tail) ★ | 36.19 | 16.34 | 13.98 | 13.98 | 13.70 | 10.70 | 8.81 | +| pure-CVD greedy max-min | 36.19 | 21.45 | 19.81 | 15.20 | 13.70 | 10.70 | 8.81 | + +★ = recommended. + +**pure-cvd-greedy** maximises ΔE per n by maximising min-CVD-distance to the +already-placed set at each step. Result for muted-8: it picks both `#C475FD` +lavender and `#954477` rosé (both "purple" family — read as pinkish-purple in +practice) in the first 4, and `#99B314` lime alongside `#009E73` brand green +(both "green" family). Visually weird despite the great ΔE numbers — the +"2× green, 2× purple" artefact flagged in the v2 review. + +**hybrid-v3** trades a small per-n ΔE for two structural improvements: (a) the +first 4 slots span 4 distinct perceptual hue families (no "2× green / 2× +pink"), and (b) the semantic red anchor is deferred so it stays available via +the named API rather than being burned on slot 2 of every chart. The CVD floor +at n=8 is identical (8.81) — both sortings ship the same 8 hexes, so the +worst-case is fixed by the palette, not the order. + +(The v2 head-to-head also compared `wheel-gap-first`, `hue-order`, and +`every-other-hue`, all of which had ΔE_CVD ≈ 10–14 from n=2 onward — see +[../palette-variants-v2/](../palette-variants-v2/) for that history.) + +## The hybrid v3 algorithm + +```python +def sort_hybrid_v3(hexes, first_n=4, defer=("#AE3030",)): + # Slot 0 fixed (project anchor: brand green). + # Slots 1..first_n-1: greedy max-min worst-CVD ΔE, constrained to + # (a) a coarse hue family not already used AND + # (b) not in `defer` (semantic red kept for named API). + # Slots first_n..N-1: pure greedy max-min worst-CVD ΔE on the remainder + # (deferred hexes re-enter the pool here). +``` + +**Why a custom coarse-family partition?** A naive 45°-bin partition splits +muted-8's `lavender` (≈ 305°) and `rosé` (≈ 345°) into *different* bins, yet +both read as "pinkish-purple" to the eye. Same story for `brand-green` +(≈ 166°) and `lime` (≈ 115°): different bins, but visually grouped as "two +greens". Inheriting v1's 14 fine hue-bands directly doesn't fix it either — +because `brand-green` at H ≈ 166° lands one degree *over* v1's `teal/cyan` +boundary (165°) and is classified as `cyan` rather than `teal`/`green`. + +So we use a custom 6-band partition with wider, perception-shaped boundaries. +Names are picked to fit the *dominant* hue in each band (so 30°–90° is +"yellow" not "orange" because the actually-present hue is ochre at H≈70° = amber, +not orange at ~40°; same logic for "purple" over "pink" since lavender at +H≈305° is purple, not pink): + +| coarse family | CAM02-UCS hue range | what lives there in muted-8 | +|---|---|---| +| red | 345°–360° ∪ 0°–30° | matte red `#AE3030` (H≈25°) | +| yellow | 30°–90° | ochre `#BD8233` (H≈70°) | +| green | 90°–180° | brand green `#009E73` (H≈166°), lime `#99B314` (H≈115°) | +| cyan | 180°–230° | cyan `#2ABCCD` (H≈209°) | +| blue | 230°–285° | blue `#4467A3` (H≈254°) | +| purple | 285°–345° | lavender `#C475FD` (H≈305°), rosé `#954477` (H≈345°) | + +For muted-8 the 8 hexes distribute as: + +``` +red (1 hex): #AE3030 red +yellow (1 hex): #BD8233 ochre +green (2 hex): #009E73 brand-G (H≈166°), #99B314 lime (H≈115°) +cyan (1 hex): #2ABCCD cyan +blue (1 hex): #4467A3 blue +purple (2 hex): #C475FD lavender (H≈305°), #954477 rosé (H≈345°) +``` + +Six distinct families across the 8 hexes — the constraint comfortably enforces +4-distinct-families in the first 4 slots, with no fallback ever triggering. + +## Finalist slot order + +| slot | hex | fine family | coarse family | hue | +|---|---|---|---|---| +| 0 | `#009E73` | cyan | green | 166.2° | +| 1 | `#C475FD` | purple | purple | 305.1° | +| 2 | `#4467A3` | blue | blue | 254.7° | +| 3 | `#BD8233` | lime | yellow | 70.2° | +| 4 | `#AE3030` | orange | red | 25.3° | +| 5 | `#2ABCCD` | azure | cyan | 209.5° | +| 6 | `#954477` | magenta | purple | 344.8° | +| 7 | `#99B314` | green | green | 115.1° | + +worst-pair ΔE for the sorted palette, per first-n subset: + +| | n=2 | n=3 | n=4 | n=5 | n=6 | n=7 | n=8 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| normal vision | 54.15 | 33.14 | 33.14 | 28.81 | 22.51 | 20.73 | 20.73 | +| under CVD (min) | 36.19 | 16.34 | 13.98 | 13.98 | 13.70 | 10.70 | 8.81 | + +## Comparison to established palettes + +The 2021–2026 best practice is to constrain on min ΔE under simulated CVD as a +hard constraint inside the generator (Petroff 2021 arXiv:2107.02270; Zeileis +2024 `colorspace` 2.1; `qualpalr`). Slot ordering inside the optimised set is a +secondary concern handled differently per palette. + +| palette | slot 0..3 (hue family) | reds vs greens placement | ΔE_CVD floor (n=8) | +|---|---|---|---| +| **muted-8 hybrid-v3 ★** | green / purple / blue / yellow | red at slot 4 (deferred past first-4) | 8.8 | +| Okabe-Ito (n=8, Wong 2011) | orange / sky-blue / green / yellow | vermillion at slot 5, green at slot 2 — 3 slots apart | ≈ 11 (Petroff 2021) | +| Paul Tol "muted" (n=9) | pink / indigo / yellow-tan / green | wine-red at slot 5, green at slot 3 | (unverified) | +| ColorBrewer Set2 (n=8) | teal / orange / blue-violet / pink | no "true" red in palette | (unverified) | +| Tableau-10 | blue / orange / red / teal | red at slot 2 (kept in first 4) | (unverified) | +| Tableau-Colorblind-10 | blue / orange / grey / dark-grey | only 2 hue families (blue + orange) + greys | n/a | +| D3 schemeCategory10 | blue / orange / green / red | green at 2, red at 3 — adjacent (textbook CVD weak point) | (unverified) | +| Petroff 2021 (n=8) | blue / orange / red / magenta | red at 2 (kept in first 4) | ≈ 18 (paper §3) | + +Sources: Wong 2011 *Nature Methods* 8:441; Tol 2018 (https://personal.sron.nl/~pault/); +ColorBrewer (https://colorbrewer2.org); Tableau-CB-10 hex via AndiH gist; +d3-scale-chromatic source; Petroff 2021 arXiv:2107.02270 §3. + +**Position:** muted-8 hybrid-v3 sits in the academic-publishing family — +defers red (like Okabe-Ito and Tol), 4 distinct hue families in slots 0..3, +similar ΔE_CVD floor at n=8 (~9 vs Okabe-Ito ~11), distinct because muted-8 +has a true semantic red available — just reached by name, not by position. + +## Why defer red? + +The CVD trade-off, measured concretely: + +| | n=2 | n=3 | n=4 | +|---|---|---|---| +| hybrid-v3 (with `defer=("#AE3030",)`, current) | 36.19 | 16.34 | 13.98 | +| hybrid-v3 without defer | 36.19 | 17.44 | 16.34 | + +Deferring red costs **~2.4 ΔE_CVD at n=4** because the binding pair shifts +from `green↔red` (CVD 16.34) to `green↔ochre` (CVD 13.98). In exchange: + +- **red stays a free semantic anchor.** The v2 expert reviews (consulting + + editorial) flagged that a true red is a semantic resource — "loss / error / + bad" — and burning it as slot 2 of every categorical chart wastes that + connotation. If `palette.red` and `palette[2]` both resolve to `#AE3030`, + the same colour means different things in different charts and the semantic + contract breaks. +- **the first 4 are 4 cleanly distinct hue families** — green / pink / blue / + orange — without a CVD-tight pair sitting next to each other. +- **n=4 ΔE_CVD = 13.98 is still above the 10-point "confident discrimination" + floor.** Practical loss is small; semantic gain is structural. +- **alignment with the academic-publishing family** (Okabe-Ito, Paul Tol — + both defer red to slot 5+), which is the right neighbourhood for a + generative plot tool whose output lands in editorial, slide-deck and + scientific contexts. + +Callers who want red back at slot 2 can pass `defer=()` to the sorter; the +parameter is configurable, just not the default. + +## Red anchor — considered alternatives, stayed with `#AE3030` + +Three of five v2 reviewers (editorial / brand / accessibility-implicit) flagged +`#AE3030` as too soft for the semantic-red role. The four proposed +alternatives were measured against the rest of muted-8 + both theme +backgrounds: + +| hex | source | J* | C | H° | WCAG light | WCAG dark | min ΔE_CVD | nearest | +|---|---|---|---|---|---|---|---|---| +| `#AE3030` ★ | **current muted-8** | 45.1 | 32.0 | 25.3° | 5.79:1 ✓ | **2.92:1 ❌** | 15.20 | rose | +| `#BE2B2B` | brand-rec (chroma+) | 47.9 | 35.2 | 26.6° | 5.30:1 ✓ | 3.19:1 ✓ | 14.59 | ochre | +| `#C8322C` | brand-rec (max chroma) | 50.8 | 35.5 | 28.1° | 4.79:1 ✓ | 3.53:1 ✓ | **11.52** | ochre | +| `#B71D27` | live-D / vivid-8 red | 45.1 | 36.1 | 25.0° | 5.88:1 ✓ | **2.87:1 ❌** | 17.38 | ochre | +| `#A41E22` | editorial-rec (darker) | 40.7 | 33.8 | 25.9° | 6.79:1 ✓ | **2.49:1 ❌** | 16.14 | rose | + +**What each reviewer argument actually says, vs the data:** + +- **Editorial: "AE3030 sits too close to rosé under deuteranopia."** Confirmed + empirically — AE has the smallest ΔE_CVD to `#954477` rosé (15.20). All + four alternatives push that gap to 16+. But 15.20 is still well above the + 10-point confident-discrimination threshold, so the practical impact is + small. +- **Brand: "AE3030 is brick, not red — push hue back toward 25°."** Marketing + language — all five candidates sit at hue 25–28°, virtually identical. + What reviewers perceive as "redder" is actually higher chroma (32 → 35–36) + and lightness, not hue. There is no meaningful hue shift available. +- **Accessibility (implicit): "matte red has less hue-rotation under CVD + because less chroma."** Correct — and this is exactly why AE works. + `#C8322C` raises chroma to 35.5 and immediately collapses min ΔE_CVD to + 11.52 against ochre (because ochre also sits in the warm hue band — higher + chroma rotates *into* that collision under CVD). + +**The only candidate that's a real refinement, not a regression:** + +`#BE2B2B` would close the dark-bg WCAG gap (2.92:1 → 3.19:1, just over the +3:1 line) and slightly widen the rosé gap under CVD (15.20 → 18.22). The +trade-off: marginally tighter ochre gap (14.59), still well above floor. +Visually it is essentially indistinguishable from AE — same hue, +3 chroma. + +**Decision: keep `#AE3030`.** + +The dark-bg sub-3:1 is documented in the "Contrast caveats" section and +handled by the outline pattern, which is needed for the other sub-3:1 hexes +on light bg regardless. Switching to `#BE2B2B` would close one half of one +WCAG line at the cost of regenerating every CVD-distance figure, slot +annotation, and screenshot in the rationale — for a change that's +visually marginal. The v3 documentation is built on AE3030 and that's +the shipping choice. Future per-theme hex sets (next step #6) would solve +the dark-bg gap structurally rather than by 0.07-point optimisation. + +## Semantic anchors outside the categorical pool + +The 8 hues above are the **categorical pool** — what `palette[:n]` returns. +But several semantic roles don't map cleanly onto any of the 8: warning +(needs leuchtgelb, not ochre-brown), totals/baseline (needs a neutral that's +visually structural rather than categorical), and "other/rest" in stacked +charts (needs a soft neutral that doesn't compete with the data). Three +additional anchors close those gaps. They live **outside** the slot pool and +are only reachable via the named API. + +### palette.amber — warning / caution + +Fixed hex `#DDCC77` (Paul Tol "muted" yellow). Min ΔE under CVD to all 8 +categorical hexes = **14.52** — confidently distinct from every +member, including `#99B314` lime. + +| candidate | min ΔE_normal | min ΔE_CVD | C | comment | +|---|---|---|---|---| +| `#D4A017` amber-2017 | 12.62 | **2.37** | 29.2 | collapses against `#99B314` lime under deuteranopia (unusable) | +| `#D4AF37` goldenrod | 14.99 | **2.33** | 27.1 | same lime collision (unusable) | +| `#DDCC77` Tol muted-yellow ★ | 19.56 | **14.52** | 20.6 | only CVD-safe option; consistent with the academic-publishing family muted-8 lives in | + +The two more saturated amber candidates fail because under deuteranopia / +protanopia they collapse to the same lightness-band as lime. Tol's +lower-chroma amber sits in a different lightness band (J*=84 vs lime's J*=71) +and survives the simulation. C=20.6 is *just* below the muted-8 chroma +envelope (C ∈ [24, 32]) but that's a feature: it signals "I'm not a +categorical-pool member, I'm a semantic anchor next to it". + +### palette.neutral — totals / baseline / outline (theme-adaptive) + +The neutral isn't a fixed hex but a **role** that flips per theme — same +pattern as Apple HIG, Material Design, GitHub Primer, and Wong (2011) +Okabe-Ito position 8 (style-guide §4.1): + +- **light theme** → `#1A1A17` (warm near-black ink) +- **dark theme** → `#F0EFE8` (warm near-white ink) + +Same hex as the chart's text and gridlines, so a "totals" / "baseline" / +"reference outline" series reads as part of the chart's structural layer +rather than as just another category. Implemented today as `NEUTRAL_LIGHT` / +`NEUTRAL_DARK` in `scripts/_palette_common.py:70-71`. + +### palette.muted — other / rest / disabled (theme-adaptive) + +A second adaptive neutral, soft-contrast rather than full-contrast: + +- **light theme** → `#6B6A63` (warm mid-gray) +- **dark theme** → `#A8A79F` (warm mid-gray) + +For "other" / "rest" slices in stacked bars, disabled / inactive series, +confidence bands, and annotations that should sit behind the data without +competing. Comes from `LIGHT_THEME["ink_muted"]` / +`DARK_THEME["ink_muted"]` — already used everywhere in the design system, +just not yet exposed through the palette API. + +## Final named-API surface + +```python +# Categorical pool (8 hues — sorted by hybrid-v3, slots 0..7) +anyplot.palette.green # → #009E73 ("good / profit / energy") +anyplot.palette.red # → #AE3030 ("bad / loss / error") +anyplot.palette.blue # → #4467A3 ("cool / water / info") +anyplot.palette.cyan # → #2ABCCD ("sky / tech-cool") +anyplot.palette.lime # → #99B314 ("growth / nature") +anyplot.palette.ochre # → #BD8233 ("earth / commodity") +anyplot.palette.lavender # → #C475FD ("creative") +anyplot.palette.rose # → #954477 ("wellness / feminine") + +# Semantic anchors OUTSIDE the categorical pool (never returned by palette[:n]) +anyplot.palette.amber # → #DDCC77 ("caution / warning") +anyplot.palette.neutral # → adaptive ("totals / baseline / outline") +anyplot.palette.muted # → adaptive ("other / rest / disabled") + +# Semantic-role aliases that map to the anchors above +anyplot.palette.semantic.good # → green +anyplot.palette.semantic.bad # → red +anyplot.palette.semantic.warning # → amber (NB: NOT ochre — ochre is "earth", not "caution") +anyplot.palette.semantic.info # → cyan +anyplot.palette.semantic.baseline # → neutral (adaptive) +anyplot.palette.semantic.other # → muted (adaptive) +``` + +Slot order and named access are independent — both ship. + +## Contrast caveats & the outline pattern + +The muted aesthetic carries a known trade-off: the lighter members reach +their distinguishability through chroma, not L-spread, so on the light theme +(cream `#F5F3EC`) five categorical hues + amber fall under WCAG 2.1 +SC 1.4.11's 3:1 minimum for graphical objects. This is not unique to +muted-8 — Okabe-Ito's `#F0E442` yellow, Paul Tol “muted”'s +`#DDCC77` and `#88CCEE`, and ColorBrewer Set2's `#A6D854` green all +have the same limitation. + +Per-theme ratios for every categorical hue + amber: + +| hex | name | on cream bg | on dark bg | +|---|---|---|---| +| `#009E73` | brand-green | 3.08:1 ✓ | 5.48:1 ✅ | +| `#AE3030` | matte-red | 5.79:1 ✅ | **2.92:1** ❌ | +| `#C475FD` | lavender | **2.59:1** ❌ | 6.53:1 ✅ | +| `#99B314` | lime | **2.15:1** ❌ | 7.87:1 ✅ | +| `#4467A3` | blue | 5.09:1 ✅ | 3.32:1 ✓ | +| `#2ABCCD` | cyan | **2.06:1** ❌ | 8.19:1 ✅ | +| `#954477` | rose | 5.61:1 ✅ | 3.01:1 ✓ | +| `#BD8233` | ochre | **2.95:1** ❌ | 5.72:1 ✅ | +| `#DDCC77` | amber (anchor) | **1.46:1** ❌ | 11.59:1 ✅ | + +### Recommended pattern: thin ink-color outline + +The industry-standard rescue is a thin stroke in the chart's ink color +on the affected series — Tableau, Vega, and most modern dashboarding tools +do this automatically in their accessibility mode. Recommended: + +- **line / scatter / area edges:** 1px solid ink stroke +- **bar / pie fills:** 1–1.5px solid ink stroke +- **legend swatches:** match the chart's outline behavior + +The stroke contrast (`#1A1A17` ink on `#F5F3EC` bg = 15.71:1) always passes +on its own, so the *visible boundary* of the series clears 3:1 even if the +fill colour doesn't. + +### What about amber specifically? + +`palette.amber = #DDCC77` is the worst case on light bg (1.46:1) but lives +outside the categorical pool — it's only reached intentionally via +`palette.amber` or `palette.semantic.warning` for caution / warning roles. +On the light theme, the same outline rule applies: wherever amber is used +(typically: a warning marker, a status icon, a single attention slice), add +a thin ink stroke. amber is never used by `palette[:n]` so it never ends +up on light bg by accident. + +### Dark-theme caveats + +The dark theme is mostly clean (every hue ≥ 3:1) except `#AE3030` matte-red +at 2.92:1 — fractionally under threshold. Same outline rule applies for +high-stakes layouts (financial dashboards, accessibility-strict contexts). +Future work — listed in v2's reviewer recommendations — is a separate +per-theme hex set with L+12 lift on the cool half; until then, the outline +pattern is the documented fix. + +See the live demo in [`index.html`](./index.html#contrast) — every member +rendered on both themes, with the sub-3:1 ones shown both as-is and with +the 1px ink ring. + +## Next steps + +1. Apply the hybrid-v3 ordering above as the new live `ANYPLOT_PALETTE`. +2. Ship the named API alongside, with `amber` / `neutral` / `muted` as the + three semantic anchors outside the categorical pool. +3. Wire `semantic.warning → amber` (not ochre — ochre is the "earth / + commodity" categorical hue, not a caution signal). +4. Document the n > 4 redundant-encoding guidance (linestyle / marker / shape). +5. *Optional* — expose a `palette.cvd_severity` knob defaulting to 1.0 (the + conservative current behaviour) but lettable down to ~0.6 for users who + explicitly want the palette tuned to realistic deuteranomaly severity + instead of full dichromacy. +6. *Future work — per-theme hex sets.* The "Contrast caveats" section + documents that 5 hexes sit below WCAG 3:1 on cream bg, and `#AE3030` + marginally below on dark bg. A separate dark-theme variant with L+12 + lifted on the cool half would close those structurally rather than via + outline. Until then, the outline pattern is the documented fix. +7. *Future work — OKLCH notation + Display-P3 variant.* CSS Color Level 4 is + broadly supported (Chrome 111+, Firefox 113+, Safari 15.4+). Defining the + web-side palette in OKLCH would give predictable chroma editing and + prevent the auto-saturation P3 browsers apply to sRGB hex. The Python + plotting side (matplotlib / plotly / altair) stays on hex — those engines + don't accept OKLCH input today. So this is a web/docs polish, not a + plot-render improvement. A `palette.p3.*` namespace with deliberately + raised chroma would only pay off once the plot generators support P3 + color() output (likely 1–2 years out). + +## References + +- Wong B. (2011). "Points of view: Color blindness." *Nature Methods* 8:441. +- Tol P. (2018). "Colour Schemes", https://personal.sron.nl/~pault/. +- Sharpe L. T. et al. (1999). "Opsin genes, cone photopigments, color vision, and color blindness." In *Color Vision: From Genes to Perception*. +- Birch J. (2012). "Worldwide prevalence of red-green color deficiency." *J. Opt. Soc. Am. A* 29(3):313–320. +- Simunovic M. P. (2010). "Colour vision deficiency." *Eye* 24:747–755. +- Petroff M. A. (2021). "Accessible Color Sequences for Data Visualization." arXiv:2107.02270. +- Zeileis A. (2024). "Color Vision Deficiency Emulation in colorspace 2.1." https://www.zeileis.org/news/simulate_cvd/. +- Larsson J. (2024). "qualpalr: Automatic Generation of Qualitative Color Palettes." https://jolars.github.io/qualpalr/. +- Carbon Design System. "Data visualization color palettes." https://carbondesignsystem.com/data-visualization/color-palettes/. diff --git a/docs/reference/palette-variants-v3/index.html b/docs/reference/palette-variants-v3/index.html new file mode 100644 index 0000000000..e5efccc3d7 --- /dev/null +++ b/docs/reference/palette-variants-v3/index.html @@ -0,0 +1,651 @@ +palette variants v3 — muted-8 finalist

palette variants v3 — muted-8 finalist

muted-8 was unanimously picked by 5 independent expert reviewers in v2. v3 settles the last open question — slot ordering — by introducing a hybrid sort that combines hue-family diversity in the first 4 slots with greedy max-min CVD distance for the tail. semantic red #AE3030 is deferred past slot 3 so it's reached via the named API (palette.red) for loss / error / bad, not by position 1..3 where it would appear in every chart with ≥ 3 series.

90°180°270°1·#009E73 (brand anchor)#009E732·#C475FD#C475FD3·#4467A3#4467A34·#BD8233#BD82335·#AE3030#AE30306·#2ABCCD#2ABCCD7·#954477#9544778·#99B314#99B314

chroma corridor C ∈ [24, 32]. brand green pinned at slot 0. remaining slots picked by the hybrid algorithm — slots 1..3 from distinct coarse hue families (red / yellow / green / cyan / blue / purple), with #AE3030 matte red deferred past first-4, then greedy max-min worst-CVD ΔE for the tail.

slot 0
#009E73 — green
slot 1
#C475FD — purple
slot 2
#4467A3 — blue
slot 3
#BD8233 — yellow
slot 4
#AE3030 — red
slot 5
#2ABCCD — cyan
slot 6
#954477 — purple
slot 7
#99B314 — green

semantic anchors — outside the categorical pool

the 8 categorical hues above cover the hue-diverse roles (palette.green, palette.red, …). three additional anchors live outside the slot pool — they are never returned by palette[:n], only by the named API. two are theme-adaptive: their hex flips between the light and dark theme so the role stays semantically consistent across both modes (same pattern as Apple HIG / Material Design / GitHub Primer).

warning / caution

palette.amber — #DDCC77

Paul Tol “muted” yellow. min ΔE under CVD to the 8 categorical hexes = 14.52 — confidently distinct from every member, including #99B314 lime (the two more saturated amber options, #D4A017 and #D4AF37 goldenrod, both collapse to ΔE_CVD ≈ 2.3 against lime under deuteranopia).

#1A1A17#F0EFE8

totals / baseline / outline

palette.neutral — adaptive

full-contrast ink, theme-adaptive. on cream bg → #1A1A17; on dark bg → #F0EFE8. same value as text and gridlines, so “total” / “baseline” / “reference outline” series automatically read as part of the chart's structural layer rather than as “just another category”.

#6B6A63#A8A79F

other / rest / disabled

palette.muted — adaptive

soft-contrast ink, theme-adaptive. on cream bg → #6B6A63; on dark bg → #A8A79F. meant for “other” / “rest” slices in stacked charts, disabled / inactive series, confidence bands, and any annotation that should sit behind the data without competing for attention.

finalist — muted-8 with hybrid-v3 sort

first 4 slots come from 4 different coarse hue families (red / yellow / green / cyan / blue / purple — see slot annotations below), with #AE3030 matte red explicitly deferred past first-4 so it remains a free semantic anchor reachable via the named API. slots 4..7 are pure-CVD greedy on the remaining hexes (deferred red re-enters here). this preserves visual hue diversity in dense small-n usage (no “two greens, two purples” problem) and still maximises CVD-distance for n=5..8.

#009E73
#C475FD
#4467A3
#BD8233
#AE3030
#2ABCCD
#954477
#99B314
slot 0cyangreen · H=166°
slot 1purplepurple · H=305°
slot 2blueblue · H=255°
slot 3limeyellow · H=70°
slot 4orangered · H=25°
slot 5azurecyan · H=210°
slot 6magentapurple · H=345°
slot 7greengreen · H=115°
worst-pair ΔEn=2n=3n=4n=5n=6n=7n=8
normal vision54.1533.1433.1428.8122.5120.7320.73
under CVD (min)36.1916.3413.9813.9813.7010.708.81

light theme

lines

muted-8 hybrid-v3 — bg-page #F5F3EC0255075100

bars (all 8)

muted-8 hybrid-v3 — barsQ1Q2Q3Q4

pie (first 4)

muted-8 hybrid-v3 — pie (first 4)

stocks (first 4)

muted-8 hybrid-v3 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

muted-8 hybrid-v3 — scatter (edge-clusters, centre-overlap)

dark theme

lines

muted-8 hybrid-v3 — bg-page #1212100255075100

bars (all 8)

muted-8 hybrid-v3 — barsQ1Q2Q3Q4

pie (first 4)

muted-8 hybrid-v3 — pie (first 4)

stocks (first 4)

muted-8 hybrid-v3 — stocks (first 4)1431281139883

scatter (edge clusters, centre overlap)

muted-8 hybrid-v3 — scatter (edge-clusters, centre-overlap)

contrast on both themes — and the outline pattern

muted palettes share a known limitation: the lighter members carry their distinguishability through chroma, not L-spread, so on a light background (cream #F5F3EC) they fall under WCAG 2.1 SC 1.4.11's 3:1 minimum for graphical objects. Okabe-Ito, Paul Tol “muted”, and ColorBrewer Set2 all have the same issue. The industry-standard fix is a thin ink-color outline on the affected series. Below: every categorical hue + amber on both themes, with the sub-3:1 ones shown both as-is and with a 1px ring.

on cream bg #F5F3EC (light theme)

brand-G
#009E73
3.08:1
red
#AE3030
5.79:1
lavender
#C475FD
2.59:1
lime
#99B314
2.15:1
blue
#4467A3
5.09:1
cyan
#2ABCCD
2.06:1
rose
#954477
5.61:1
ochre
#BD8233
2.95:1
amber
#DDCC77
1.46:1

↳ same 5 sub-3:1 hexes with a 1px #1A1A17 ink ring

lavender
#C475FD
15.71:1
lime
#99B314
15.71:1
cyan
#2ABCCD
15.71:1
ochre
#BD8233
15.71:1
amber
#DDCC77
15.71:1

on warm near-black bg #121210 (dark theme)

brand-G
#009E73
5.48:1
red
#AE3030
2.92:1
lavender
#C475FD
6.53:1
lime
#99B314
7.87:1
blue
#4467A3
3.32:1
cyan
#2ABCCD
8.19:1
rose
#954477
3.01:1
ochre
#BD8233
5.72:1
amber
#DDCC77
11.59:1

↳ same 1 sub-3:1 hex with a 1px #F0EFE8 ink ring

red
#AE3030
16.27:1

Guidance. on the light theme, render the affected series (lavender, lime, cyan, ochre, amber) with a thin outline in the ink color (#1A1A17): 1px stroke on line/scatter, 1–1.5px on bar/pie fills. amber is a semantic anchor outside the categorical pool — its low light-bg contrast is acceptable because it's reached intentionally (palette.amber) for caution/warning, and the same outline rule applies. on the dark theme, only #AE3030 red sits marginally below 3:1 (2.92:1) — same outline fix recommended for high-stakes layouts.

how this compares to the v2 alternatives

all five sortings use the identical 8 hexes. only the slot order differs. ★ marks the recommended sort. the per-n table shows the weakest pair's ΔE under the min of the 3 CVD simulations (deuteranopia / protanopia / tritanopia at 100% severity). bold underline marks the column-best.

sort · worst-CVD ΔE per nn=2n=3n=4n=5n=6n=7n=8
hybrid-v3 (family-diverse first 4, red deferred, CVD-greedy tail) ★36.1916.3413.9813.9813.7010.708.81
pure-CVD greedy max-min36.1921.4519.8115.2013.7010.708.81

slot-order strips

hybrid-v3 (family-diverse first 4, red deferred, CVD-greedy tail) ★
min ΔE_CVD over n=2..8 = 8.81
#009E73
#C475FD
#4467A3
#BD8233
#AE3030
#2ABCCD
#954477
#99B314
pure-CVD greedy max-min
min ΔE_CVD over n=2..8 = 8.81
#009E73
#C475FD
#99B314
#954477
#AE3030
#2ABCCD
#4467A3
#BD8233
\ No newline at end of file diff --git a/docs/reference/plausible.md b/docs/reference/plausible.md index 6ceeffdb69..d5f1846068 100644 --- a/docs/reference/plausible.md +++ b/docs/reference/plausible.md @@ -164,7 +164,6 @@ carry `spec`, `library`, or `value` for richer breakdowns. | `section_header` | SectionHeader.tsx | `specs.all()` / `libraries.all()` / `palette.explore()` headers | | `specs_more_link` | LandingPage.tsx | `+ N more in the catalogue →` | | `suggest_spec_link` | LandingPage.tsx | `spec.suggest()` GitHub-issue link | -| `palette_okabe_ito` | LandingPage.tsx | external Okabe & Ito reference | **Random methods**: - `click`: Shuffle icon clicked diff --git a/docs/reference/style-guide.md b/docs/reference/style-guide.md index f0138c8471..4dc705b616 100644 --- a/docs/reference/style-guide.md +++ b/docs/reference/style-guide.md @@ -20,11 +20,11 @@ anyplot.ai is a considered reference work styled like a code editor over a paper |-------|------|--------| | **Brand** | Identity, voice, logo, tone of communication | Lowercase default; `any.plot()` wordmark with green dot | | **Frontend** | Website visual language: typography, layout, components | Editorial-scientific paper × terminal/code-editor overlay | -| **Plots** | Color palette for every visualization across all 11 libraries (9 Python + R/ggplot2 + Julia/Makie) | Okabe-Ito 8-color categorical, brand `#009E73` first | +| **Plots** | Color palette for every visualization across all 11 libraries (9 Python + R/ggplot2 + Julia/Makie) | imprint 8-color categorical, brand `#009E73` first | **Aesthetic direction:** `arXiv paper` × `tmux/lazygit` rather than `SaaS dashboard` or `AI startup`. The reader should feel they're browsing a curated journal that happens to live inside a terminal — section headers carry shell prompts (`❯`, `$`, `~/plots/`), hero text types itself with a blinking cursor, action buttons read as method calls (`.copy()`, `.open()`, `.download()`). -**Color discipline:** `#009E73` (Okabe-Ito Bluish Green) appears only in small, deliberate moments — logo dot, italic accents, hover states, active navigation, terminal cursor. Everything else is warm grayscale, so when a chart appears its colors land with full impact. +**Color discipline:** `#009E73` (brand green) appears only in small, deliberate moments — logo dot, italic accents, hover states, active navigation, terminal cursor. Everything else is warm grayscale, so when a chart appears its colors land with full impact. **Pipeline transparency:** AI is part of the story, not a hidden production detail. Humans submit ideas (plot requests as GitHub issues); AI drafts the spec from the idea; humans approve the spec before any code runs; AI generates the implementations and reviews them; on rejection AI retries and humans tune the rules or refine the spec. Communicated openly in copy and methodology pages. @@ -34,9 +34,9 @@ anyplot.ai is a considered reference work styled like a code editor over a paper ### 2.1 Visual principles (frontend) -**1. Reserve color for moments that earn it.** The Okabe-Ito palette has seven saturated colors. If the website itself uses them liberally — brightly colored buttons, banners, hero backgrounds — the actual plots lose their visual punch. We treat the palette as **precious**: the brand green appears in maybe five to ten places on a given page (logo, one italic headline accent, terminal cursor, primary CTA hover, active nav indicator, a handful of link underlines). Everything else is one of the seven gray tones. +**1. Reserve color for moments that earn it.** The imprint palette has eight categorical colours. If the website itself uses them liberally — brightly colored buttons, banners, hero backgrounds — the actual plots lose their visual punch. We treat the palette as **precious**: the brand green appears in maybe five to ten places on a given page (logo, one italic headline accent, terminal cursor, primary CTA hover, active nav indicator, a handful of link underlines). Everything else is one of the eight gray tones. -**2. Warmth over clinical.** Pure `#FFFFFF` backgrounds make palette colors look harsh and the layout feel like a banking app. We use a warm off-white (`#F5F3EC`) as the base, with slightly lighter surfaces (`#FAF8F1`) for cards. This gives the page a paper-like quality and makes the saturated plot colors look **intentional** rather than loud. Dark mode mirrors this: `#121210` rather than pure black, with a subtle warm undertone. +**2. Warmth over clinical.** Pure `#FFFFFF` backgrounds make palette colors look harsh and the layout feel like a banking app. We use a warm off-white (`#FAF8F1`) as the base, with slightly lighter surfaces (`#FAF8F1`) for cards. This gives the page a paper-like quality and makes the saturated plot colors look **intentional** rather than loud. Dark mode mirrors this: `#121210` rather than pure black, with a subtle warm undertone. **3. Code is the native register.** Section headers carry shell prompts (`❯ libraries`, `$ plots`, `~/anyplot/`), action buttons read as method calls (`.copy()`, `.open()`, `.download()`), the hero headline types itself with a blinking cursor. The site speaks the dialect of its visitors. This is not skinning — it's the framing device. Removing the editorial paper underneath would over-tip into "developer toy"; removing the terminal layer would feel sterile and product-marketing-y. The two layers depend on each other. @@ -103,7 +103,7 @@ any.plot() Specifically: - `any` in `--ink` (near-black on light, near-white on dark) -- `.` in `--ok-green` (`#009E73`), scaled to 145% with 2–3px horizontal margin +- `.` in `--imprint-green` (`#009E73`), scaled to 145% with 2–3px horizontal margin - `plot` in `--ink` - `()` in `--ink` at 45% opacity, normal weight (not bold) @@ -276,7 +276,7 @@ The second version is shorter, says what's actually happening (humans submit ide - A **reference catalogue**, like a cookbook or an atlas. You come for specific examples. - **Library-agnostic**, showing the same chart types across matplotlib, seaborn, plotly, and others. -- **Colorblind-safe by default**. Every example uses the Okabe-Ito palette. This isn't a feature, it's a baseline. +- **Colorblind-safe by default**. Every example uses the imprint palette. This isn't a feature, it's a baseline. - **Copyable**. Every example is self-contained, with the full code visible and executable. - **Curated**. We don't aggregate every plot on the internet — we maintain a considered collection. - **AI-built, human-shaped.** Plot ideas come from humans (GitHub issues). Everything else — drafting the spec, generating code per library, reviewing it — runs on AI. Humans approve specs before any code is generated, and when something repeatedly fails review we either refine the spec or tune the AI rules. We never patch generated code by hand. The pipeline is documented and visible. @@ -295,7 +295,7 @@ Narrative hooks for talking about anyplot — adapt to context, don't recite ver **The pipeline story:** Humans submit plot ideas as GitHub issues. AI drafts the spec from each idea; humans approve it before any code is generated. AI then generates implementations across every supported library from the same spec — Python for most, R for ggplot2 — and reviews each one for visual quality and spec compliance. When something doesn't pass review, AI retries — and when it keeps failing, we refine the spec or tune the AI rules. We never patch the code by hand. That makes anyplot a catalogue that maintains itself: when matplotlib ships a new release, we re-run the pipeline; when a better example pattern emerges, we update the spec and every library regenerates. Humans curate; AI executes. -**The palette story:** Every plot uses the Okabe-Ito palette, peer-reviewed for colorblind safety and designed for scientific publications in 2008. About 8% of men have some form of color vision deficiency — most plotting libraries ignore this entirely. We make it the default. +**The palette story:** Every plot uses the imprint palette, colourblind-safe and tuned for warm-paper rendering. About 8% of men have some form of color vision deficiency — most plotting libraries ignore this entirely. We make it the default. **The library-agnostic story:** A "Gentoo penguin" is always blue, whether you draw it in matplotlib, plotly, or bokeh. The palette travels with you across libraries. Switching tools doesn't mean re-learning your color grammar. @@ -311,7 +311,7 @@ Narrative hooks for talking about anyplot — adapt to context, don't recite ver - **Package name**: `anyplot` (lowercase, one word) - **Import convention**: `import anyplot as ap` - **Sub-modules**: `anyplot.mpl`, `anyplot.plotly`, `anyplot.bokeh`, etc. — one sub-module per supported library -- **Palettes**: `anyplot.palettes.okabe_ito`, `anyplot.palettes.viridis`, etc. +- **Palette**: `anyplot.palette` (the imprint palette — categorical + semantic anchors + cmap helpers). - **Datasets**: `anyplot.load("penguins")`, `anyplot.load("iris")` — not `load_penguins()`; consistent loader signature **Domain:** @@ -331,60 +331,64 @@ Slugs are the canonical identifier. They're used in URLs, filenames, and `ap.loa ## 4. Color System -### 4.1 The Okabe-Ito Palette +### 4.1 The imprint Palette -The Okabe-Ito palette was published in 2008 by Masataka Okabe (Jikei Medical School) and Kei Ito (University of Tokyo) as part of their research on accessible color design for scientific figures. It was optimized empirically for three types of color vision deficiency (CVD) — deuteranopia, protanopia, and tritanopia — which together affect roughly 8% of men and 0.5% of women of Northern European descent. +anyplot's **imprint** palette is a bespoke, colourblind-safe categorical palette tuned for warm-paper rendering. 8 hues in a hybrid sort order plus 3 semantic anchors (amber for warning, theme-adaptive neutral, theme-adaptive muted). It sits in the academic-publishing family — same neighbourhood as Okabe-Ito, Paul Tol "muted", and ColorBrewer Set2 — and was validated against deuteranopia, protanopia, and tritanopia at full simulation severity. Full design rationale: [`palette-variants-v3/decision-rationale.md`](palette-variants-v3/decision-rationale.md). -Three properties make it the right choice for a multi-library plotting catalogue: +Why this palette over a one-shot import of Okabe-Ito: -**Peer-reviewed and widely trusted.** ggplot2, seaborn, and many scientific R/Python toolkits offer it as a built-in option. Using it means our examples are immediately credible in academic and publication contexts. +**Closes the hue-coverage gap of the 7-hue Okabe-Ito layout.** With 7 categorical slots you have to choose either lime or cyan, either lavender or rosé. imprint ships all six primary hue families (red / yellow / green / cyan / blue / purple) plus two tertiary tones. -**Stable across backgrounds.** Every color has enough luminance contrast to remain distinguishable on both white and near-black backgrounds. +**Stable across backgrounds and CVD modes.** Every hue holds worst-pair ΔE above the 10-point discrimination floor through n=6 under simulated CVD. The semantic-red anchor is deferred to slot 4 so it stays a free named anchor for bad/loss/error roles instead of being burned on slot 2 of every chart. -**Eight colors is the right cap.** Research by Ware, Glasbey, and Miller on distinguishable categorical colors converges on 7 ± 2 as the practical limit before viewers start confusing categories. +**Eight is the practical cap.** Research by Ware, Glasbey, and Miller on distinguishable categorical colours converges on 7 ± 2 as the practical limit. imprint stops at 8 — and recommends adding a marker shape or linestyle from n=6 upward. ```python -anyplot_palette = [ - "#009E73", # 01 · bluish green ★ brand - "#D55E00", # 02 · vermillion - "#0072B2", # 03 · blue - "#CC79A7", # 04 · reddish purple - "#E69F00", # 05 · orange - "#56B4E9", # 06 · sky blue - "#F0E442", # 07 · yellow - # 08 · neutral — adaptive: #1A1A1A on light, #E8E8E0 on dark +imprint_palette = [ + "#009E73", # slot 0 · brand green ★ always first series + "#C475FD", # slot 1 · lavender — creative / artistic + "#4467A3", # slot 2 · blue — cool / water / info + "#BD8233", # slot 3 · ochre — earth / commodity + "#AE3030", # slot 4 · matte red — semantic anchor: bad / loss / error + "#2ABCCD", # slot 5 · cyan — sky / tech-cool + "#954477", # slot 6 · rose — wellness / health + "#99B314", # slot 7 · lime — growth / nature ] -``` -**The colors:** +# Semantic anchors outside the categorical pool — never returned by palette[:n] +ANYPLOT_AMBER = "#DDCC77" # warning / caution (fixed hex) +ANYPLOT_NEUTRAL = "#1A1A17" # totals / baseline / outline — flips to #F0EFE8 on dark theme +ANYPLOT_MUTED = "#6B6A63" # other / rest / disabled — flips to #A8A79F on dark theme +``` -| # | Role | Hex | Semantic Use | -|---|---------------|------------|---------------------------------------------------------------| -| 1 | Brand | `#009E73` | Logo dot, first category in any plot, primary CTAs | -| 2 | Secondary | `#D55E00` | Second category, warm contrast, warnings | -| 3 | Tertiary | `#0072B2` | Third category, cool anchor, informational links | -| 4 | Quaternary | `#CC79A7` | Fourth category, soft, distinctive | -| 5 | Accent warm | `#E69F00` | Fifth category, highlights, hover states | -| 6 | Accent cool | `#56B4E9` | Sixth category, info states, secondary links | -| 7 | Highlight | `#F0E442` | Seventh category — use sparingly, poor on white backgrounds | -| 8 | Neutral | adaptive | Text, gridlines, "other", totals | +**The colours:** -**The adaptive neutral.** Position 8 is not a fixed color but a **role** that switches based on the theme: +| Slot | Hex | Name | Role | +|------|------------|---------------|-------------------------------------------------------| +| 0 | `#009E73` | brand green | Logo dot, first category in any plot, primary CTAs | +| 1 | `#C475FD` | lavender | Second category, creative / artistic | +| 2 | `#4467A3` | blue | Third category, cool / water / info | +| 3 | `#BD8233` | ochre | Fourth category, earth / commodity | +| 4 | `#AE3030` | matte red | Deferred semantic anchor — bad / loss / error | +| 5 | `#2ABCCD` | cyan | Sky / tech-cool | +| 6 | `#954477` | rose | Wellness / health | +| 7 | `#99B314` | lime | Growth / nature | -- **Light theme** → `#1A1A1A` (near-black, softer than pure `#000`) -- **Dark theme** → `#E8E8E0` (near-white, softer than pure `#FFF`) +**Semantic anchors** (outside the categorical pool — never returned by `palette[:n]`, reached only by name): -This follows the `semantic tokens` pattern used in Apple HIG, Material Design, and GitHub Primer. The first seven colors stay identical across themes so that a category retains its identity; only the neutral flips. +| Anchor | Hex (light → dark) | Role | +|----------|-------------------------------|-----------------------------------------------| +| amber | `#DDCC77` | warning / caution (single fixed hex) | +| neutral | `#1A1A17` → `#F0EFE8` | totals / baseline / outline (theme-adaptive) | +| muted | `#6B6A63` → `#A8A79F` | other / rest / disabled (theme-adaptive) | -**Order matters.** The color order is **not scientifically prescribed** — the Okabe-Ito paper lists the colors but doesn't mandate a sequence. We've chosen an order optimized for two goals: +The neutral pattern follows the semantic-tokens convention used in Apple HIG, Material Design, and GitHub Primer — the categorical hues stay identical across themes so a category keeps its identity; only the structural neutrals flip. -1. **Brand consistency.** Position 1 is always `#009E73`. The first data series in every plot is automatically our brand color. -2. **Maximum early contrast.** Positions 1–4 alternate between warm and cool, saturated hues to guarantee maximum distinguishability for plots with few categories. Orange (`#E69F00`) and Yellow (`#F0E442`) — which share the yellow-orange region — are separated by four positions. +**Order matters.** Slots are picked by the **hybrid-v3** sort: brand green pinned at slot 0; slots 1..3 filled by greedy max-min worst-CVD ΔE constrained to distinct hue families (red / yellow / green / cyan / blue / purple); semantic red deferred past slot 3 so it stays a free anchor; slots 4..7 filled by unconstrained CVD-greedy on the remainder. ``` -green → vermillion → blue → purple → orange → sky → yellow → neutral -warm? warm cool warm warm cool warm neutral -sat. sat. sat. sat. sat. soft soft +green → lavender → blue → ochre → matte red → cyan → rose → lime +slot 0 slot 1 slot 2 slot 3 slot 4 slot 5 slot 6 slot 7 ``` ### 4.2 Surfaces @@ -393,11 +397,11 @@ Plots and content sit inside **surface containers** with consistent styling: | Surface | Light | Dark | Use | |-------------|----------------|----------------|-----------------------------| -| `bg-page` | `#F5F3EC` | `#121210` | Outer page background | +| `bg-page` | `#FAF8F1` | `#121210` | Outer page background | | `bg-surface`| `#FAF8F1` | `#1A1A17` | Cards, plot containers | | `bg-elevated`| `#FFFDF6` | `#242420` | Modals, tooltips | -The warm off-white (`#F5F3EC`) is the foundation — pure white would make the saturated palette colors look harsh. +The warm off-white (`#FAF8F1`) is the foundation — pure white would make the saturated palette colors look harsh. ### 4.3 Warm-tinted Grayscale @@ -412,13 +416,13 @@ A warm-tinted grayscale (reddish-brown undertone instead of blue-gray) matches t All three text tokens pass **WCAG 2.1 AA contrast** (≥ 4.5:1 for normal text) against both `--bg-page` surfaces: -| Token | on `--bg-page` light (`#F5F3EC`) | on `--bg-page` dark (`#121210`) | +| Token | on `--bg-page` light (`#FAF8F1`) | on `--bg-page` dark (`#121210`) | |---------------|-----------------------------------|----------------------------------| | `--ink` | 16.7 : 1 (AAA) | 16.5 : 1 (AAA) | | `--ink-soft` | 7.79 : 1 (AAA) | 9.42 : 1 (AAA) | | `--ink-muted` | 5.07 : 1 (AA) | 7.95 : 1 (AAA) | -Verified with [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/). Never use Okabe-Ito palette colors as body-text colors — they are reserved for plot data marks (and a few documented chrome accents like `#009E73` in §4.4). +Verified with [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/). Never use imprint palette colours as body-text colors — they are reserved for plot data marks (and a few documented chrome accents like `#009E73` in §4.4). ### 4.4 UI Color Application @@ -442,7 +446,7 @@ It does **not** appear in: - Static (non-cursor, non-status) decorative dots or glyphs - **Default colour on in-prose links** (see below) -**In-prose link treatment.** Links inside body text default to `--ink-soft` with a 1px `--rule` underline (via `text-decoration`). On hover the colour flips to `--ok-green` and the underline thickens to `currentColor`. Do **not** set `color: colors.primary` as the default on inline links — brand green stays a signal colour that only appears on interaction. The reusable sx object is exported from `app/src/theme/index.ts` as `proseLinkStyle`; import it everywhere a contextual link lives in prose (About, Legal, MCP, Palette, Stats). +**In-prose link treatment.** Links inside body text default to `--ink-soft` with a 1px `--rule` underline (via `text-decoration`). On hover the colour flips to `--imprint-green` and the underline thickens to `currentColor`. Do **not** set `color: colors.primary` as the default on inline links — brand green stays a signal colour that only appears on interaction. The reusable sx object is exported from `app/src/theme/index.ts` as `proseLinkStyle`; import it everywhere a contextual link lives in prose (About, Legal, MCP, Palette, Stats). ```ts import { proseLinkStyle } from '../theme'; @@ -453,15 +457,16 @@ Method-call CTAs (`.copy()`, `subject.verb()`, hero buttons) keep their own hove ### 4.5 Status Colors -The remaining Okabe-Ito palette colors have reserved UI roles: +The imprint palette and its semantic anchors map onto UI status roles: -- `#D55E00` (Vermillion) — destructive actions, error states -- `#E69F00` (Orange) — "new" badges, hover highlights on secondary elements -- `#0072B2` (Blue) — informational links in prose, footnotes +- `#AE3030` (matte red, slot 4) — destructive actions, error states +- `#DDCC77` (amber anchor) — "new" badges, warning highlights +- `#4467A3` (blue, slot 2) — informational links in prose, footnotes +- `#BD8233` (ochre, slot 3) — accent for hover highlights on secondary elements ### 4.6 Plot-only Colors -**Purple (`#CC79A7`), Sky (`#56B4E9`), and Yellow (`#F0E442`) are plot-only and do not appear in the UI at all.** This preserves their visual impact for the data visualizations. +**Lavender (`#C475FD`), cyan (`#2ABCCD`), rose (`#954477`), and lime (`#99B314`) are plot-only and do not appear in the UI chrome.** This preserves their visual impact for the data visualizations. Using them in navigation or buttons breaks the color hierarchy. @@ -530,12 +535,12 @@ steal like an artist._▌ │ │ ``` **H1 construction (two lines):** -- Line 1: `any.plot()` — MonoLisa Bold (700), upright, with `.` in `--ok-green` (circle via `scale(1.3)`), ghosted `()` at opacity 0.45. This matches the NavBar logo exactly for brand consistency. +- Line 1: `any.plot()` — MonoLisa Bold (700), upright, with `.` in `--imprint-green` (circle via `scale(1.3)`), ghosted `()` at opacity 0.45. This matches the NavBar logo exactly for brand consistency. - Line 2: `— any library.` — MonoLisa italic + `ss02`, weight 400, same 0.75em size as `any.plot()` so heights align. The script variant reads as the editorial subline. - Size: `clamp(2.75rem, 4.5vw, 4.75rem)` on the H1 container, with `0.75em` on both spans. **Layer hierarchy (top to bottom, left column):** -1. **Eyebrow** — `— the open plot catalogue`, lowercase, 11px MonoLisa upright, `--ok-green`, tracked `.08em`, preceded by 18×1px green rule. +1. **Eyebrow** — `— the open plot catalogue`, lowercase, 11px MonoLisa upright, `--imprint-green`, tracked `.08em`, preceded by 18×1px green rule. 2. **H1** — see above. 3. **Subtitle** — `one spec · every library · always current.`, MonoLisa upright weight 500, 18px, `--ink`. Must fit on one line (`whiteSpace: nowrap` + stepped responsive size). 4. **Intro prose** — 4–6 lines MonoLisa upright weight 300, `--ink-soft`, 18px. `your` renders as italic-ss02 script accent inline. @@ -554,7 +559,7 @@ CSS sketch: color: var(--ink); } .hero-h1 .wordmark { font-weight: 700; font-size: 0.75em; letter-spacing: -0.02em; } -.hero-h1 .dot { background: var(--ok-green); border-radius: 50%; /* circle */ } +.hero-h1 .dot { background: var(--imprint-green); border-radius: 50%; /* circle */ } .hero-h1 .parens { font-weight: 400; opacity: 0.45; } .hero-h1 .accent { font-style: italic; @@ -564,7 +569,7 @@ CSS sketch: white-space: nowrap; } .hero-tagline { font-style: italic; font-feature-settings: "ss02"; } -.hero-cursor { width: 0.55em; background: var(--ok-green); animation: blink 1s steps(2) infinite; transition: opacity 0.6s; } +.hero-cursor { width: 0.55em; background: var(--imprint-green); animation: blink 1s steps(2) infinite; transition: opacity 0.6s; } @keyframes blink { 50% { opacity: 0; } } ``` @@ -646,7 +651,7 @@ $ plots - **Prefix glyph**: `❯` is the default everywhere (navigation, list, editorial). `$` may be used for explicit action/list sections if it adds meaning; `~/path/` for hierarchical/about/meta sections. Earlier drafts used `§` in places (landing, about, libraries) — those have all been migrated to `❯`. No new glyph enters the system. - **Prefix font**: MonoLisa weight 500, color `--ink-muted`, scaled to ~0.6× the title size -- **Title font**: MonoLisa italic + `ss02` stylistic set, 1.6–2rem weight 400; the `` child is rendered in `--ok-green` (script accent), the rest of the h2 stays `--ink` +- **Title font**: MonoLisa italic + `ss02` stylistic set, 1.6–2rem weight 400; the `` child is rendered in `--imprint-green` (script accent), the rest of the h2 stays `--ink` - **Spacing**: header wrapper is `pt: 2.5` (20px), `pb: 1.5` (12px) to sit the underline close to the baseline, `mb: 4` (32px) under the rule before content resumes. Sections wrap in `` so the page rhythm is uniform across editorial pages. - **Underline**: 1px solid `--rule`, full container width - **Component**: reuse `app/src/components/SectionHeader.tsx` — it encodes the pt/pb/mb/border rules above. Do not re-implement. @@ -763,7 +768,7 @@ Used in the catalogue grid. Similar to plot card but with specific additions: - Mini-plot thumbnail uses SVG at fixed 120px height - Library name in mono-bold, example count in mono-muted (`matplotlib · 142 plots`) -The color of the accent bar is a subtle way to give each library a personality without breaking the shared palette system — every library gets one of the Okabe-Ito colors as its accent. +The color of the accent bar is a subtle way to give each library a personality without breaking the shared palette system — every library gets one of the imprint colours as its accent. ### 7.4 Buttons @@ -787,10 +792,10 @@ Three variants. All buttons read as method calls in mono — there is no ambigui } .btn-action::before { content: "."; color: var(--ink-muted); } .btn-action:hover { - color: var(--ok-green); + color: var(--imprint-green); background: var(--bg-elevated); } -.btn-action:hover::before { color: var(--ok-green); } +.btn-action:hover::before { color: var(--imprint-green); } ``` Examples: `.copy()`, `.open()`, `.download()`, `.preview()`, `.share()`, `.fork()`, `.raw()`. The leading `.` is part of the visual language — it signals "this is a method on the thing in front of you" without saying so. No icons needed for common actions. @@ -829,7 +834,7 @@ Pluralisation: prefer the natural plural for collections (`plots.browse()`, `lib } .btn-cta::before { content: "."; opacity: 0.6; } .btn-cta:hover { - background: var(--ok-green); + background: var(--imprint-green); color: #FFF; } .btn-cta:hover::before { opacity: 1; } @@ -893,7 +898,7 @@ box-shadow: 0 24px 48px -16px rgba(0,0,0,0.2); The fake macOS window-controls (`● ● ●` in 10% opacity) are rendered via a `::before` pseudo-element at the top-left. Playful but not distracting. They also subtly communicate "this is a screenshot of real code running somewhere" which fits the developer-tool framing. -**Syntax highlighting** uses the Okabe-Ito palette: +**Syntax highlighting** uses the imprint palette: - Keywords → sky blue - Strings → brand green @@ -912,7 +917,7 @@ Horizontal text links, no icons, no boxes. On hover, a 1px green underline anima content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 1px; - background: var(--ok-green); + background: var(--imprint-green); transform: scaleX(0); transform-origin: left; transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); @@ -990,7 +995,7 @@ The block cursor at the end of `make it yours._▌` blinks indefinitely: display: inline-block; width: 0.6em; height: 1em; - background: var(--ok-green); + background: var(--imprint-green); vertical-align: -0.1em; animation: blink 1s steps(2) infinite; } @@ -1064,18 +1069,17 @@ The palette strip at the bottom responds to hover: normal state is even distribu ### 9.1 Color Defaults - **First series = brand color (`#009E73`)**. Always. This is the single most important consistency rule. -- **Neutral (position 8) is reserved** for aggregates, residuals, and reference lines. Don't use it for a normal category unless you've exhausted the other seven. -- **Yellow (`#F0E442`) on white backgrounds** has poor contrast. Use it only for position 7 or later, never for thin lines or small markers. +- **Neutral (semantic anchor)** is reserved for aggregates, totals, reference lines. Don't use it for a normal category — it sits outside the categorical pool by design. +- **Light-bg WCAG caveat:** lavender, ochre, cyan, lime, and amber all fall below WCAG 2.1 SC 1.4.11's 3:1 minimum against cream `#FAF8F1`. Add a 1px ink-color stroke on affected series when the chart is small or accessibility-strict — see the outline pattern in [palette-variants-v3/decision-rationale.md](palette-variants-v3/decision-rationale.md#contrast-caveats--the-outline-pattern). ### 9.2 Non-categorical Data -Okabe-Ito is a **categorical** palette — it's for distinct categories, not ordered or continuous data. For other data types: +imprint is a **categorical** palette — it's for distinct categories, not ordered or continuous data. For other data types, anyplot ships two palette-derived cmaps: -- **Sequential (single-variable magnitude)**: use `viridis` or `cividis`. Both are perceptually uniform and colorblind-safe. `cividis` is additionally optimized for print. -- **Diverging (two-sided, midpoint-anchored)**: use `BrBG` (brown-to-bluish-green) from ColorBrewer, or construct one centered on a neutral tone. -- **Heatmaps**: use `viridis` for general-purpose, `Reds`/`Blues` for single-polarity intensity. +- **`imprint_seq` (sequential)**: brand green → blue. Use for single-polarity continuous data (intensity, magnitude, density, single-polarity heatmaps). +- **`imprint_div` (diverging)**: matte red ↔ near-neutral ↔ blue. Use when the data has a meaningful midpoint (correlations, residuals, signed deviations). The midpoint flips per theme — `#FAF8F1` on cream bg, `#1A1A17` on dark bg. -Don't reach for Okabe-Ito for these cases — a categorical palette on continuous data produces misleading banding artifacts. +Don't reach for the categorical palette on continuous data — a categorical palette on continuous data produces misleading banding artifacts. And don't substitute viridis/cividis/BrBG/jet/hsv/rainbow either; palette identity is part of the brand. ### 9.3 Plot Container Surfaces @@ -1096,49 +1100,58 @@ Recommended tools: - [Sim Daltonism](https://michelf.ca/projects/sim-daltonism/) — macOS system-wide CVD simulator - [Colour Science for Python](https://www.colour-science.org/) — programmatic access to Machado et al. (2009) simulation matrices -For algorithmic palette generation beyond Okabe-Ito, see Petroff (2021) — a more recent palette optimized with numerical solvers in the CAM02-UCS perceptual color space. +For algorithmic palette generation beyond imprint, see Petroff (2021) — a more recent palette optimized with numerical solvers in the CAM02-UCS perceptual color space. ### 9.5 Implementation Reference The palette is exposed in the Python library as: ```python -import anyplot as ap +from core.palette import palette, IMPRINT -# full palette as list -ap.palettes.okabe_ito # returns list of 8 hex strings +# full categorical pool as list +IMPRINT # ["#009E73", "#C475FD", "#4467A3", "#BD8233", ...] -# by role name -ap.palettes.okabe_ito.brand # "#009E73" -ap.palettes.okabe_ito.vermillion # "#D55E00" -ap.palettes.okabe_ito.blue # "#0072B2" +# by hue name +palette.green # "#009E73" (slot 0, brand) +palette.lavender # "#C475FD" (slot 1) +palette.blue # "#4467A3" (slot 2) +palette.red # "#AE3030" (slot 4, deferred semantic anchor) # ... -# theme-aware neutral -ap.palettes.okabe_ito.neutral("light") # "#1A1A1A" -ap.palettes.okabe_ito.neutral("dark") # "#E8E8E0" - -# as matplotlib cycler -import matplotlib.pyplot as plt -plt.rcParams['axes.prop_cycle'] = ap.palettes.okabe_ito.cycler() +# semantic anchors outside the categorical pool +palette.amber # "#DDCC77" (warning / caution) +palette.neutral("light") # "#1A1A17" (totals / baseline — adaptive) +palette.neutral("dark") # "#F0EFE8" +palette.muted("light") # "#6B6A63" (other / rest — adaptive) +palette.muted("dark") # "#A8A79F" + +# semantic-role aliases +palette.semantic.bad # → red +palette.semantic.warning # → amber +palette.semantic.info # → cyan + +# matplotlib + cmap registration +from core.palette import imprint_seq, register_with_matplotlib +register_with_matplotlib() # registers imprint_seq + imprint_div_light/dark ``` For CSS: ```css :root { - --ap-green: #009E73; - --ap-vermillion: #D55E00; - --ap-blue: #0072B2; - --ap-purple: #CC79A7; - --ap-orange: #E69F00; - --ap-sky: #56B4E9; - --ap-yellow: #F0E442; - --ap-neutral: #1A1A1A; /* adaptive */ -} - -@media (prefers-color-scheme: dark) { - :root { --ap-neutral: #E8E8E0; } + --imprint-green: #009E73; /* slot 0 — brand */ + --imprint-lavender: #C475FD; /* slot 1 */ + --imprint-blue: #4467A3; /* slot 2 */ + --imprint-ochre: #BD8233; /* slot 3 */ + --imprint-red: #AE3030; /* slot 4 — semantic anchor */ + --imprint-cyan: #2ABCCD; /* slot 5 */ + --imprint-rose: #954477; /* slot 6 */ + --imprint-lime: #99B314; /* slot 7 */ + + /* semantic anchors */ + --imprint-amber: #DDCC77; /* warning (fixed) */ + /* neutral + muted are aliased to --ink and --ink-muted which flip per theme */ } ``` @@ -1169,9 +1182,9 @@ For CSS: ### 10.3 Color -- **Purple (`#CC79A7`), Sky (`#56B4E9`), or Yellow (`#F0E442`) in UI chrome.** These are plot-only colors. Using them in navigation or buttons breaks the color hierarchy. +- **Lavender (`#C475FD`), cyan (`#2ABCCD`), rose (`#954477`), or lime (`#99B314`) in UI chrome.** These are plot-only colors. Using them in navigation or buttons breaks the color hierarchy. - **Brand green in backgrounds, body text emphasis, or non-logo icons.** Reserve `#009E73` for the seven approved contexts (§4.4). -- **Categorical palettes on continuous data.** Use viridis/cividis/BrBG instead — see §9.2. +- **Categorical palettes on continuous data.** Use `imprint_seq` (single-polarity) or `imprint_div` (diverging) instead — see §9.2. --- @@ -1181,7 +1194,7 @@ The design system is implemented across: - **HTML reference (full mockup)**: `mockups/landing.html` — single-file reference with all sections, SVG plots, and animations - **Theme tokens (frontend)**: `app/src/theme/index.ts` and `app/src/main.tsx` — MUI theme exports for colors, typography, spacing, headingStyle, subheadingStyle, textStyle, tableStyle, codeBlockStyle -- **Palette (Python library)**: `anyplot.palettes.okabe_ito` — see §9.5 +- **Palette (Python library)**: `anyplot.palette` — see §9.5 **Reference CSS skeleton:** @@ -1198,17 +1211,19 @@ The design system is implemented across: + + +
+

any.plot() — {page_title}

+
CAM02-UCS · v1 · #5817
+ +
+ + + +
+ strategy: {strategy_text}
+ paper-ink corridor: J' ∈ [{J_MIN:.0f}, {J_MAX:.0f}], C ∈ [{c_min_v:.0f}, {c_max_v:.0f}]. + first-4 reordered to maximise min worst-CVD ΔE within {{1..4}}, pairwise hue gap ≥60°. +
+ {score_html} + all-pairs normal min ΔE{normal_min:.2f} +
+
+ +
+

palette

+

{len(hues)} hues + 2 adaptive neutrals. positions 1–4 are the "first-4 most beautiful" subset chosen to maximise min worst-CVD ΔE. positions 5–{len(hues)} follow in descending min-distance-to-the-first-4. neutrals stay theme-adaptive (same as today's design tokens).

+ {swatches} +
+ +
+

color wheel

+

CAM02-UCS hue ring at L=60, C=40. each palette dot sits at its actual (C, H) coordinates — angle is the hue, distance from centre is the chroma. dashed circles mark this variant's chroma corridor. the brand-anchor green is marked with a star. {"" if is_baseline else "toggle the overlay to see live D's dot positions for comparison."}

+ {wheel} +
+ +
+

sample & first-n

+

first-4 chart on both production bg-page surfaces. the first-n table reads as "if you only use the first n positions, what's the weakest pair under normal vision vs. worst CVD".

+ {sample_charts} + {first_n} +
+ +
+

ΔE matrix

+

normal vision left, worst-of-3-cvd right. cells coloured by the 4-step Petroff-2021 scale: ≥15 optimal, 10–15 okay, 5–10 marginal, <5 confusable.

+ {matrix} + {legend} +
+ +
+

continuous colormaps

+

two cmaps derived from this variant's palette: a sequential (brand-green → dark blue-zone palette member) and a diverging (warmest palette member ↔ coolest palette member through a near-neutral). hues come from the palette so the cmap reads as the same identity; J' and C are tuned for monotonic lightness descent (sequential) or symmetric weight (diverging). below each gradient: MATLAB's peaks surface rendered with that cmap.

+ {cmap_block} +
+ +
+

on the website

+

hero mockup pair using this variant's brand position-1 colour as the green-dot anchor. wcag badges live-update against the production bg-page surfaces.

+ {hero_pair} +
+ + + + +""" + + +# ----------------------------------------------------------------------------- +# Index page (links all 5 variants) +# ----------------------------------------------------------------------------- + + +def render_compare_page(rows: list[tuple[Variant, list[str], float, float]]) -> str: + """One-page side-by-side comparison. v1 baseline = live D.""" + baseline_first4 = measure_first_4(ANYPLOT_D_PALETTE) + + rendered_rows = [] + # Render the live-D row first as the reference, then each candidate + baseline_seq_rgb, baseline_seq_label = build_sequential_cmap(ANYPLOT_D_PALETTE) + baseline_div_rgb, baseline_div_label = build_diverging_cmap(ANYPLOT_D_PALETTE) + full_rows: list[tuple[str, str, str, list[str], float, float, np.ndarray, str, np.ndarray, str]] = [] + full_rows.append(( + "D", "baseline", "the palette currently shipping in core/images.py — every candidate below is measured against this row", + ANYPLOT_D_PALETTE, baseline_first4, measure_all_normal_min(ANYPLOT_D_PALETTE), + baseline_seq_rgb, baseline_seq_label, baseline_div_rgb, baseline_div_label, + )) + for variant, hues, first4, normal_min in rows: + seq_rgb, seq_label = build_sequential_cmap(hues) + div_rgb, div_label = build_diverging_cmap(hues) + full_rows.append(( + variant.key, variant.title, variant.one_liner, + hues, first4, normal_min, + seq_rgb, seq_label, div_rgb, div_label, + )) + + for (key, title, one_liner, hues, first4, normal_min, + seq_rgb, seq_label, div_rgb, div_label) in full_rows: + chip_all = "".join( + f'' + for hx in hues + ) + ( + f'' + f'' + ) + seq_strip = render_gradient(seq_rgb[::4]) + div_strip = render_gradient(div_rgb[::4]) + seq_png = _peaks_png_b64(seq_rgb) + div_png = _peaks_png_b64(div_rgb) + seq_demo = f'peaks (sequential)' + div_demo = f'peaks (diverging)' + + is_baseline_row = (key == "D") + delta = first4 - baseline_first4 + delta_sign = "+" if delta >= 0 else "" + score_class = cell_class(first4) + if is_baseline_row: + delta_html = '★ baseline' + link_target = "D-baseline.html" + card_class = "compare-card compare-card-baseline" + else: + delta_class = 'delta-pos' if delta >= 0 else 'delta-neg' + delta_html = f'{delta_sign}{delta:.2f} vs live D' + # Find the variant slug for the link target + slug_lookup = {v.key: v.slug for v in VARIANTS} + link_target = f"{key}-{slug_lookup[key]}.html" + card_class = "compare-card" + + rendered_rows.append(f""" +
+
+
+ {key} +

{title}

+
+
+ first-4 worst-CVD{first4:.2f} + {delta_html} + open full ↗ +
+
+

{one_liner}.

+
{chip_all}
+
+
+
sequential — {seq_label}
+ {seq_strip} + {seq_demo} +
+
+
diverging — {div_label}
+ {div_strip} + {div_demo} +
+
+
+""") + + variant_nav_links = "".join( + f'{v.key} · {v.title}' + for v in VARIANTS + ) + + return f""" + + + + +palette variants v1 — side-by-side compare (#5817) + + + +
+

any.plot() — palette variants v1 · compare

+
CAM02-UCS · v1 · #5817
+ +
+ +
+

all candidates side-by-side against live D. each card shows the full 7-hue + 2-neutral palette (left to right), both palette-derived continuous colormaps (sequential green→dark blue-zone, diverging warmest↔coolest), and a peaks-function preview of each cmap. baseline live D first-4 worst-CVD ΔE = {baseline_first4:.2f} — every candidate's Δ is reported against that.

+ {"".join(rendered_rows)} +
+ + + +""" + + +def render_index_page(rows: list[tuple[Variant, list[str], float, float]]) -> str: + """v1 index: hero color wheel on top (live D), D-baseline card in the centre, + candidate cards arrayed around it with Δ-vs-D coloring and per-card small + wheels.""" + + baseline_first4 = measure_first_4(ANYPLOT_D_PALETTE) + baseline_normal = measure_all_normal_min(ANYPLOT_D_PALETTE) + baseline_chip_top = "".join( + f'' + for hx in ANYPLOT_D_PALETTE[:4] + ) + baseline_chip_tail = "".join( + f'' + for hx in ANYPLOT_D_PALETTE[4:] + ) + baseline_score_class = cell_class(baseline_first4) + baseline_wheel = render_color_wheel( + ANYPLOT_D_PALETTE, size_px=180, mode="small", + dom_id="card-wheel-baseline", + ) + baseline_card = f""" + +
+ +

D · baseline (live anyplot palette)

+
+

the palette currently shipping in core/images.py — the bar every v1 candidate is measured against. variant D came from the v0 round (Petroff max-min ΔE, paper-ink corridor C ∈ [22, 36]) and has been adopted as the active ANYPLOT_PALETTE.

+
+
+
+
{baseline_chip_top}
+
{baseline_chip_tail}
+
+
+ first-4 worst-CVD{baseline_first4:.2f} + all-pairs normal{baseline_normal:.2f} + Δ-vs-D0.00 +
+
+
{baseline_wheel}
+
+
open diagnostic →
+
+""" + + cards = [] + for variant, hues, first4, normal_min in rows: + chip_top = "".join( + f'' for hx in hues[:4] + ) + chip_tail = "".join( + f'' for hx in hues[4:] + ) + score_class = cell_class(first4) + delta = first4 - baseline_first4 + delta_sign = "+" if delta >= 0 else "" + delta_class = "delta-pos" if delta >= 0 else "delta-neg" + small_wheel = render_color_wheel( + hues, size_px=180, mode="small", + dom_id=f"card-wheel-{variant.key.lower()}", + ) + cards.append(f""" + +
+ {variant.key} +

{variant.title}

+
+

{variant.one_liner}.

+
+
+
+
{chip_top}
+
{chip_tail}
+
+
+ first-4 worst-CVD{first4:.2f} + all-pairs normal{normal_min:.2f} + Δ-vs-D{delta_sign}{delta:.2f} +
+
+
{small_wheel}
+
+
open →
+
+""") + + # Hero: large wheel of live D + a toggle row that lets the viewer overlay + # any candidate's dots on top to compare geometry without leaving the page. + hero_wheel = render_color_wheel( + ANYPLOT_D_PALETTE, size_px=420, mode="large", + chroma_corridor=(22.0, 36.0), + overlay_hexes=None, # overlay is dynamically swapped by the candidate-toggle row + dom_id="hero-wheel", + ) + candidate_toggle_buttons = "".join( + f'' + for (v, h, _, _) in rows + ) + + # Variant nav (same shape as v0 but using the v1 roster) + variant_nav_links = "".join( + f'{v.key} · {v.title}' + for v in VARIANTS + ) + + return f""" + + + + +palette variants v1 — anyplot #5817 + + + +
+

any.plot() — palette variants v1 (#5817)

+
CAM02-UCS · v1 challenges live D · Petroff target ≥ 15
+ +
+ + +
+

v0 (palette-variants/) explored 6 candidates against Okabe-Ito and led to + variant D being adopted as the live ANYPLOT_PALETTE in + core/images.py. v1 reverses the framing: every candidate here is measured against + live D, not Okabe-Ito. the bar is D's own first-4 worst-CVD ΔE + of {baseline_first4:.2f}. a candidate that doesn't measurably beat that is not + worth the migration cost.

+

v1 splits into refine (three D-family tweaks D1/D2/D3) and rethink + (two fresh strategies T tetradic and W warm-pole). same paper-ink corridor + (J' ∈ [{J_MIN:.0f}, {J_MAX:.0f}], C ∈ [{C_MIN:.0f}, {C_MAX:.0f}]) and same greedy max-min ΔE under + normal + 3 CVD conditions. each candidate page includes a CAM02-UCS color wheel + that places every hue at its actual (C, H) coordinates — toggle the overlay to + see how the candidate's geometry compares with live D.

+
+ +
+
{hero_wheel}
+
+

live D on the color wheel

+

each dot sits at its real (C, H) coordinates in CAM02-UCS — angle is the hue, + distance from centre is the chroma. dashed rings mark the paper-ink corridor + (C ∈ [22, 36]). brand anchor is the green star. click a candidate below to + overlay its dot positions as outlined circles — distance shifts visualise the + chroma/hue cost of each refinement.

+
+ + {candidate_toggle_buttons} +
+
+
+ +
+{baseline_card} +{"".join(cards)} +
+ +
+

baseline (live D) first-4 worst-CVD min ΔE = {baseline_first4:.2f} + — the bar these candidates try to clear. v0 round of variants A–F is preserved at + ../palette-variants/. references: petroff (2021) arXiv:2107.02270, + okabe & ito (2008), wong (2011), machado et al. (2009).

+
+ + + + +""" + + +# ----------------------------------------------------------------------------- +# CLI +# ----------------------------------------------------------------------------- + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate palette variants v1 for #5817") + parser.add_argument( + "--out-dir", type=Path, default=DEFAULT_OUT_DIR, + help=f"Output directory (default: {DEFAULT_OUT_DIR})", + ) + parser.add_argument("--quiet", action="store_true") + args = parser.parse_args() + + logging.basicConfig( + level=logging.WARNING if args.quiet else logging.INFO, + format="%(message)s", + ) + log = logging.getLogger("palette-variants-v1") + + args.out_dir.mkdir(parents=True, exist_ok=True) + + pool = CandidatePool.build(log) + + rows: list[tuple[Variant, list[str], float, float]] = [] + + # Pinning: v1 D-family + warm-pole are "no anchors past brand-green"; only + # tetradic has explicit pos 1-3 anchors that should not be reshuffled. + # D1-8 pins pos 1 (= the hue-band red #AE3030 from _strategy_bands) AND + # opts out of reorder_first_4's wheel-gap-first heuristic (see + # USE_PURE_CVD_REORDER below) — the 60°-gap-first rule was picking a + # CVD-weak first-4 like {green, blue, tan, mauve} whenever the 8th hue + # opened new gap-valid quadruples. + PINNED: dict[str, tuple[int, ...]] = { + "tetradic": (1, 2, 3), + "d-tight-chroma-8": (1,), + } + # Strategies that should skip reorder_first_4 (wheel-gap-first) and use + # reorder_pure_cvd_greedy instead — strictly CVD max-min ordering, slowest + # possible degradation of the worst-pair curve as n grows. + USE_PURE_CVD_REORDER = {"d-tight-chroma-8"} + + # Per-strategy select_palette kwargs unique to v1. + FORBIDDEN_BANDS: dict[str, tuple[tuple[float, float], ...]] = { + # (currently no global hue exclusions in v1 — D3 was redesigned from + # "swap-tan" into "expand-8" since tan + the new pick fill opposite + # wheel gaps, so there's no reason to forbid either.) + } + # Semantic-red anchor — pinned for strategies whose natural picks fail to + # land on a true red, so plots can still map loss/error/bad to the expected + # colour rather than a tight-corridor brown or warm-bonus orange. + SEMANTIC_RED = "#B71D27" # live D's ANYPLOT_RED, same source as ANYPLOT_D_PALETTE[2] + EXTRA_SEEDS: dict[str, tuple[str, ...]] = { + # D1 — no extra_seed; a hue-band constraint at position 1 in + # _strategy_bands keeps the picked red inside the tight chroma corridor + # (a matte bordeaux ≈ L60·H25·C30 instead of the corridor-violating + # live D #B71D27 at C≈44). + "warm-pole": (SEMANTIC_RED,), + # D3 — pin every non-brand member of live D as a seed so reorder_first_4 + # works on the full live-D set plus one greedy 8th pick (positions 1-6 + # of live D become extra_seeds; brand-green is the implicit pos-0 seed). + "d-expand-8": tuple(ANYPLOT_D_PALETTE[1:]), + } + WARM_BONUS: dict[str, tuple[float, float, float]] = { + # W — additive bonus centred at 55° (warm orange-red), half-width 30°, + # weight 3.0 ΔE units at the centre. Strong enough to nudge selection + # toward warms without overriding the no-clash gap mask. + "warm-pole": (55.0, 30.0, 3.0), + } + + baseline_4 = measure_first_4(ANYPLOT_D_PALETTE) + log.info("baseline live D first-4 worst-CVD min ΔE = %.2f", baseline_4) + + for variant in VARIANTS: + log.info("generating variant %s. %s …", variant.key, variant.title) + hues = select_palette( + variant.strategy, pool, n_hues=variant.n_hues, + extra_seeds=EXTRA_SEEDS.get(variant.strategy, ()), + forbidden_hue_bands=FORBIDDEN_BANDS.get(variant.strategy, ()), + warm_bonus=WARM_BONUS.get(variant.strategy), + ) + if variant.strategy in USE_PURE_CVD_REORDER: + hues = reorder_pure_cvd_greedy(hues, pinned=PINNED.get(variant.strategy, ())) + else: + hues = reorder_first_4(hues, pinned=PINNED.get(variant.strategy, ())) + + first_4 = measure_first_4(hues) + normal_min = measure_all_normal_min(hues) + log.info( + " hues: %s", + " ".join(hues), + ) + log.info( + " first-4 worst-CVD min ΔE = %.2f (baseline live D = %.2f; Δ %+.2f)", + first_4, baseline_4, first_4 - baseline_4, + ) + + seq_rgb, seq_label = build_sequential_cmap(hues) + div_rgb, div_label = build_diverging_cmap(hues) + html = render_variant_page(variant, hues, seq_rgb, seq_label, div_rgb, div_label) + + out_path = args.out_dir / f"{variant.key}-{variant.slug}.html" + out_path.write_text(html, encoding="utf-8") + size_kb = out_path.stat().st_size / 1024 + log.info(" wrote %s (%.1f kB)", out_path, size_kb) + + rows.append((variant, hues, first_4, normal_min)) + + # Render the baseline (live D) using the same template as candidates so + # it sits in the lineup as "the one to beat". + log.info("generating D-baseline (live anyplot palette) diagnostic page …") + baseline_variant = Variant( + "D", "baseline", "baseline", + "balanced", # so PER_VARIANT_C_RANGE returns live D's corridor (22, 36) + "the live anyplot palette currently shipping in core/images.py", + ) + baseline_seq_rgb, baseline_seq_label = build_sequential_cmap(ANYPLOT_D_PALETTE) + baseline_div_rgb, baseline_div_label = build_diverging_cmap(ANYPLOT_D_PALETTE) + baseline_html = render_variant_page( + baseline_variant, ANYPLOT_D_PALETTE, + baseline_seq_rgb, baseline_seq_label, + baseline_div_rgb, baseline_div_label, + is_baseline=True, + ) + baseline_path = args.out_dir / "D-baseline.html" + baseline_path.write_text(baseline_html, encoding="utf-8") + log.info(" wrote %s (%.1f kB)", baseline_path, baseline_path.stat().st_size / 1024) + + index_html = render_index_page(rows) + index_path = args.out_dir / "index.html" + index_path.write_text(index_html, encoding="utf-8") + log.info("wrote %s (%.1f kB)", index_path, index_path.stat().st_size / 1024) + + compare_html = render_compare_page(rows) + compare_path = args.out_dir / "compare.html" + compare_path.write_text(compare_html, encoding="utf-8") + log.info("wrote %s (%.1f kB)", compare_path, compare_path.stat().st_size / 1024) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/palette-variants-v2.py b/scripts/palette-variants-v2.py new file mode 100644 index 0000000000..5919571ff1 --- /dev/null +++ b/scripts/palette-variants-v2.py @@ -0,0 +1,916 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "colorspacious>=1.1.2", +# "numpy>=2.0", +# "matplotlib>=3.10", +# "pillow>=11.0", +# ] +# /// +"""Palette variants v2 — head-to-head: vivid-8 (D3) vs muted-8 (D1-8). + +v1 produced five candidate variants (D-baseline, D1, D1-8, D3, T, W). Two +emerged as the realistic contenders for the next live ANYPLOT_PALETTE: + + vivid-8 (was D3 / d-expand-8) — chroma corridor C ∈ [22, 36], + live D's 7 hues + 1 indigo greedy + pick in the largest wheel gap; + max CVD-distance headroom. + + muted-8 (was D1-8 / d-tight-chroma-8) — chroma corridor C ∈ [24, 32], + live D's max-min selection inside + a tighter paper-ink band + 1 matte + rosé in the back-gap; cleaner + co-existence in dense charts. + +The two palettes are colour-identical to v1's D3 / D1-8; only the slot +**order** of the 8 hues can vary, and the order matters because the +review-loop picks colours in order from positions 0..n. v2 explores +**different sort orderings** for the same two palettes and shows them +side-by-side per sorting in a single HTML page so a human can pick which +combination of palette × sorting feels best. + +Layout per sorting section +-------------------------- + ┌──────────────────────┬──────────────────────┐ + │ vivid-8 strip + n→ΔE │ muted-8 strip + n→ΔE │ + ├──────────────────────┼──────────────────────┤ + │ vivid-8 light chart │ muted-8 light chart │ + ├──────────────────────┼──────────────────────┤ + │ vivid-8 dark chart │ muted-8 dark chart │ + └──────────────────────┴──────────────────────┘ + +Sortings included +----------------- + 1. pure-CVD greedy max-min (slowest possible per-n ΔE degradation; + pos 0 fixed brand-green; pos 1 fixed at + muted-8's semantic red for stability) + 2. wheel-gap-first (v1's reorder_first_4 — first-4 picked by + widest pairwise hue-gap at ≥60° then rest + by descending distance to first-4) + 3. hue-order (pos 0 = brand green; rest by hue angle + clockwise — natural rainbow, ignores CVD) + 4. every-other-hue (hue-order, then interleave: first-4 = every + other wedge for maximally even wheel + coverage; rest are the in-between wedges) + +Run:: + + uv run --script scripts/palette-variants-v2.py +""" + +from __future__ import annotations + +import argparse +import html +import importlib.util +import logging +import math +import random +import sys +from pathlib import Path +from typing import Callable, Sequence + +import numpy as np + + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +# Import v1 utilities (hyphenated filename → importlib). +_V1_SPEC = importlib.util.spec_from_file_location( + "palette_variants_v1", REPO_ROOT / "scripts" / "palette-variants-v1.py" +) +assert _V1_SPEC is not None and _V1_SPEC.loader is not None +v1 = importlib.util.module_from_spec(_V1_SPEC) +# Register before exec_module so @dataclass can resolve cls.__module__ during +# class construction (otherwise dataclasses.py raises NoneType.__dict__). +sys.modules["palette_variants_v1"] = v1 +_V1_SPEC.loader.exec_module(v1) + +from _palette_common import ( # noqa: E402 + DARK_THEME_FULL, + LIGHT_THEME_FULL, + PAGE_CSS, + PAGE_JS, + hex_to_rgb1, + pairwise_delta_e, + render_sample_bars, + render_sample_chart, + to_jab, + worst_cvd_pairwise_delta_e, +) + + +# ----------------------------------------------------------------------------- +# Palette definitions — colour-identical to v1's D3 / D1-8 picks +# ----------------------------------------------------------------------------- + +VIVID_8: list[str] = [ + "#009E73", # brand green + "#9418DB", # purple — live D pos 1 + "#B71D27", # red — live D pos 2 + "#16B8F3", # cyan — live D pos 3 + "#99B314", # lime — live D pos 4 + "#D359A7", # pink — live D pos 5 + "#7981FD", # indigo — v1 D3 8th-slot pick + "#BA843E", # tan — live D pos 6 +] + +MUTED_8: list[str] = [ + "#009E73", # brand green + "#AE3030", # matte red — pinned via hue-band [25°±10°] in tight corridor + "#C475FD", # purple + "#99B314", # lime + "#4467A3", # blue + "#2ABCCD", # cyan + "#954477", # matte rosé — v1 D1-8 8th-slot pick (back-gap bridge) + "#BD8233", # tan +] + + +PALETTES: list[tuple[str, str, list[str], str]] = [ + ( + "vivid-8", + "wide chroma corridor C ∈ [22, 36] — live D's 7 hues plus a greedy 8th indigo pick that fills the wheel gap opposite tan. max CVD-headroom, the best worst-pair ΔE at small n.", + VIVID_8, + "rgb(72, 158, 116)", # accent for section headings + ), + ( + "muted-8", + "tight chroma corridor C ∈ [24, 32] — D1's max-min selection plus a matte rosé filling the 75° back-gap between purple and red. lower per-pair ΔE ceiling but flatter co-existence inside dense small-multiple charts.", + MUTED_8, + "rgb(86, 132, 191)", + ), +] + + +# ----------------------------------------------------------------------------- +# Sorting functions +# ----------------------------------------------------------------------------- + + +def _hue(hex_str: str) -> float: + rgb = hex_to_rgb1(hex_str).reshape(1, 3) + jab = to_jab(rgb)[0] + _, _, H = v1.jab_to_lch(jab) + return H + + +def _lightness(hex_str: str) -> float: + rgb = hex_to_rgb1(hex_str).reshape(1, 3) + jab = to_jab(rgb)[0] + L, _, _ = v1.jab_to_lch(jab) + return L + + +def sort_pure_cvd_greedy(hexes: list[str]) -> list[str]: + """Only pos 0 (brand green) fixed; positions 1..n picked iteratively to + maximise min worst-CVD ΔE against the already-placed set. No semantic + anchors — pure algorithmic CVD optimisation.""" + return v1.reorder_pure_cvd_greedy(hexes, pinned=()) + + +def sort_wheel_gap_first(hexes: list[str]) -> list[str]: + """v1's reorder_first_4: widest-gap first-4 then rest by descending distance.""" + return v1.reorder_first_4(hexes) + + +def sort_hue_order(hexes: list[str]) -> list[str]: + """pos 0 = brand green; rest sorted by hue angle clockwise from green.""" + brand_hue = _hue(hexes[0]) + rest = sorted(hexes[1:], key=lambda hx: (_hue(hx) - brand_hue) % 360) + return [hexes[0], *rest] + + +def sort_every_other_hue(hexes: list[str]) -> list[str]: + """Hue-ordered then interleaved: first-4 = every-other wedge for maximally + even wheel coverage; the in-between wedges follow as the second-4.""" + full = sort_hue_order(hexes) + n = len(full) + even = [full[i] for i in range(0, n, 2)] + odd = [full[i] for i in range(1, n, 2)] + return [*even, *odd] + + +SORTINGS: list[tuple[str, str, str, Callable[[list[str]], list[str]]]] = [ + ( + "pure-cvd-greedy", + "pure-CVD greedy max-min", + "only pos 0 (brand green) fixed; positions 1..n picked iteratively to maximise min worst-CVD ΔE against the already-placed set. No semantic anchors — pure algorithmic CVD optimisation. Designed to keep the per-n worst-pair curve as high as possible for chart series-count growth.", + sort_pure_cvd_greedy, + ), + ( + "wheel-gap-first", + "wheel-gap-first (v1 reorder_first_4)", + "v1 algorithm: among 3-tuples joining brand green, pick the one whose 4-set has the widest pairwise hue-gap at ≥60° (degrades in 5° steps if no quadruple satisfies); rest by descending min-distance to first-4. Trades CVD distinctness for visual wheel symmetry.", + sort_wheel_gap_first, + ), + ( + "hue-order", + "hue-order (rainbow)", + "pos 0 = brand green; remaining 7 hues sorted by hue angle going clockwise around the wheel. Pure “natural rainbow” order — ignores CVD distance entirely. Useful when the chart's series correspond to an ordered category (time, magnitude bins) and the rainbow conveys that order.", + sort_hue_order, + ), + ( + "every-other-hue", + "every-other-hue (interleaved)", + "Hue-order full set, then interleave: first-4 = every other wedge for maximally even wheel coverage; the in-between wedges follow as the second-4. The first-4 still spans the whole wheel symmetrically — like wheel-gap-first but constructed via interleaving instead of search.", + sort_every_other_hue, + ), +] + + +# ----------------------------------------------------------------------------- +# Measurements +# ----------------------------------------------------------------------------- + + +def measure_per_n(hexes: list[str]) -> list[float]: + """Worst-CVD ΔE of the weakest pair inside the first-n subset, for n=2..N. + Takes the min across normal + 3 CVD simulations.""" + rgb = np.array([hex_to_rgb1(h) for h in hexes]) + M, _ = worst_cvd_pairwise_delta_e(rgb) + return _weakest_pair_per_n(M, len(hexes)) + + +def measure_per_n_normal(hexes: list[str]) -> list[float]: + """Normal-vision pairwise ΔE of the weakest pair in first-n, no CVD sim.""" + rgb = np.array([hex_to_rgb1(h) for h in hexes]) + M = pairwise_delta_e(rgb, "normal") + return _weakest_pair_per_n(M, len(hexes)) + + +def _weakest_pair_per_n(M: np.ndarray, n: int) -> list[float]: + out: list[float] = [] + for k in range(2, n + 1): + sub = M[:k, :k].copy() + np.fill_diagonal(sub, np.inf) + out.append(round(float(sub.min()), 2)) + return out + + +# ----------------------------------------------------------------------------- +# HTML rendering +# ----------------------------------------------------------------------------- + + +V2_CSS = """ +.v2-hero { padding: 32px 28px 18px; } +.v2-hero h1 { margin: 0 0 4px; font-size: 26px; } +.v2-hero p.subtitle { margin: 0 0 8px; color: var(--ink-muted); font-size: 14px; } +.v2-pair-row { + display: grid; grid-template-columns: 1fr 1fr; gap: 24px; + margin: 20px 0; +} +.v2-pair-cell { min-width: 0; } +.v2-pair-cell h2 { margin: 0 0 8px; font-size: 18px; } +.v2-pair-cell h3 { margin: 14px 0 6px; font-size: 13px; color: var(--ink-muted); font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; } +.v2-pair-cell p { margin: 0 0 8px; font-size: 13px; color: var(--ink-muted); line-height: 1.5; } + +/* Strips row uses a column-major grid so corresponding rows (heading, intro, + strip, table) line up between left and right cells even if the intro + paragraph wraps to a different number of lines on each side. */ +.v2-pair-grid { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 24px; + row-gap: 6px; + margin: 20px 0; +} +.v2-pair-grid .pg-head { margin: 0 0 4px; font-size: 18px; } +.v2-pair-grid .pg-intro { margin: 0; font-size: 13px; color: var(--ink-muted); line-height: 1.5; align-self: end; } +.v2-pair-grid .pg-wheel { display: flex; justify-content: center; padding: 8px 0; } +.v2-pair-grid .pg-wheel svg { max-width: 100%; height: auto; } +/* corridor ring is always visible in v2; drop the toggle UI from v1's wheel. */ +.v2-pair-grid .wheel-toggles { display: none; } + +.v2-palette-strip { display: flex; height: 70px; border-radius: 6px; overflow: hidden; box-shadow: 0 0 0 1px var(--rule); } +.v2-palette-strip .swatch { flex: 1; display: flex; align-items: center; justify-content: center; font-family: var(--mono); font-size: 10px; } +.v2-pertable { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 11px; margin-top: 8px; } +.v2-pertable th, .v2-pertable td { padding: 4px 6px; text-align: right; border-bottom: 1px solid var(--rule); } +.v2-pertable th:first-child, .v2-pertable td:first-child { text-align: left; color: var(--ink-muted); } +.v2-pertable td.good { color: #2a8d52; font-weight: 600; } +.v2-pertable td.warn { color: #b56b18; } +.v2-pertable td.bad { color: #b62d2d; font-weight: 600; } + +.v2-section { padding: 28px; border-top: 1px solid var(--rule); scroll-margin-top: 60px; } +.v2-section > h2 { margin: 0 0 4px; font-size: 22px; } +.v2-section > p.intro { margin: 0 0 18px; color: var(--ink-muted); font-size: 14px; max-width: 80ch; line-height: 1.55; } + +.v2-scorecard { + display: flex; flex-direction: column; gap: 4px; + margin: 0 0 16px; + padding: 10px 12px; + border: 1px solid var(--rule); border-radius: 6px; + background: color-mix(in srgb, var(--bg-page) 60%, transparent); + font-family: var(--mono); font-size: 12px; +} +.v2-scorecard .sc-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.v2-scorecard .sc-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-muted); width: 52px; flex-shrink: 0; } +.v2-scorecard .sc-strip { display: flex; gap: 3px; } +.v2-scorecard .sc-cell { padding: 2px 5px; border-radius: 3px; font-size: 10px; } +.v2-scorecard .sc-cell.win-l { background: rgba(72, 158, 116, 0.30); color: var(--ink); } +.v2-scorecard .sc-cell.win-r { background: rgba(86, 132, 191, 0.32); color: var(--ink); } +.v2-scorecard .sc-cell.win-tie { background: rgba(120, 120, 120, 0.25); color: var(--ink-muted); } + +.v2-charts-stack { display: grid; gap: 12px; } +.v2-charts-stack .sample-chart { width: 100%; height: auto; } +.v2-charts-stack .theme-divider { + margin: 16px 0 4px; + padding-top: 12px; + border-top: 1px solid var(--rule); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink); + font-weight: 600; +} +.v2-charts-stack .theme-divider:first-child { + margin-top: 0; padding-top: 0; border-top: none; +} + +.v2-toc { + position: sticky; top: 0; z-index: 50; + padding: 10px 28px; border-bottom: 1px solid var(--rule); + background: color-mix(in srgb, var(--bg-page) 92%, transparent); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; align-items: center; gap: 16px; +} +.v2-toc h2 { margin: 0; font-size: 11px; color: var(--ink-muted); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; flex-shrink: 0; } +.v2-toc ol { margin: 0; padding: 0; list-style: none; display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; } +.v2-toc a { color: var(--ink); text-decoration: none; border-bottom: 1px dashed var(--ink-muted); } +.v2-toc a:hover { border-bottom-style: solid; } + +@media (max-width: 900px) { + .v2-pair-row { grid-template-columns: 1fr; } +} +""" + + +def h(s: str) -> str: + return html.escape(str(s), quote=True) + + +def _swatch_text_color(hx: str) -> str: + r, g, b = hex_to_rgb1(hx) + luma = 0.299 * r + 0.587 * g + 0.114 * b + return "#111" if luma > 0.55 else "#FAF8F1" + + +def render_palette_strip(hexes: list[str]) -> str: + cells = "".join( + f'
{h(hx)}
' + for hx in hexes + ) + return f'
{cells}
' + + +def _classify_per_n(val: float) -> str: + if val >= 15.0: + return "good" + if val >= 10.0: + return "warn" + return "bad" + + +def render_scorecard( + left_name: str, left_cvd: list[float], left_norm: list[float], + right_name: str, right_cvd: list[float], right_norm: list[float], +) -> str: + """Compact win-per-n strip for one sorting. Two rows: CVD and + normal-vision, each showing per-n winner as a coloured n=k chip.""" + def winners(a: list[float], b: list[float]) -> list[str]: + out: list[str] = [] + for av, bv in zip(a, b): + if abs(av - bv) < 0.05: # treat ≤0.05 ΔE as a tie + out.append("=") + elif av > bv: + out.append("L") + else: + out.append("R") + return out + + def strip(labels: list[str]) -> str: + cells = [] + for i, lab in enumerate(labels): + cls = {"L": "win-l", "R": "win-r", "=": "win-tie"}[lab] + cells.append(f'n={i + 2}') + return "".join(cells) + + return ( + '
' + '
' + 'CVD' + f'
{strip(winners(left_cvd, right_cvd))}
' + '
' + '
' + 'normal' + f'
{strip(winners(left_norm, right_norm))}
' + '
' + '
' + ) + + +def render_per_n_table(values_cvd: list[float], values_normal: list[float]) -> str: + headers = "".join(f"n={i + 2}" for i in range(len(values_cvd))) + cvd_cells = "".join( + f'{v:.2f}' for v in values_cvd + ) + normal_cells = "".join( + f'{v:.2f}' for v in values_normal + ) + return ( + '' + f"{headers}" + "" + f"{normal_cells}" + f"{cvd_cells}" + "" + "
worst-pair ΔE
normal vision
under CVD (min)
" + ) + + +def render_pair_grid( + items: list[tuple[str, list[str], list[float], list[float], str]] +) -> str: + """Column-major grid: row 1 = headings, row 2 = intros, row 3 = strips, + row 4 = tables. Each row is filled left-then-right so corresponding items + share the same grid-row and align vertically regardless of intro length.""" + headings = "".join(f'

{h(name)}

' for name, *_ in items) + intros = "".join( + f'

{h(blurb)}

' for _, _, _, _, blurb in items + ) + strips = "".join( + f"
{render_palette_strip(hx)}
" for _, hx, _, _, _ in items + ) + tables = "".join( + f"
{render_per_n_table(per_n_cvd, per_n_norm)}
" + for _, _, per_n_cvd, per_n_norm, _ in items + ) + return f'
{headings}{intros}{strips}{tables}
' + + +def render_sample_stocks( + theme: dict[str, str], theme_label: str, series_hexes: Sequence[str] +) -> str: + """Inline SVG stock-style time series — 4 random-walk paths normalised + around a 100 baseline, each with a distinct drift. Tests how the first-4 + sorted picks read in a dense overlapping line context (typical financial + visualisation: noisy daily ticks, all four crossing each other often).""" + width, height = 460, 240 + margin_l, margin_r, margin_t, margin_b = 38, 22, 22, 26 + plot_w = width - margin_l - margin_r + plot_h = height - margin_t - margin_b + + bg = theme["bg_page"] + ink_muted = theme["ink_muted"] + rule = theme.get("rule", "#DFDDD6") + is_light = bg.upper().startswith("#F") + grid = "rgba(26,26,23,0.15)" if is_light else "rgba(240,239,232,0.15)" + + n_points = 90 + # Per-series annualised-style drift + per-step vol. Picked deterministically + # so the chart looks the same across runs and across the two palettes. + series_specs = [ + (+0.18, 1.5), # series 0 — moderate uptrend + (-0.10, 1.2), # series 1 — gentle decline + (+0.05, 2.4), # series 2 — flat-ish but volatile + (-0.02, 1.0), # series 3 — slightly down + ] + rnd = random.Random(7) + series_values: list[list[float]] = [] + for drift, vol in series_specs[: len(series_hexes)]: + v = 100.0 + path = [v] + for _ in range(n_points - 1): + step = drift + rnd.gauss(0, vol) + v = max(40.0, v + step) # floor so prices stay positive-ish + path.append(v) + series_values.append(path) + + # Normalise y to the combined min/max range, padded slightly. + all_vals = [v for s in series_values for v in s] + v_min, v_max = min(all_vals), max(all_vals) + pad = (v_max - v_min) * 0.08 or 1.0 + y_lo = v_min - pad + y_hi = v_max + pad + + grid_lines = "".join( + f'' + for f in (0.25, 0.5, 0.75) + ) + + polylines: list[str] = [] + for i, path in enumerate(series_values): + pts = [] + for j, val in enumerate(path): + x = j / (n_points - 1) + y = (val - y_lo) / (y_hi - y_lo) + px = margin_l + x * plot_w + py = margin_t + (1 - y) * plot_h + pts.append(f"{px:.1f},{py:.1f}") + polylines.append( + f'' + ) + + axes = ( + f'' + f'' + ) + + # Price-axis ticks (a couple of round values inside the range) + tick_count = 4 + tick_labels: list[str] = [] + for k in range(tick_count + 1): + f = k / tick_count + val = y_lo + (y_hi - y_lo) * (1 - f) + ty = margin_t + plot_h * f + 3 + tick_labels.append( + f'{val:.0f}' + ) + + label = ( + f'{h(theme_label)} — stocks (first 4)' + ) + + return ( + f'' + f"{label}{grid_lines}{axes}{''.join(polylines)}{''.join(tick_labels)}" + "" + ) + + +def render_sample_pie( + theme: dict[str, str], theme_label: str, series_hexes: Sequence[str] +) -> str: + """Inline SVG pie chart — 4 wedges with deliberately uneven sizes so each + colour shows at a different arc length. Tests how the first-4 sorted + picks read when they share boundaries directly (no axes, no whitespace + between wedges other than a hair-thin bg-coloured stroke).""" + width, height = 460, 240 + margin_l, margin_t = 36, 22 + + bg = theme["bg_page"] + ink_muted = theme["ink_muted"] + rule = theme.get("rule", "#DFDDD6") + + series = list(series_hexes) + fractions = [0.35, 0.27, 0.22, 0.16][: len(series)] + total = sum(fractions) or 1.0 + fractions = [f / total for f in fractions] + + # Pie centred vertically below the title strip; radius bounded by the + # smaller plot dimension so it stays within the SVG even on the + # 460-wide / 240-tall canvas. + cx = width / 2 + cy = margin_t + (height - margin_t - 12) / 2 + r = min((height - margin_t - 12) / 2 - 6, 92) + + wedges: list[str] = [] + start = -math.pi / 2 # start at 12 o'clock + for i, frac in enumerate(fractions): + end = start + frac * 2 * math.pi + x1 = cx + r * math.cos(start) + y1 = cy + r * math.sin(start) + x2 = cx + r * math.cos(end) + y2 = cy + r * math.sin(end) + large_arc = 1 if frac > 0.5 else 0 + d = ( + f"M {cx:.2f} {cy:.2f} " + f"L {x1:.2f} {y1:.2f} " + f"A {r:.2f} {r:.2f} 0 {large_arc} 1 {x2:.2f} {y2:.2f} Z" + ) + wedges.append( + f'' + ) + start = end + + label = ( + f'{h(theme_label)} — pie (first 4)' + ) + + return ( + f'' + f"{label}{''.join(wedges)}" + "" + ) + + +def render_overlap_scatter( + theme: dict[str, str], theme_label: str, series_hexes: Sequence[str] +) -> str: + """Scatter chart where each series has its own cluster near the plot + perimeter, but with a wide enough Gaussian tail that the inner edges of + all clusters meet and overlap in the middle. Tests how the palette reads + at the edges (single colour visible cleanly) AND in the middle (multiple + colours collide pixel-by-pixel). Z-order shuffled so no colour stays on + top.""" + width, height = 460, 240 + margin_l, margin_r, margin_t, margin_b = 36, 18, 22, 26 + plot_w = width - margin_l - margin_r + plot_h = height - margin_t - margin_b + + bg = theme["bg_page"] + ink_muted = theme["ink_muted"] + rule = theme.get("rule", "#DFDDD6") + is_light = bg.upper().startswith("#F") + grid = "rgba(26,26,23,0.15)" if is_light else "rgba(240,239,232,0.15)" + + rnd = random.Random(42) + n_series = len(series_hexes) + # Each colour contributes two Gaussian blobs: + # 1. EDGE blob — its own dedicated cluster on the perimeter (tight σ so + # adjacent colours don't bleed into each other much). + # 2. CENTER blob — a small mass at (0.5, 0.5) so all 8 colours mix in + # the middle (otherwise opposite colours never meet — only adjacent + # ones overlap, and the centre stays empty). + # Cluster centres ring around an ellipse — radii sized to roughly account + # for the chart's ~2:1 aspect so the visual ring looks circular on screen. + r_x = 0.35 + r_y = 0.37 + sigma_edge_x = 0.060 + sigma_edge_y = 0.070 + sigma_center = 0.105 + n_edge = 16 + n_center = 12 + + grid_lines = "".join( + f'' + for f in (0.25, 0.5, 0.75) + ) + + dots: list[tuple[float, float, float, str]] = [] + for i, color in enumerate(series_hexes): + # Start the ring at 12 o'clock so brand-green sits at the top in both + # palettes — the eye can compare same-screen-position swatches across + # vivid-8 and muted-8. + theta = -math.pi / 2 + i * (2 * math.pi / n_series) + edge_x = 0.5 + r_x * math.cos(theta) + edge_y = 0.5 + r_y * math.sin(theta) + + # Edge blob + for _ in range(n_edge): + x = max(0.04, min(0.96, edge_x + rnd.gauss(0, sigma_edge_x))) + y = max(0.04, min(0.96, edge_y + rnd.gauss(0, sigma_edge_y))) + px = margin_l + x * plot_w + py = margin_t + (1 - y) * plot_h + dots.append((px, py, 3.4, color)) + + # Center mix blob — same σ on both axes (ellipse not needed; we want a + # round mass exactly at the middle). + for _ in range(n_center): + x = max(0.04, min(0.96, 0.5 + rnd.gauss(0, sigma_center))) + y = max(0.04, min(0.96, 0.5 + rnd.gauss(0, sigma_center))) + px = margin_l + x * plot_w + py = margin_t + (1 - y) * plot_h + dots.append((px, py, 3.4, color)) + + # Shuffle so the z-order across colours is randomised — no single colour + # permanently sits on top of the stack. + rnd.shuffle(dots) + dots_svg = "".join( + f'' + for (px, py, r, color) in dots + ) + + axes = ( + f'' + f'' + ) + + label = ( + f'{h(theme_label)} — scatter (edge-clusters, centre-overlap)' + ) + + return ( + f'' + f"{label}{grid_lines}{axes}{dots_svg}" + "" + ) + + +def _charts_stack(label: str, hexes: list[str]) -> str: + """Per-cell vertical stack — first ALL light-theme charts, then ALL + dark-theme charts (each block: lines / bars all-8 / pie first-4 / + stocks first-4 / scatter centre-overlap).""" + first_4 = hexes[:4] + + def block(theme: dict[str, str], theme_label: str) -> str: + return ( + f'

{h(theme_label)}

' + '

lines

' + f'{render_sample_chart(theme, label, hexes)}' + '

bars (all 8)

' + f'{render_sample_bars(theme, label, hexes)}' + '

pie (first 4)

' + f'{render_sample_pie(theme, label, first_4)}' + '

stocks (first 4)

' + f'{render_sample_stocks(theme, label, first_4)}' + '

scatter (edge clusters, centre overlap)

' + f'{render_overlap_scatter(theme, label, hexes)}' + ) + + return ( + '
' + f"{block(LIGHT_THEME_FULL, 'light theme')}" + f"{block(DARK_THEME_FULL, 'dark theme')}" + "
" + ) + + +def render_charts_pair( + label_left: str, hexes_left: list[str], label_right: str, hexes_right: list[str] +) -> str: + """2-col grid with the full chart-stack per palette.""" + return ( + '
' + f"{_charts_stack(label_left, hexes_left)}" + f"{_charts_stack(label_right, hexes_right)}" + "
" + ) + + +def render_sorting_section( + section_id: str, + title: str, + intro: str, + sorted_palettes: list[tuple[str, list[str], str]], +) -> str: + # Top: column-major grid so headings/intros/strips/tables align by row + # even when intros differ in line count. + pair_items = [ + (name, hexes, measure_per_n(hexes), measure_per_n_normal(hexes), blurb) + for name, hexes, blurb in sorted_palettes + ] + strips_row = render_pair_grid(pair_items) + + # Scorecard: per-n winner strips for CVD and normal vision. + name_l, hexes_l, _ = sorted_palettes[0] + name_r, hexes_r, _ = sorted_palettes[1] + cvd_l = measure_per_n(hexes_l) + cvd_r = measure_per_n(hexes_r) + norm_l = measure_per_n_normal(hexes_l) + norm_r = measure_per_n_normal(hexes_r) + scorecard = render_scorecard(name_l, cvd_l, norm_l, name_r, cvd_r, norm_r) + + # Charts row: 2 columns, light stacked above dark per column. + charts_row = render_charts_pair(name_l, hexes_l, name_r, hexes_r) + + return ( + f'
' + f"

{h(title)}

" + f'

{h(intro)}

' + f"{scorecard}" + f"{strips_row}" + f"{charts_row}" + "
" + ) + + +def render_hero(palettes: list[tuple[str, str, list[str], str]]) -> str: + # Column-major grid so head/intro/wheel/strip line up between palettes + # even if intros differ in line count. + heads = "".join(f'

{h(name)}

' for name, *_ in palettes) + intros = "".join( + f'

{h(blurb)}

' for _, blurb, _, _ in palettes + ) + # Per-palette C corridor — kept light-touch so the wheel ring shows where + # the algorithm's paper-ink band sat without dominating the disk. + corridors = {"vivid-8": (22.0, 36.0), "muted-8": (24.0, 32.0)} + wheels = "".join( + '
' + f'{v1.render_color_wheel(list(hexes), size_px=360, mode="large", chroma_corridor=corridors.get(name))}' + "
" + for name, _b, hexes, _a in palettes + ) + # Hero strips sorted by hue (rainbow) — easiest side-by-side comparison + # of which hue each palette places where. + strips = "".join( + f'
{render_palette_strip(sort_hue_order(list(hexes)))}
' + for _n, _b, hexes, _a in palettes + ) + return ( + '
' + "

palette variants v2 — head-to-head

" + '

vivid-8 (was D3) vs muted-8 (was D1-8) under 4 different slot orderings. ' + "colours are identical between rows; only the position changes. each section is one sorting; " + "compare per-n worst-pair ΔE under CVD against the live sample charts below it.

" + '
' + f"{heads}{intros}{wheels}{strips}" + "
" + "
" + ) + + +def render_toc(sortings: list[tuple[str, str, str, Callable]]) -> str: + items = "".join( + f'
  • {h(title)}
  • ' + for slug, title, _desc, _fn in sortings + ) + return ( + '" + ) + + +def render_page( + palettes: list[tuple[str, str, list[str], str]], + sortings: list[tuple[str, str, str, Callable[[list[str]], list[str]]]], +) -> str: + hero = render_hero(palettes) + toc = render_toc(sortings) + + sections: list[str] = [] + for slug, title, desc, fn in sortings: + sorted_pair: list[tuple[str, list[str], str]] = [] + for name, _blurb, hexes, _accent in palettes: + sorted_hexes = fn(list(hexes)) + short = f"{name} after {title}" + sorted_pair.append((name, sorted_hexes, short)) + sections.append(render_sorting_section(slug, title, desc, sorted_pair)) + + return ( + "" + '' + "palette variants v2 — vivid-8 vs muted-8" + '' + f"" + "" + '
    ' + f"{hero}{toc}{''.join(sections)}" + "
    " + f"" + "" + ) + + +# ----------------------------------------------------------------------------- +# CLI +# ----------------------------------------------------------------------------- + + +DEFAULT_OUT_DIR = REPO_ROOT / "docs" / "reference" / "palette-variants-v2" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate palette variants v2") + parser.add_argument( + "--out-dir", type=Path, default=DEFAULT_OUT_DIR, + help=f"Output directory (default: {DEFAULT_OUT_DIR})", + ) + parser.add_argument("--quiet", action="store_true") + args = parser.parse_args() + + logging.basicConfig( + level=logging.WARNING if args.quiet else logging.INFO, + format="%(message)s", + ) + log = logging.getLogger("palette-variants-v2") + + args.out_dir.mkdir(parents=True, exist_ok=True) + + for name, _blurb, hexes, _accent in PALETTES: + log.info("palette %s (%d hues)", name, len(hexes)) + for slug, title, _desc, fn in SORTINGS: + log.info("sorting %s → %s", slug, title) + for name, _blurb, hexes, _accent in PALETTES: + sorted_h = fn(list(hexes)) + per_n = measure_per_n(sorted_h) + log.info(" %s: %s per-n=%s", name, " ".join(sorted_h), per_n) + + html_out = render_page(PALETTES, SORTINGS) + out_path = args.out_dir / "index.html" + out_path.write_text(html_out, encoding="utf-8") + log.info("wrote %s (%.1f kB)", out_path, out_path.stat().st_size / 1024) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/palette-variants-v3.py b/scripts/palette-variants-v3.py new file mode 100644 index 0000000000..6a39373864 --- /dev/null +++ b/scripts/palette-variants-v3.py @@ -0,0 +1,1458 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "colorspacious>=1.1.2", +# "numpy>=2.0", +# "matplotlib>=3.10", +# "pillow>=11.0", +# ] +# /// +"""palette variants v3 — muted-8 finalist with hybrid sort. + +After 5 independent expert reviewers unanimously picked muted-8 over vivid-8 +(see ``../palette-variants-v2/expert-reviews.md``), only the slot order was +still open. The v2 head-to-head exposed a real trade-off: pure-CVD-greedy +maximised ΔE under CVD simulation but duplicated hue families in the first 4 +slots, while wheel-gap-first gave 4 visually distinct hues but put red and +green next to each other (worst-pair ΔE_CVD ≈ 10.7 already at n=3). + +v3 introduces a hybrid sort that fixes both: the first ``first_n`` slots are +constrained to come from distinct hue bins (45° wide by default) AND picked +by greedy max-min worst-CVD ΔE; the tail is unconstrained pure-CVD greedy. +Red is deliberately deferred to slot 4+; semantic-red is reached via the +named API (``palette.red``), not by position. + +Outputs: + docs/reference/palette-variants-v3/index.html - interactive page + docs/reference/palette-variants-v3/decision-rationale.md - written rationale + +Run:: + + uv run --script scripts/palette-variants-v3.py +""" + +from __future__ import annotations + +import argparse +import html +import importlib.util +import logging +import sys +from pathlib import Path +from typing import Callable, Sequence + +import numpy as np + + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + + +def _load(module_name: str, filename: str): + spec = importlib.util.spec_from_file_location( + module_name, REPO_ROOT / "scripts" / filename + ) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + sys.modules[module_name] = mod + spec.loader.exec_module(mod) + return mod + + +v1 = _load("palette_variants_v1", "palette-variants-v1.py") +v2 = _load("palette_variants_v2", "palette-variants-v2.py") + +from _palette_common import ( # noqa: E402 + DARK_THEME_FULL, + LIGHT_THEME_FULL, + NEUTRAL_DARK, + NEUTRAL_LIGHT, + PAGE_CSS, + PAGE_JS, + hex_to_rgb1, + pairwise_delta_e, + render_sample_bars, + render_sample_chart, + to_jab, + wcag_contrast, + worst_cvd_pairwise_delta_e, +) + + +# ----------------------------------------------------------------------------- +# Semantic anchors that live outside the categorical pool. +# These are NOT used by palette[:n] — only by the named API +# (palette.amber, palette.neutral, palette.muted) for warning / baseline / +# disabled-other use cases the 8 categorical hues don't cover. +# ----------------------------------------------------------------------------- + +AMBER = "#DDCC77" # Paul Tol "muted" yellow — see decision-rationale.md +# adaptive ink (full-contrast neutral): ink on light bg, ink on dark bg +INK_LIGHT = NEUTRAL_LIGHT # "#1A1A17" +INK_DARK = NEUTRAL_DARK # "#F0EFE8" +# adaptive ink-muted (soft-contrast neutral): for "other / rest / disabled" +INK_MUTED_LIGHT = LIGHT_THEME_FULL["ink_muted"] # "#6B6A63" +INK_MUTED_DARK = DARK_THEME_FULL["ink_muted"] # "#A8A79F" + + +# ----------------------------------------------------------------------------- +# Palette — muted-8 (the finalist; colour-identical to v1's D1-8) +# ----------------------------------------------------------------------------- + +MUTED_8: list[str] = [ + "#009E73", # brand green + "#AE3030", # matte red + "#C475FD", # purple / lavender + "#99B314", # lime + "#4467A3", # blue + "#2ABCCD", # cyan + "#954477", # matte rosé + "#BD8233", # ochre / tan +] + +# Short display labels used in the contrast audit dots. +MUTED_8_LABELS: dict[str, str] = { + "#009E73": "brand-G", + "#AE3030": "red", + "#C475FD": "lavender", + "#99B314": "lime", + "#4467A3": "blue", + "#2ABCCD": "cyan", + "#954477": "rose", + "#BD8233": "ochre", +} + + +# ----------------------------------------------------------------------------- +# Hybrid v3 sort +# ----------------------------------------------------------------------------- + + +def _hue(hex_str: str) -> float: + rgb = hex_to_rgb1(hex_str).reshape(1, 3) + jab = to_jab(rgb)[0] + _, _, H = v1.jab_to_lch(jab) + return H + + +# Perceptual "coarse family" partition by CAM02-UCS hue angle. +# 6 wedges chosen so the muted-8 hues partition like a viewer would group them: +# - brand-green (H≈166°) and lime (H≈115°) BOTH in "green" -> "2× green" complaint +# - lavender (H≈305°) and rosé (H≈345°) BOTH in "pink" -> "2× pink" complaint +# - ochre (H≈70°) in "orange", red (H≈25°) in "red", blue and cyan kept separate +# Boundaries are wider than v1's 14 fine hue-bands because the colloquial families +# people compare against ("red/orange/yellow/green/blue/purple/pink") are coarser. +COARSE_FAMILY_BANDS = [ + (30.0, "red"), # 0° – 30° true reds + (90.0, "yellow"), # 30° – 90° orange / amber / yellow (ochre lives here) + (180.0, "green"), # 90° – 180° lime / green / teal (brand green lives here) + (230.0, "cyan"), # 180°– 230° cyan / azure + (285.0, "blue"), # 230°– 285° blue / indigo + (345.0, "purple"), # 285°– 345° purple / magenta (lavender + rosé) + (360.0, "red"), # 345°– 360° pink / wrap-around back to red family +] + + +def _coarse_family(hex_str: str) -> str: + H = _hue(hex_str) + for boundary, name in COARSE_FAMILY_BANDS: + if H < boundary: + return name + return COARSE_FAMILY_BANDS[-1][1] + + +def sort_hybrid_v3( + hexes: list[str], + first_n: int = 4, + defer: tuple[str, ...] = ("#AE3030",), +) -> list[str]: + """Hue-family-diverse first ``first_n`` + CVD-greedy tail, with the + semantic red anchor deferred past first-N by default. + + - Slot 0 stays where it is (project-pinned anchor — brand green for muted-8). + - Slots 1..first_n-1: greedy max-min worst-CVD ΔE, **constrained** to + (a) a coarse hue family (see ``COARSE_FAMILY_BANDS``) not already used, + **and** (b) not a hex in ``defer``. The defer list reserves semantically + loaded hues (e.g. `#AE3030` matte red for loss/error/bad) so they're + always reached by name in the palette API rather than by position 1..N-1 + where they'd appear in every chart with ≥ N series. + - Slots first_n..N-1: pure greedy max-min worst-CVD ΔE on the remainder + (deferred hexes re-enter the pool here). + + If the family/defer constraints can't be satisfied (all remaining hexes + are excluded), they relax for that slot. For muted-8 at first_n=4 the + fallback never triggers — there are 6 distinct coarse families across the + 8 hexes and only 1 hex in the default defer list. + """ + n = len(hexes) + rgb = np.array([hex_to_rgb1(h) for h in hexes]) + M_worst, _ = worst_cvd_pairwise_delta_e(rgb) + families = [_coarse_family(h) for h in hexes] + deferred_idx = {i for i, hx in enumerate(hexes) if hx in defer} + + placed: list[int] = [0] + remaining = list(range(1, n)) + + while remaining: + if len(placed) < first_n: + used = {families[i] for i in placed} + candidates = [ + i for i in remaining + if families[i] not in used and i not in deferred_idx + ] + if not candidates: + candidates = remaining + else: + candidates = remaining + best = max(candidates, key=lambda i: float(M_worst[i, placed].min())) + placed.append(best) + remaining.remove(best) + + return [hexes[i] for i in placed] + + +# Full sorting set for the comparison table (the new hybrid + v2's 4). +ALL_SORTINGS: list[tuple[str, str, Callable[[list[str]], list[str]]]] = [ + ("hybrid-v3", "hybrid-v3 (family-diverse first 4, red deferred, CVD-greedy tail)", sort_hybrid_v3), + ("pure-cvd-greedy", "pure-CVD greedy max-min", v2.sort_pure_cvd_greedy), +] + + +# ----------------------------------------------------------------------------- +# Measurements +# ----------------------------------------------------------------------------- + + +def measure_per_n_cvd(hexes: list[str]) -> list[float]: + return v2.measure_per_n(hexes) + + +def measure_per_n_normal(hexes: list[str]) -> list[float]: + return v2.measure_per_n_normal(hexes) + + +def slot_annotations(hexes: list[str]) -> list[tuple[str, str, float]]: + """Per-slot (fine-name, coarse-family, hue-deg) for the rationale.""" + return [(v1.hue_to_name(h), _coarse_family(h), _hue(h)) for h in hexes] + + +# ----------------------------------------------------------------------------- +# HTML rendering +# ----------------------------------------------------------------------------- + + +V3_CSS = """ +.v3-hero { padding: 32px 28px 18px; } +.v3-hero h1 { margin: 0 0 4px; font-size: 26px; } +.v3-hero p.subtitle { margin: 0 0 8px; color: var(--ink-muted); font-size: 14px; max-width: 84ch; line-height: 1.55; } +/* Hero is stacked, not 2-col: the wheel SVG has overflow="visible" so its + cardinal tick labels extend ≈32px beyond each side, which makes any 2-col + layout fragile. Stacking removes the possibility of overlap entirely. + Note: classes are v3-prefixed to avoid clashing with the global .hero-meta + rule in _palette_common.py (which forces position:absolute on that class). */ +.v3-hero .v3-stack { display: flex; flex-direction: column; gap: 20px; align-items: stretch; margin-top: 18px; } +.v3-hero .v3-wheel { display: flex; justify-content: center; padding: 16px 0; } +.v3-hero .v3-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 28px; align-items: start; } +.v3-hero .v3-meta p { margin: 0; font-size: 13px; color: var(--ink-muted); line-height: 1.55; max-width: 60ch; } +.v3-hero .v3-meta dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 10px; font-family: var(--mono); font-size: 11px; margin: 0; } +.v3-hero .v3-meta dt { color: var(--ink-muted); } +.v3-hero .v3-meta dd { margin: 0; } + +@media (max-width: 720px) { + .v3-hero .v3-meta { grid-template-columns: 1fr; } +} + +.v3-section { padding: 28px; border-top: 1px solid var(--rule); } +.v3-section > h2 { margin: 0 0 4px; font-size: 22px; } +.v3-section > p.intro { margin: 0 0 14px; color: var(--ink-muted); font-size: 14px; max-width: 84ch; line-height: 1.55; } + +/* Contrast audit (graphical-objects WCAG 3:1 against bg_page per theme). + Two "stages" — one light, one dark — each shows every categorical hue + + amber as a 48px dot on its theme's bg, with hex/label/ratio under it. + For sub-3:1 members, a second "outlined" variant is shown alongside, + demonstrating that a 1.5px ink-color stroke rescues the contrast. */ +.v3-contrast-stage { border-radius: 10px; padding: 18px 16px 16px; margin-top: 10px; box-shadow: 0 0 0 1px var(--rule); } +.v3-contrast-stage h3 { margin: 0 0 4px; font-size: 14px; font-family: var(--mono); letter-spacing: 0.04em; } +.v3-contrast-stage p.note { margin: 0 0 12px; font-size: 12px; line-height: 1.5; max-width: 60ch; } +.v3-contrast-stage.stage-light { background: #F5F3EC; color: #1A1A17; } +.v3-contrast-stage.stage-light p.note { color: #4A4A44; } +.v3-contrast-stage.stage-dark { background: #121210; color: #F0EFE8; } +.v3-contrast-stage.stage-dark p.note { color: #B8B7B0; } + +.v3-contrast-row { display: grid; grid-template-columns: repeat(9, 1fr); gap: 8px; margin-top: 6px; } +.v3-contrast-row .cell { display: flex; flex-direction: column; align-items: center; gap: 4px; } +.v3-contrast-row .dot { width: 48px; height: 48px; border-radius: 50%; box-shadow: none; } +.v3-contrast-row .label { font-family: var(--mono); font-size: 9px; letter-spacing: 0.02em; opacity: 0.85; } +.v3-contrast-row .hex { font-family: var(--mono); font-size: 9px; opacity: 0.7; } +.v3-contrast-row .ratio { font-family: var(--mono); font-size: 10px; font-weight: 600; } +.v3-contrast-row .ratio.pass { color: #2a8d52; } +.v3-contrast-row .ratio.fail { color: #b62d2d; } +.v3-contrast-stage.stage-dark .ratio.pass { color: #6fcf97; } +.v3-contrast-stage.stage-dark .ratio.fail { color: #ff8585; } + +.v3-contrast-fix { display: grid; grid-template-columns: repeat(auto-fit, minmax(72px, max-content)); gap: 8px 14px; margin-top: 14px; padding-top: 12px; border-top: 1px dashed currentColor; } +.v3-contrast-fix .cell { display: flex; flex-direction: column; align-items: center; gap: 4px; opacity: 0.95; } +.v3-contrast-fix .dot { width: 48px; height: 48px; border-radius: 50%; } +.v3-contrast-fix .pair { display: flex; gap: 6px; align-items: center; } +.v3-contrast-fix h4 { margin: 0 0 6px; font-size: 12px; font-family: var(--mono); letter-spacing: 0.03em; font-weight: 600; opacity: 0.95; } +@media (max-width: 720px) { + .v3-contrast-row { grid-template-columns: repeat(3, 1fr); } +} + +/* Semantic anchors row (amber + 2 adaptive neutrals). Each anchor is a card + showing the hex(es), a role label, and a one-line use-case hint. The + adaptive neutrals show a split swatch — left half = light-theme value, + right half = dark-theme value — so the theme-flip is visible at a glance. */ +.v3-anchors { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 12px; } +.v3-anchors .anchor { border: 1px solid var(--rule); border-radius: 8px; padding: 0; overflow: hidden; background: var(--bg-surface); } +.v3-anchors .sw-full { height: 64px; } +.v3-anchors .sw-split { height: 64px; display: grid; grid-template-columns: 1fr 1fr; } +.v3-anchors .sw-split > span { display: flex; align-items: flex-end; justify-content: center; font-family: var(--mono); font-size: 10px; padding-bottom: 4px; } +.v3-anchors .sw-split > span.lit { color: #1A1A17; } +.v3-anchors .sw-split > span.dim { color: #F0EFE8; } +.v3-anchors .body { padding: 10px 12px 12px; } +.v3-anchors .role { font-family: var(--mono); font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink); margin: 0 0 4px; } +.v3-anchors .api { font-family: var(--mono); font-size: 11px; color: var(--ink-muted); margin: 0 0 6px; } +.v3-anchors .hint { font-size: 12px; color: var(--ink-muted); line-height: 1.45; margin: 0; } +.v3-anchors .hex { font-family: var(--mono); font-size: 10px; color: var(--ink-muted); } +@media (max-width: 720px) { + .v3-anchors { grid-template-columns: 1fr; } +} + +.v3-strip { display: flex; height: 80px; border-radius: 6px; overflow: hidden; box-shadow: 0 0 0 1px var(--rule); margin-bottom: 6px; } +.v3-strip .sw { flex: 1; display: flex; align-items: center; justify-content: center; font-family: var(--mono); font-size: 10px; } +.v3-anno { display: grid; grid-template-columns: repeat(8, 1fr); gap: 6px; font-family: var(--mono); font-size: 10px; color: var(--ink-muted); margin-bottom: 14px; } +.v3-anno .col { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 4px 2px; text-align: center; border: 1px solid var(--rule); border-radius: 4px; } +.v3-anno .col .slot { font-weight: 600; color: var(--ink); letter-spacing: 0.06em; } +.v3-anno .col .family { color: var(--ink); } +.v3-anno .col .meta { color: var(--ink-muted); } + +.v3-pertable { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 11px; margin-top: 8px; } +.v3-pertable th, .v3-pertable td { padding: 4px 6px; text-align: right; border-bottom: 1px solid var(--rule); } +.v3-pertable th:first-child, .v3-pertable td:first-child { text-align: left; color: var(--ink-muted); } +.v3-pertable td.good { color: #2a8d52; font-weight: 600; } +.v3-pertable td.warn { color: #b56b18; } +.v3-pertable td.bad { color: #b62d2d; font-weight: 600; } +.v3-pertable td.best { box-shadow: inset 0 -2px 0 0 currentColor; } + +.v3-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-top: 16px; } +.v3-charts .col { display: grid; gap: 10px; } +.v3-charts .sample-chart { width: 100%; height: auto; } +.v3-charts h3 { margin: 6px 0 2px; font-size: 12px; color: var(--ink-muted); letter-spacing: 0.04em; text-transform: uppercase; font-weight: 500; } +.v3-charts h4.theme-divider { + margin: 8px 0 4px; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink); + font-weight: 600; +} + +.v3-compare-row { display: grid; gap: 10px; margin: 8px 0 18px; } +.v3-compare-row .crow { display: grid; grid-template-columns: 220px 1fr; gap: 12px; align-items: center; } +.v3-compare-row .clabel { font-family: var(--mono); font-size: 11px; } +.v3-compare-row .clabel .crow-title { font-weight: 600; } +.v3-compare-row .clabel .crow-sub { color: var(--ink-muted); font-size: 10px; } +.v3-compare-row .cstrip { display: flex; height: 36px; border-radius: 4px; overflow: hidden; box-shadow: 0 0 0 1px var(--rule); } +.v3-compare-row .cstrip .sw { flex: 1; } + +.v3-footer { padding: 24px 28px 40px; color: var(--ink-muted); font-size: 13px; line-height: 1.55; border-top: 1px solid var(--rule); } +.v3-footer a { color: var(--ink); } + +@media (max-width: 900px) { + .v3-charts { grid-template-columns: 1fr; } + .v3-anno { grid-template-columns: repeat(4, 1fr); } + .v3-compare-row .crow { grid-template-columns: 1fr; } +} +""" + + +def h(s: str) -> str: + return html.escape(str(s), quote=True) + + +def _swatch_text_color(hx: str) -> str: + r, g, b = hex_to_rgb1(hx) + luma = 0.299 * r + 0.587 * g + 0.114 * b + return "#111" if luma > 0.55 else "#FAF8F1" + + +def render_strip(hexes: list[str], cell_class: str = "v3-strip") -> str: + cells = "".join( + f'
    {h(hx)}
    ' + for hx in hexes + ) + return f'
    {cells}
    ' + + +def render_annotation_row(hexes: list[str]) -> str: + """Per-slot family/hue annotation under the main hex strip.""" + cells = [] + for i, (fine, coarse, hue) in enumerate(slot_annotations(hexes)): + cells.append( + f'
    ' + f'slot {i}' + f'{h(fine)}' + f'{h(coarse)} · H={hue:.0f}°' + f"
    " + ) + return f'
    {"".join(cells)}
    ' + + +def _classify(v: float) -> str: + if v >= 15.0: + return "good" + if v >= 10.0: + return "warn" + return "bad" + + +def render_per_n_table(values_cvd: list[float], values_norm: list[float]) -> str: + headers = "".join(f"n={i + 2}" for i in range(len(values_cvd))) + cvd_cells = "".join( + f'{v:.2f}' for v in values_cvd + ) + norm_cells = "".join( + f'{v:.2f}' for v in values_norm + ) + return ( + '' + f"{headers}" + "" + f"{norm_cells}" + f"{cvd_cells}" + "" + "
    worst-pair ΔE
    normal vision
    under CVD (min)
    " + ) + + +def render_chart_stack(label: str, hexes: list[str]) -> str: + first_4 = hexes[:4] + + def block(theme: dict[str, str], theme_label: str) -> str: + return ( + f'

    {h(theme_label)}

    ' + "

    lines

    " + f"{render_sample_chart(theme, label, hexes)}" + "

    bars (all 8)

    " + f"{render_sample_bars(theme, label, hexes)}" + "

    pie (first 4)

    " + f"{v2.render_sample_pie(theme, label, first_4)}" + "

    stocks (first 4)

    " + f"{v2.render_sample_stocks(theme, label, first_4)}" + "

    scatter (edge clusters, centre overlap)

    " + f"{v2.render_overlap_scatter(theme, label, hexes)}" + ) + + return ( + '
    ' + f'
    {block(LIGHT_THEME_FULL, "light theme")}
    ' + f'
    {block(DARK_THEME_FULL, "dark theme")}
    ' + "
    " + ) + + +def render_compare_table( + sortings: list[tuple[str, str, list[str], list[float], list[float]]], +) -> str: + """4-column comparison table — one row per sorting, columns n=2..8 of + worst-CVD ΔE. The 'best' value per column gets a `.best` class.""" + n_count = len(sortings[0][3]) + # Determine the best (max) cvd value per n column + best_idx_per_n = [] + for k in range(n_count): + col_vals = [s[3][k] for s in sortings] + best_idx_per_n.append(int(np.argmax(col_vals))) + + headers = "".join(f"n={i + 2}" for i in range(n_count)) + rows: list[str] = [] + for r, (slug, title, _hexes, cvd, _norm) in enumerate(sortings): + cells: list[str] = [] + for k, v in enumerate(cvd): + cls = _classify(v) + if best_idx_per_n[k] == r: + cls += " best" + cells.append(f'{v:.2f}') + marker = " ★" if slug == "hybrid-v3" else "" + rows.append( + f'{h(title)}{marker}{"".join(cells)}' + ) + return ( + '' + f"{headers}" + f'{"".join(rows)}' + "
    sort · worst-CVD ΔE per n
    " + ) + + +def render_compare_strips( + sortings: list[tuple[str, str, list[str], list[float], list[float]]], +) -> str: + """Stacked palette strips — one row per sorting.""" + rows: list[str] = [] + for slug, title, hexes, cvd, _norm in sortings: + worst = min(cvd) if cvd else 0.0 + marker = " ★" if slug == "hybrid-v3" else "" + rows.append( + '
    ' + f'
    ' + f'
    {h(title)}{marker}
    ' + f'
    min ΔE_CVD over n=2..8 = {worst:.2f}
    ' + "
    " + f'{render_strip(hexes, cell_class="cstrip")}' + "
    " + ) + return f'
    {"".join(rows)}
    ' + + +def render_hero(hybrid_hexes: list[str]) -> str: + wheel = v1.render_color_wheel( + list(hybrid_hexes), size_px=360, mode="large", + chroma_corridor=(24.0, 32.0), + ) + annotations = "".join( + f"
    slot {i}
    {h(hx)} — {h(_coarse_family(hx))}
    " + for i, hx in enumerate(hybrid_hexes) + ) + return ( + '
    ' + "

    palette variants v3 — muted-8 finalist

    " + '

    ' + "muted-8 was unanimously picked by 5 independent expert reviewers in v2. " + "v3 settles the last open question — slot ordering — by introducing a " + "hybrid sort that combines hue-family diversity in the " + "first 4 slots with greedy max-min CVD distance for the tail. semantic " + "red #AE3030 is deferred past slot 3 so it's reached via " + "the named API (palette.red) for loss / error / bad, not by " + "position 1..3 where it would appear in every chart with ≥ 3 series." + "

    " + '
    ' + f'
    {wheel}
    ' + '
    ' + "

    chroma corridor C ∈ [24, 32]. brand green pinned at slot 0. " + "remaining slots picked by the hybrid algorithm — slots 1..3 from " + "distinct coarse hue families (red / yellow / green / cyan / blue / " + "purple), with #AE3030 matte red deferred past first-4, " + "then greedy max-min worst-CVD ΔE for the tail.

    " + f"
    {annotations}
    " + "
    " + "
    " + ) + + +def _contrast_dot( + hx: str, bg_hex: str, label: str, *, outlined_stroke: str | None = None +) -> str: + """Render one dot cell: 48px filled circle, label, hex, ratio. + + If outlined_stroke is given, draws a 1.5px ring of that color around the + fill. The ratio shown is then the stroke-vs-bg ratio (which always passes + since outline is full-ink). + """ + ratio = wcag_contrast(hex_to_rgb1(hx), hex_to_rgb1(bg_hex)) + if outlined_stroke is not None: + stroke_ratio = wcag_contrast( + hex_to_rgb1(outlined_stroke), hex_to_rgb1(bg_hex) + ) + # The visible boundary in the outlined variant is the stroke against + # bg, so report THAT ratio (the actually-effective contrast). + ratio_str = f"{stroke_ratio:.2f}:1" + ratio_class = "pass" if stroke_ratio >= 3.0 else "fail" + ring = f"box-shadow: inset 0 0 0 1px {outlined_stroke};" + else: + ratio_str = f"{ratio:.2f}:1" + ratio_class = "pass" if ratio >= 3.0 else "fail" + ring = "" + return ( + f'
    ' + f'
    ' + f'
    {html.escape(label)}
    ' + f'
    {hx}
    ' + f'
    {ratio_str}
    ' + f"
    " + ) + + +def render_contrast_section() -> str: + """WCAG-3:1 contrast audit per theme + outline-fix demo for sub-3:1 hexes.""" + palette = [*MUTED_8, AMBER] + labels = {**MUTED_8_LABELS, AMBER: "amber"} + bg_light = LIGHT_THEME_FULL["bg_page"] + bg_dark = DARK_THEME_FULL["bg_page"] + ink_light = NEUTRAL_LIGHT # ink for light bg = dark + ink_dark = NEUTRAL_DARK # ink for dark bg = light + + def ratio(fg: str, bg: str) -> float: + return wcag_contrast(hex_to_rgb1(fg), hex_to_rgb1(bg)) + + weak_light = [hx for hx in palette if ratio(hx, bg_light) < 3.0] + weak_dark = [hx for hx in palette if ratio(hx, bg_dark) < 3.0] + + light_dots = "".join( + _contrast_dot(hx, bg_light, labels[hx]) for hx in palette + ) + dark_dots = "".join( + _contrast_dot(hx, bg_dark, labels[hx]) for hx in palette + ) + + if weak_light: + light_fix_dots = "".join( + _contrast_dot(hx, bg_light, labels[hx], outlined_stroke=ink_light) + for hx in weak_light + ) + light_fix = ( + f'
    ' + f'

    ↳ same {len(weak_light)} sub-3:1 hexes with a 1px ' + f'{ink_light} ink ring

    ' + f'
    {light_fix_dots}
    ' + f"
    " + ) + else: + light_fix = "" + + if weak_dark: + dark_fix_dots = "".join( + _contrast_dot(hx, bg_dark, labels[hx], outlined_stroke=ink_dark) + for hx in weak_dark + ) + dark_fix = ( + f'
    ' + f'

    ↳ same {len(weak_dark)} sub-3:1 hex with a 1px ' + f'{ink_dark} ink ring

    ' + f'
    {dark_fix_dots}
    ' + f"
    " + ) + else: + dark_fix = "" + + return ( + '
    ' + "

    contrast on both themes — and the outline pattern

    " + '

    ' + "muted palettes share a known limitation: the lighter members carry " + "their distinguishability through chroma, not L-spread, so on a light " + "background (cream #F5F3EC) they fall under WCAG 2.1 " + "SC 1.4.11's 3:1 minimum for graphical objects. Okabe-Ito, Paul " + "Tol “muted”, and ColorBrewer Set2 all have the same " + "issue. The industry-standard fix is a thin ink-color outline on the " + "affected series. Below: every categorical hue + amber on both themes, " + "with the sub-3:1 ones shown both as-is and with a 1px ring." + "

    " + '
    ' + '

    on cream bg #F5F3EC (light theme)

    ' + f'
    {light_dots}
    ' + f"{light_fix}" + "
    " + '
    ' + '

    on warm near-black bg #121210 (dark theme)

    ' + f'
    {dark_dots}
    ' + f"{dark_fix}" + "
    " + '

    ' + "Guidance. on the light theme, render the affected " + f"series ({', '.join(labels[h] for h in weak_light)}) with a thin " + "outline in the ink color " + f"({ink_light}): 1px stroke on line/scatter, 1–1.5px on " + "bar/pie fills. amber is a semantic anchor outside the categorical " + "pool — its low light-bg contrast is acceptable because it's " + "reached intentionally (palette.amber) for caution/" + "warning, and the same outline rule applies. on the dark theme, only " + f"#AE3030 red sits marginally below 3:1 " + f"({ratio('#AE3030', bg_dark):.2f}:1) — same outline fix recommended " + "for high-stakes layouts." + "

    " + "
    " + ) + + +def render_semantic_anchors_section() -> str: + """Render the 3 semantic anchors that live OUTSIDE the categorical pool. + + These never enter the palette[:n] slot order — they're only reachable via + the named API. Two of them (neutral, muted) are theme-adaptive: their hex + flips between the light and dark theme. + """ + # Pre-compute the worst-pair CVD ΔE from amber to every muted-8 member so we + # can quote the min in the page (justifies the amber pick). + rgbs = np.stack([hex_to_rgb1(c) for c in [AMBER, *MUTED_8]]) + cvd_min = float("inf") + for cvd in ("deuter", "protan", "tritan"): + m = pairwise_delta_e(rgbs, cvd) + cvd_min = min(cvd_min, float(min(m[0, 1:]))) + return ( + '
    ' + "

    semantic anchors — outside the categorical pool

    " + '

    ' + "the 8 categorical hues above cover the hue-diverse roles " + "(palette.green, palette.red, …). three " + "additional anchors live outside the slot pool — they " + "are never returned by palette[:n], only by the named API. " + "two are theme-adaptive: their hex flips between the light and " + "dark theme so the role stays semantically consistent across both modes " + "(same pattern as Apple HIG / Material Design / GitHub Primer)." + "

    " + '
    ' + # 1. amber — warning / caution + f'
    ' + f'
    ' + f'
    ' + f'

    warning / caution

    ' + f'

    palette.amber — {AMBER}

    ' + f"

    Paul Tol “muted” yellow. min ΔE under " + f"CVD to the 8 categorical hexes = {cvd_min:.2f} — confidently distinct " + f"from every member, including #99B314 lime (the two more " + f"saturated amber options, #D4A017 and #D4AF37 " + f"goldenrod, both collapse to ΔE_CVD ≈ 2.3 against lime under " + f"deuteranopia)." + f"

    " + f"
    " + f"
    " + # 2. neutral — full-contrast ink (theme-adaptive) + f'
    ' + f'
    ' + f'{INK_LIGHT}' + f'{INK_DARK}' + f"
    " + f'
    ' + f'

    totals / baseline / outline

    ' + f'

    palette.neutral — adaptive

    ' + f'

    full-contrast ink, theme-adaptive. on cream bg → ' + f'{INK_LIGHT}; on dark bg → {INK_DARK}. same ' + f"value as text and gridlines, so “total” / " + f"“baseline” / “reference outline” series " + f"automatically read as part of the chart's structural layer rather " + f"than as “just another category”." + f"

    " + f"
    " + f"
    " + # 3. muted — soft-contrast ink (theme-adaptive) + f'
    ' + f'
    ' + f'{INK_MUTED_LIGHT}' + f'{INK_MUTED_DARK}' + f"
    " + f'
    ' + f'

    other / rest / disabled

    ' + f'

    palette.muted — adaptive

    ' + f'

    soft-contrast ink, theme-adaptive. on cream bg → ' + f'{INK_MUTED_LIGHT}; on dark bg → {INK_MUTED_DARK}. ' + f"meant for “other” / “rest” slices in stacked " + f"charts, disabled / inactive series, confidence bands, and " + f"any annotation that should sit behind the data without competing for " + f"attention." + f"

    " + f"
    " + f"
    " + "
    " + "
    " + ) + + +def render_finalist_section(hybrid_hexes: list[str]) -> str: + cvd = measure_per_n_cvd(hybrid_hexes) + norm = measure_per_n_normal(hybrid_hexes) + return ( + '
    ' + "

    finalist — muted-8 with hybrid-v3 sort

    " + '

    ' + "first 4 slots come from 4 different coarse hue families " + "(red / yellow / green / cyan / blue / purple — see slot annotations " + "below), with #AE3030 matte red explicitly deferred past " + "first-4 so it remains a free semantic anchor reachable via the named " + "API. slots 4..7 are pure-CVD greedy on the remaining hexes (deferred " + "red re-enters here). this preserves visual hue diversity in dense " + "small-n usage (no “two greens, two purples” problem) and " + "still maximises CVD-distance for n=5..8." + "

    " + f"{render_strip(hybrid_hexes)}" + f"{render_annotation_row(hybrid_hexes)}" + f"{render_per_n_table(cvd, norm)}" + f"{render_chart_stack('muted-8 hybrid-v3', hybrid_hexes)}" + "
    " + ) + + +def render_compare_section( + sortings: list[tuple[str, str, list[str], list[float], list[float]]], +) -> str: + return ( + '
    ' + "

    how this compares to the v2 alternatives

    " + '

    ' + "all five sortings use the identical 8 hexes. only the slot order " + "differs. ★ marks the recommended sort. the per-n table shows the " + "weakest pair's ΔE under the min of the 3 CVD simulations " + "(deuteranopia / protanopia / tritanopia at 100% severity). bold " + "underline marks the column-best." + "

    " + f"{render_compare_table(sortings)}" + '

    slot-order strips

    ' + f"{render_compare_strips(sortings)}" + "
    " + ) + + +def render_footer() -> str: + return ( + '" + ) + + +def render_page(hybrid_hexes: list[str]) -> str: + # Precompute per-sort metrics for the comparison. + sort_rows: list[tuple[str, str, list[str], list[float], list[float]]] = [] + for slug, title, fn in ALL_SORTINGS: + sorted_hexes = fn(list(MUTED_8)) + cvd = measure_per_n_cvd(sorted_hexes) + norm = measure_per_n_normal(sorted_hexes) + sort_rows.append((slug, title, sorted_hexes, cvd, norm)) + + return ( + "" + '' + "palette variants v3 — muted-8 finalist" + '' + f"" + "" + '
    ' + f"{render_hero(hybrid_hexes)}" + f"{render_semantic_anchors_section()}" + f"{render_finalist_section(hybrid_hexes)}" + f"{render_contrast_section()}" + f"{render_compare_section(sort_rows)}" + f"{render_footer()}" + "
    " + f"" + "" + ) + + +# ----------------------------------------------------------------------------- +# Markdown rationale +# ----------------------------------------------------------------------------- + + +def _per_n_md_row(label: str, values: list[float]) -> str: + cells = " | ".join(f"{v:.2f}" for v in values) + return f"| {label} | {cells} |" + + +def render_rationale_md( + hybrid_hexes: list[str], + sort_rows: list[tuple[str, str, list[str], list[float], list[float]]], +) -> str: + n_count = len(sort_rows[0][3]) + n_header = " | ".join(f"n={i + 2}" for i in range(n_count)) + n_sep = " | ".join(["---"] * (n_count + 1)) + + hybrid_cvd = next( + cvd for slug, _t, _h, cvd, _n in sort_rows if slug == "hybrid-v3" + ) + hybrid_norm = next( + norm for slug, _t, _h, _c, norm in sort_rows if slug == "hybrid-v3" + ) + + # Compact per-sort table + compare_rows = "\n".join( + _per_n_md_row(title + (" ★" if slug == "hybrid-v3" else ""), cvd) + for slug, title, _h, cvd, _n in sort_rows + ) + + # Slot annotation table for the finalist + annos = slot_annotations(hybrid_hexes) + slot_rows = "\n".join( + f"| {i} | `{hybrid_hexes[i]}` | {fine} | {coarse} | {hue:.1f}° |" + for i, (fine, coarse, hue) in enumerate(annos) + ) + + # Amber → muted-8 worst-CVD min distance (used in the "semantic anchors" + # section to back the amber pick). Worst of deuter/protan/tritan, smallest + # ΔE to any of the 8 categorical hexes. + amber_rgbs = np.stack([hex_to_rgb1(c) for c in [AMBER, *MUTED_8]]) + amber_cvd_min = float("inf") + for cvd_name in ("deuter", "protan", "tritan"): + m = pairwise_delta_e(amber_rgbs, cvd_name) + amber_cvd_min = min(amber_cvd_min, float(min(m[0, 1:]))) + + # Theme-adaptive neutral hexes (for the "Semantic anchors" section). + ink_light = INK_LIGHT + ink_dark = INK_DARK + ink_muted_light = INK_MUTED_LIGHT + ink_muted_dark = INK_MUTED_DARK + + return f"""# palette-v3 — muted-8 finalist (decision rationale) + +> Generated by [`scripts/palette-variants-v3.py`](../../../scripts/palette-variants-v3.py). +> Companion to [`index.html`](./index.html) (live charts) and the v2 review at +> [`../palette-variants-v2/expert-reviews.md`](../palette-variants-v2/expert-reviews.md). + +## TL;DR + +After 5 independent expert reviewers unanimously picked **muted-8** over vivid-8, +only the slot ordering was still open. v3 introduces a **hybrid sort** that: + +1. pins brand green `#009E73` at slot 0, +2. fills slots 1..3 by greedy max-min worst-CVD ΔE **constrained to distinct + perceptual hue families** (red / yellow / green / cyan / blue / purple), +3. **defers semantic red `#AE3030` past the first-4 pool** so it's reached via + the named API rather than by position 1..3, and +4. fills slots 4..7 by pure-CVD greedy (no constraint) on the remainder + (deferred red re-enters here). + +The result: visually hue-diverse first 4 slots (no "two greens, two purples" +artefact from pure-CVD-greedy), red kept as a free semantic anchor, and +ΔE_CVD ≥ {min(hybrid_cvd[:3]):.1f} through n=4. + +Semantic red `#AE3030` sits at slot {hybrid_hexes.index('#AE3030')}, reachable via the +named API (`palette.red`) for loss / error / bad without polluting the +positional default for every chart with ≥ 3 series. + +## CVD prevalence — who are we actually designing for? + +The default CVD simulation (Coblis, color-blindness.com, this repo's +`worst_cvd_pairwise_delta_e`) shows **100% dichromacy**. That covers only the +fully-dichromatic 1–2% of the population, not the much larger anomalous-trichromacy +group. + +| Form | ♂ rate (NW Europe) | ♀ rate | Description | +|---|---|---|---| +| Deuteranomaly (M-cone shifted) | ~5.0% | ~0.35% | Reduced red/green discrimination, residual colour | +| Protanomaly (L-cone shifted) | ~1.0% | ~0.03% | Same, plus red appears darker | +| **Deuteranopia** (M-cone absent) | **~1.0%** | **~0.01%** | Red/green near-indistinguishable | +| **Protanopia** (L-cone absent) | **~1.0%** | **~0.02%** | Same, red looks near-black | +| Tritanopia / Tritanomaly | ~0.01% | ~0.01% | Blue/yellow confusion; usually acquired | +| **Σ all forms** | **~8%** | **~0.5%** | | + +Sources: Sharpe et al. 1999; Birch 2012 *Ophthalmic Physiol Opt* 32(5); Hood et al. 2024. + +**Key consequence:** the ~5% anomalous-trichromacy group has a *partial* cone shift, +not full loss. Population-mean severity for deuteranomaly is ~0.6 (Simunovic 2010, +*Eye* 24, 747–755). At severity 0.6, worst-pair ΔE_CVD is roughly **30-40% higher** +than at 1.0 simulation. + +A palette with worst-pair ΔE_CVD ≈ 10 at full-100% simulation translates to roughly +**14-16 ΔE** for the actual deuteranomaly population — clearly above the discrimination +floor. The 1-2% with full deuteranopia *will* still need a redundant encoding (line +style / marker / label) at n ≥ 6, regardless of which 8-hex palette ships. + +This is why every published categorical palette caps at "min ΔE_CVD ≈ 11" at n=8 +(Okabe-Ito, Paul Tol "muted", Petroff 2021's n=8 reach ~18) rather than chasing +the unreachable ΔE ≥ 15 ceiling. + +## Why n=8? + +The current live `ANYPLOT_PALETTE` in `core/images.py` ships **7 categorical +hues** plus 1 adaptive neutral — the classic Okabe-Ito layout. muted-8 adds +one categorical slot. Four reasons that's the right pool size: + +**1. The 7-hue palette has a documented hue-coverage gap.** With 7 slots you +must pick *either* lime or cyan, *either* lavender or rosé. anyplot live +chose lime + pink and gave up cyan + lavender — both of which are needed for +the named-API roles (`palette.cyan` → info / tech-cool, `palette.lavender` → +creative / brand-secondary). With 8 slots all six primary hue families +(red / yellow / green / cyan / blue / purple) plus two tertiary tones fit. + +**2. n=8 is the CVD-discrimination sweet spot.** The min ΔE_CVD floor falls +roughly geometrically with n in any well-spaced palette: + +| n | muted-8 hybrid-v3 min ΔE_CVD | discrimination floor | +|---|---|---| +| 2..4 | 14–36 | well above | +| 5..6 | 13.70 | safely above | +| 7 | 10.70 | borderline | +| 8 | 8.81 | marginal but usable | +| 9+ | extrapolated < 7 | unusable for CVD | + +Tableau-10 and D3 schemeCategory10 push past this boundary and are known to +fail CVD users (Petroff 2021 §3 measures this). 8 is the largest pool size +where every published categorical palette still clears the practical floor. + +**3. Lineage consistency.** Most academic-publishing categorical palettes sit +at n=7..9: + +| Palette | Slots | Year | +|---|---|---| +| Okabe-Ito (Wong 2011, *Nature Methods*) | 7 cat. + 1 neutral = 8 | 2011 | +| ColorBrewer Set2 | 8 (incl. grey) | 2003 | +| Paul Tol "muted" | 9 (incl. grey) | 2018 | +| Petroff 2021 (*arXiv*) | 8 | 2021 | +| **muted-8** (anyplot v3) | **8 + 3 anchors** | **2026** | + +**4. 360° / 45° = 8 — clean hue-wheel coverage.** With 8 slots every +categorical hex gets its own 45°-bin on the perceptual hue wheel. With 7 you +have one 51°+ gap (a family "missing"); with 9 you have to double up. muted-8 +distributes evenly: + +``` +hue: 25° 70° 115° 166° 209° 254° 305° 345° + red ochre lime green cyan blue lavnd rose +``` + +**What n=8 does NOT mean.** It does not mean "render 8 series in a single +chart." The n > 4 redundant-encoding guidance (linestyle / marker / small +multiples) still applies — the 8 are a *semantic pool*, not a stack-lines +ceiling. The slot-pool size lets the named API cover the standard semantic +roles without sacrificing the neutral; it doesn't encourage cramming. + +## Hybrid-v3 vs pure-CVD-greedy + +Both sortings use the **identical** 8 hexes of muted-8: + +``` +#009E73 #AE3030 #C475FD #99B314 #4467A3 #2ABCCD #954477 #BD8233 +brand-G matte-R lavender lime blue cyan rosé ochre +``` + +Only the **slot order** changes. Worst-pair ΔE under the min of the 3 CVD +simulations (deuteranopia / protanopia / tritanopia at 100% severity): + +| sort | {n_header} | +| {n_sep} | +{compare_rows} + +★ = recommended. + +**pure-cvd-greedy** maximises ΔE per n by maximising min-CVD-distance to the +already-placed set at each step. Result for muted-8: it picks both `#C475FD` +lavender and `#954477` rosé (both "purple" family — read as pinkish-purple in +practice) in the first 4, and `#99B314` lime alongside `#009E73` brand green +(both "green" family). Visually weird despite the great ΔE numbers — the +"2× green, 2× purple" artefact flagged in the v2 review. + +**hybrid-v3** trades a small per-n ΔE for two structural improvements: (a) the +first 4 slots span 4 distinct perceptual hue families (no "2× green / 2× +pink"), and (b) the semantic red anchor is deferred so it stays available via +the named API rather than being burned on slot 2 of every chart. The CVD floor +at n=8 is identical (8.81) — both sortings ship the same 8 hexes, so the +worst-case is fixed by the palette, not the order. + +(The v2 head-to-head also compared `wheel-gap-first`, `hue-order`, and +`every-other-hue`, all of which had ΔE_CVD ≈ 10–14 from n=2 onward — see +[../palette-variants-v2/](../palette-variants-v2/) for that history.) + +## The hybrid v3 algorithm + +```python +def sort_hybrid_v3(hexes, first_n=4, defer=("#AE3030",)): + # Slot 0 fixed (project anchor: brand green). + # Slots 1..first_n-1: greedy max-min worst-CVD ΔE, constrained to + # (a) a coarse hue family not already used AND + # (b) not in `defer` (semantic red kept for named API). + # Slots first_n..N-1: pure greedy max-min worst-CVD ΔE on the remainder + # (deferred hexes re-enter the pool here). +``` + +**Why a custom coarse-family partition?** A naive 45°-bin partition splits +muted-8's `lavender` (≈ 305°) and `rosé` (≈ 345°) into *different* bins, yet +both read as "pinkish-purple" to the eye. Same story for `brand-green` +(≈ 166°) and `lime` (≈ 115°): different bins, but visually grouped as "two +greens". Inheriting v1's 14 fine hue-bands directly doesn't fix it either — +because `brand-green` at H ≈ 166° lands one degree *over* v1's `teal/cyan` +boundary (165°) and is classified as `cyan` rather than `teal`/`green`. + +So we use a custom 6-band partition with wider, perception-shaped boundaries. +Names are picked to fit the *dominant* hue in each band (so 30°–90° is +"yellow" not "orange" because the actually-present hue is ochre at H≈70° = amber, +not orange at ~40°; same logic for "purple" over "pink" since lavender at +H≈305° is purple, not pink): + +| coarse family | CAM02-UCS hue range | what lives there in muted-8 | +|---|---|---| +| red | 345°–360° ∪ 0°–30° | matte red `#AE3030` (H≈25°) | +| yellow | 30°–90° | ochre `#BD8233` (H≈70°) | +| green | 90°–180° | brand green `#009E73` (H≈166°), lime `#99B314` (H≈115°) | +| cyan | 180°–230° | cyan `#2ABCCD` (H≈209°) | +| blue | 230°–285° | blue `#4467A3` (H≈254°) | +| purple | 285°–345° | lavender `#C475FD` (H≈305°), rosé `#954477` (H≈345°) | + +For muted-8 the 8 hexes distribute as: + +``` +red (1 hex): #AE3030 red +yellow (1 hex): #BD8233 ochre +green (2 hex): #009E73 brand-G (H≈166°), #99B314 lime (H≈115°) +cyan (1 hex): #2ABCCD cyan +blue (1 hex): #4467A3 blue +purple (2 hex): #C475FD lavender (H≈305°), #954477 rosé (H≈345°) +``` + +Six distinct families across the 8 hexes — the constraint comfortably enforces +4-distinct-families in the first 4 slots, with no fallback ever triggering. + +## Finalist slot order + +| slot | hex | fine family | coarse family | hue | +|---|---|---|---|---| +{slot_rows} + +worst-pair ΔE for the sorted palette, per first-n subset: + +| | {n_header} | +| {n_sep} | +{_per_n_md_row("normal vision", hybrid_norm)} +{_per_n_md_row("under CVD (min)", hybrid_cvd)} + +## Comparison to established palettes + +The 2021–2026 best practice is to constrain on min ΔE under simulated CVD as a +hard constraint inside the generator (Petroff 2021 arXiv:2107.02270; Zeileis +2024 `colorspace` 2.1; `qualpalr`). Slot ordering inside the optimised set is a +secondary concern handled differently per palette. + +| palette | slot 0..3 (hue family) | reds vs greens placement | ΔE_CVD floor (n=8) | +|---|---|---|---| +| **muted-8 hybrid-v3 ★** | {annos[0][1]} / {annos[1][1]} / {annos[2][1]} / {annos[3][1]} | red at slot {hybrid_hexes.index('#AE3030')} (deferred past first-4) | {hybrid_cvd[-1]:.1f} | +| Okabe-Ito (n=8, Wong 2011) | orange / sky-blue / green / yellow | vermillion at slot 5, green at slot 2 — 3 slots apart | ≈ 11 (Petroff 2021) | +| Paul Tol "muted" (n=9) | pink / indigo / yellow-tan / green | wine-red at slot 5, green at slot 3 | (unverified) | +| ColorBrewer Set2 (n=8) | teal / orange / blue-violet / pink | no "true" red in palette | (unverified) | +| Tableau-10 | blue / orange / red / teal | red at slot 2 (kept in first 4) | (unverified) | +| Tableau-Colorblind-10 | blue / orange / grey / dark-grey | only 2 hue families (blue + orange) + greys | n/a | +| D3 schemeCategory10 | blue / orange / green / red | green at 2, red at 3 — adjacent (textbook CVD weak point) | (unverified) | +| Petroff 2021 (n=8) | blue / orange / red / magenta | red at 2 (kept in first 4) | ≈ 18 (paper §3) | + +Sources: Wong 2011 *Nature Methods* 8:441; Tol 2018 (https://personal.sron.nl/~pault/); +ColorBrewer (https://colorbrewer2.org); Tableau-CB-10 hex via AndiH gist; +d3-scale-chromatic source; Petroff 2021 arXiv:2107.02270 §3. + +**Position:** muted-8 hybrid-v3 sits in the academic-publishing family — +defers red (like Okabe-Ito and Tol), 4 distinct hue families in slots 0..3, +similar ΔE_CVD floor at n=8 (~9 vs Okabe-Ito ~11), distinct because muted-8 +has a true semantic red available — just reached by name, not by position. + +## Why defer red? + +The CVD trade-off, measured concretely: + +| | n=2 | n=3 | n=4 | +|---|---|---|---| +| hybrid-v3 (with `defer=("#AE3030",)`, current) | 36.19 | 16.34 | 13.98 | +| hybrid-v3 without defer | 36.19 | 17.44 | 16.34 | + +Deferring red costs **~2.4 ΔE_CVD at n=4** because the binding pair shifts +from `green↔red` (CVD 16.34) to `green↔ochre` (CVD 13.98). In exchange: + +- **red stays a free semantic anchor.** The v2 expert reviews (consulting + + editorial) flagged that a true red is a semantic resource — "loss / error / + bad" — and burning it as slot 2 of every categorical chart wastes that + connotation. If `palette.red` and `palette[2]` both resolve to `#AE3030`, + the same colour means different things in different charts and the semantic + contract breaks. +- **the first 4 are 4 cleanly distinct hue families** — green / pink / blue / + orange — without a CVD-tight pair sitting next to each other. +- **n=4 ΔE_CVD = 13.98 is still above the 10-point "confident discrimination" + floor.** Practical loss is small; semantic gain is structural. +- **alignment with the academic-publishing family** (Okabe-Ito, Paul Tol — + both defer red to slot 5+), which is the right neighbourhood for a + generative plot tool whose output lands in editorial, slide-deck and + scientific contexts. + +Callers who want red back at slot 2 can pass `defer=()` to the sorter; the +parameter is configurable, just not the default. + +## Red anchor — considered alternatives, stayed with `#AE3030` + +Three of five v2 reviewers (editorial / brand / accessibility-implicit) flagged +`#AE3030` as too soft for the semantic-red role. The four proposed +alternatives were measured against the rest of muted-8 + both theme +backgrounds: + +| hex | source | J* | C | H° | WCAG light | WCAG dark | min ΔE_CVD | nearest | +|---|---|---|---|---|---|---|---|---| +| `#AE3030` ★ | **current muted-8** | 45.1 | 32.0 | 25.3° | 5.79:1 ✓ | **2.92:1 ❌** | 15.20 | rose | +| `#BE2B2B` | brand-rec (chroma+) | 47.9 | 35.2 | 26.6° | 5.30:1 ✓ | 3.19:1 ✓ | 14.59 | ochre | +| `#C8322C` | brand-rec (max chroma) | 50.8 | 35.5 | 28.1° | 4.79:1 ✓ | 3.53:1 ✓ | **11.52** | ochre | +| `#B71D27` | live-D / vivid-8 red | 45.1 | 36.1 | 25.0° | 5.88:1 ✓ | **2.87:1 ❌** | 17.38 | ochre | +| `#A41E22` | editorial-rec (darker) | 40.7 | 33.8 | 25.9° | 6.79:1 ✓ | **2.49:1 ❌** | 16.14 | rose | + +**What each reviewer argument actually says, vs the data:** + +- **Editorial: "AE3030 sits too close to rosé under deuteranopia."** Confirmed + empirically — AE has the smallest ΔE_CVD to `#954477` rosé (15.20). All + four alternatives push that gap to 16+. But 15.20 is still well above the + 10-point confident-discrimination threshold, so the practical impact is + small. +- **Brand: "AE3030 is brick, not red — push hue back toward 25°."** Marketing + language — all five candidates sit at hue 25–28°, virtually identical. + What reviewers perceive as "redder" is actually higher chroma (32 → 35–36) + and lightness, not hue. There is no meaningful hue shift available. +- **Accessibility (implicit): "matte red has less hue-rotation under CVD + because less chroma."** Correct — and this is exactly why AE works. + `#C8322C` raises chroma to 35.5 and immediately collapses min ΔE_CVD to + 11.52 against ochre (because ochre also sits in the warm hue band — higher + chroma rotates *into* that collision under CVD). + +**The only candidate that's a real refinement, not a regression:** + +`#BE2B2B` would close the dark-bg WCAG gap (2.92:1 → 3.19:1, just over the +3:1 line) and slightly widen the rosé gap under CVD (15.20 → 18.22). The +trade-off: marginally tighter ochre gap (14.59), still well above floor. +Visually it is essentially indistinguishable from AE — same hue, +3 chroma. + +**Decision: keep `#AE3030`.** + +The dark-bg sub-3:1 is documented in the "Contrast caveats" section and +handled by the outline pattern, which is needed for the other sub-3:1 hexes +on light bg regardless. Switching to `#BE2B2B` would close one half of one +WCAG line at the cost of regenerating every CVD-distance figure, slot +annotation, and screenshot in the rationale — for a change that's +visually marginal. The v3 documentation is built on AE3030 and that's +the shipping choice. Future per-theme hex sets (next step #6) would solve +the dark-bg gap structurally rather than by 0.07-point optimisation. + +## Semantic anchors outside the categorical pool + +The 8 hues above are the **categorical pool** — what `palette[:n]` returns. +But several semantic roles don't map cleanly onto any of the 8: warning +(needs leuchtgelb, not ochre-brown), totals/baseline (needs a neutral that's +visually structural rather than categorical), and "other/rest" in stacked +charts (needs a soft neutral that doesn't compete with the data). Three +additional anchors close those gaps. They live **outside** the slot pool and +are only reachable via the named API. + +### palette.amber — warning / caution + +Fixed hex `#DDCC77` (Paul Tol "muted" yellow). Min ΔE under CVD to all 8 +categorical hexes = **{amber_cvd_min:.2f}** — confidently distinct from every +member, including `#99B314` lime. + +| candidate | min ΔE_normal | min ΔE_CVD | C | comment | +|---|---|---|---|---| +| `#D4A017` amber-2017 | 12.62 | **2.37** | 29.2 | collapses against `#99B314` lime under deuteranopia (unusable) | +| `#D4AF37` goldenrod | 14.99 | **2.33** | 27.1 | same lime collision (unusable) | +| `#DDCC77` Tol muted-yellow ★ | 19.56 | **14.52** | 20.6 | only CVD-safe option; consistent with the academic-publishing family muted-8 lives in | + +The two more saturated amber candidates fail because under deuteranopia / +protanopia they collapse to the same lightness-band as lime. Tol's +lower-chroma amber sits in a different lightness band (J*=84 vs lime's J*=71) +and survives the simulation. C=20.6 is *just* below the muted-8 chroma +envelope (C ∈ [24, 32]) but that's a feature: it signals "I'm not a +categorical-pool member, I'm a semantic anchor next to it". + +### palette.neutral — totals / baseline / outline (theme-adaptive) + +The neutral isn't a fixed hex but a **role** that flips per theme — same +pattern as Apple HIG, Material Design, GitHub Primer, and Wong (2011) +Okabe-Ito position 8 (style-guide §4.1): + +- **light theme** → `{ink_light}` (warm near-black ink) +- **dark theme** → `{ink_dark}` (warm near-white ink) + +Same hex as the chart's text and gridlines, so a "totals" / "baseline" / +"reference outline" series reads as part of the chart's structural layer +rather than as just another category. Implemented today as `NEUTRAL_LIGHT` / +`NEUTRAL_DARK` in `scripts/_palette_common.py:70-71`. + +### palette.muted — other / rest / disabled (theme-adaptive) + +A second adaptive neutral, soft-contrast rather than full-contrast: + +- **light theme** → `{ink_muted_light}` (warm mid-gray) +- **dark theme** → `{ink_muted_dark}` (warm mid-gray) + +For "other" / "rest" slices in stacked bars, disabled / inactive series, +confidence bands, and annotations that should sit behind the data without +competing. Comes from `LIGHT_THEME["ink_muted"]` / +`DARK_THEME["ink_muted"]` — already used everywhere in the design system, +just not yet exposed through the palette API. + +## Final named-API surface + +```python +# Categorical pool (8 hues — sorted by hybrid-v3, slots 0..7) +anyplot.palette.green # → #009E73 ("good / profit / energy") +anyplot.palette.red # → #AE3030 ("bad / loss / error") +anyplot.palette.blue # → #4467A3 ("cool / water / info") +anyplot.palette.cyan # → #2ABCCD ("sky / tech-cool") +anyplot.palette.lime # → #99B314 ("growth / nature") +anyplot.palette.ochre # → #BD8233 ("earth / commodity") +anyplot.palette.lavender # → #C475FD ("creative") +anyplot.palette.rose # → #954477 ("wellness / feminine") + +# Semantic anchors OUTSIDE the categorical pool (never returned by palette[:n]) +anyplot.palette.amber # → #DDCC77 ("caution / warning") +anyplot.palette.neutral # → adaptive ("totals / baseline / outline") +anyplot.palette.muted # → adaptive ("other / rest / disabled") + +# Semantic-role aliases that map to the anchors above +anyplot.palette.semantic.good # → green +anyplot.palette.semantic.bad # → red +anyplot.palette.semantic.warning # → amber (NB: NOT ochre — ochre is "earth", not "caution") +anyplot.palette.semantic.info # → cyan +anyplot.palette.semantic.baseline # → neutral (adaptive) +anyplot.palette.semantic.other # → muted (adaptive) +``` + +Slot order and named access are independent — both ship. + +## Contrast caveats & the outline pattern + +The muted aesthetic carries a known trade-off: the lighter members reach +their distinguishability through chroma, not L-spread, so on the light theme +(cream `#F5F3EC`) five categorical hues + amber fall under WCAG 2.1 +SC 1.4.11's 3:1 minimum for graphical objects. This is not unique to +muted-8 — Okabe-Ito's `#F0E442` yellow, Paul Tol “muted”'s +`#DDCC77` and `#88CCEE`, and ColorBrewer Set2's `#A6D854` green all +have the same limitation. + +Per-theme ratios for every categorical hue + amber: + +| hex | name | on cream bg | on dark bg | +|---|---|---|---| +| `#009E73` | brand-green | 3.08:1 ✓ | 5.48:1 ✅ | +| `#AE3030` | matte-red | 5.79:1 ✅ | **2.92:1** ❌ | +| `#C475FD` | lavender | **2.59:1** ❌ | 6.53:1 ✅ | +| `#99B314` | lime | **2.15:1** ❌ | 7.87:1 ✅ | +| `#4467A3` | blue | 5.09:1 ✅ | 3.32:1 ✓ | +| `#2ABCCD` | cyan | **2.06:1** ❌ | 8.19:1 ✅ | +| `#954477` | rose | 5.61:1 ✅ | 3.01:1 ✓ | +| `#BD8233` | ochre | **2.95:1** ❌ | 5.72:1 ✅ | +| `#DDCC77` | amber (anchor) | **1.46:1** ❌ | 11.59:1 ✅ | + +### Recommended pattern: thin ink-color outline + +The industry-standard rescue is a thin stroke in the chart's ink color +on the affected series — Tableau, Vega, and most modern dashboarding tools +do this automatically in their accessibility mode. Recommended: + +- **line / scatter / area edges:** 1px solid ink stroke +- **bar / pie fills:** 1–1.5px solid ink stroke +- **legend swatches:** match the chart's outline behavior + +The stroke contrast (`#1A1A17` ink on `#F5F3EC` bg = 15.71:1) always passes +on its own, so the *visible boundary* of the series clears 3:1 even if the +fill colour doesn't. + +### What about amber specifically? + +`palette.amber = #DDCC77` is the worst case on light bg (1.46:1) but lives +outside the categorical pool — it's only reached intentionally via +`palette.amber` or `palette.semantic.warning` for caution / warning roles. +On the light theme, the same outline rule applies: wherever amber is used +(typically: a warning marker, a status icon, a single attention slice), add +a thin ink stroke. amber is never used by `palette[:n]` so it never ends +up on light bg by accident. + +### Dark-theme caveats + +The dark theme is mostly clean (every hue ≥ 3:1) except `#AE3030` matte-red +at 2.92:1 — fractionally under threshold. Same outline rule applies for +high-stakes layouts (financial dashboards, accessibility-strict contexts). +Future work — listed in v2's reviewer recommendations — is a separate +per-theme hex set with L+12 lift on the cool half; until then, the outline +pattern is the documented fix. + +See the live demo in [`index.html`](./index.html#contrast) — every member +rendered on both themes, with the sub-3:1 ones shown both as-is and with +the 1px ink ring. + +## Next steps + +1. Apply the hybrid-v3 ordering above as the new live `ANYPLOT_PALETTE`. +2. Ship the named API alongside, with `amber` / `neutral` / `muted` as the + three semantic anchors outside the categorical pool. +3. Wire `semantic.warning → amber` (not ochre — ochre is the "earth / + commodity" categorical hue, not a caution signal). +4. Document the n > 4 redundant-encoding guidance (linestyle / marker / shape). +5. *Optional* — expose a `palette.cvd_severity` knob defaulting to 1.0 (the + conservative current behaviour) but lettable down to ~0.6 for users who + explicitly want the palette tuned to realistic deuteranomaly severity + instead of full dichromacy. +6. *Future work — per-theme hex sets.* The "Contrast caveats" section + documents that 5 hexes sit below WCAG 3:1 on cream bg, and `#AE3030` + marginally below on dark bg. A separate dark-theme variant with L+12 + lifted on the cool half would close those structurally rather than via + outline. Until then, the outline pattern is the documented fix. +7. *Future work — OKLCH notation + Display-P3 variant.* CSS Color Level 4 is + broadly supported (Chrome 111+, Firefox 113+, Safari 15.4+). Defining the + web-side palette in OKLCH would give predictable chroma editing and + prevent the auto-saturation P3 browsers apply to sRGB hex. The Python + plotting side (matplotlib / plotly / altair) stays on hex — those engines + don't accept OKLCH input today. So this is a web/docs polish, not a + plot-render improvement. A `palette.p3.*` namespace with deliberately + raised chroma would only pay off once the plot generators support P3 + color() output (likely 1–2 years out). + +## References + +- Wong B. (2011). "Points of view: Color blindness." *Nature Methods* 8:441. +- Tol P. (2018). "Colour Schemes", https://personal.sron.nl/~pault/. +- Sharpe L. T. et al. (1999). "Opsin genes, cone photopigments, color vision, and color blindness." In *Color Vision: From Genes to Perception*. +- Birch J. (2012). "Worldwide prevalence of red-green color deficiency." *J. Opt. Soc. Am. A* 29(3):313–320. +- Simunovic M. P. (2010). "Colour vision deficiency." *Eye* 24:747–755. +- Petroff M. A. (2021). "Accessible Color Sequences for Data Visualization." arXiv:2107.02270. +- Zeileis A. (2024). "Color Vision Deficiency Emulation in colorspace 2.1." https://www.zeileis.org/news/simulate_cvd/. +- Larsson J. (2024). "qualpalr: Automatic Generation of Qualitative Color Palettes." https://jolars.github.io/qualpalr/. +- Carbon Design System. "Data visualization color palettes." https://carbondesignsystem.com/data-visualization/color-palettes/. +""" + + +# ----------------------------------------------------------------------------- +# CLI +# ----------------------------------------------------------------------------- + + +DEFAULT_OUT_DIR = REPO_ROOT / "docs" / "reference" / "palette-variants-v3" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate palette variants v3") + parser.add_argument( + "--out-dir", type=Path, default=DEFAULT_OUT_DIR, + help=f"Output directory (default: {DEFAULT_OUT_DIR})", + ) + parser.add_argument("--quiet", action="store_true") + args = parser.parse_args() + + logging.basicConfig( + level=logging.WARNING if args.quiet else logging.INFO, + format="%(message)s", + ) + log = logging.getLogger("palette-variants-v3") + + args.out_dir.mkdir(parents=True, exist_ok=True) + + hybrid = sort_hybrid_v3(list(MUTED_8)) + log.info("muted-8 hybrid-v3 → %s", " ".join(hybrid)) + log.info( + " per-n CVD ΔE = %s", + [round(v, 2) for v in measure_per_n_cvd(hybrid)], + ) + + log.info("comparison sortings:") + sort_rows: list[tuple[str, str, list[str], list[float], list[float]]] = [] + for slug, title, fn in ALL_SORTINGS: + sorted_hexes = fn(list(MUTED_8)) + cvd = measure_per_n_cvd(sorted_hexes) + norm = measure_per_n_normal(sorted_hexes) + sort_rows.append((slug, title, sorted_hexes, cvd, norm)) + log.info( + " %-26s %s CVDmin/n=2..8=%s", + slug, + " ".join(sorted_hexes), + [round(v, 2) for v in cvd], + ) + + html_out = render_page(hybrid) + out_path = args.out_dir / "index.html" + out_path.write_text(html_out, encoding="utf-8") + log.info("wrote %s (%.1f kB)", out_path, out_path.stat().st_size / 1024) + + md_out = render_rationale_md(hybrid, sort_rows) + md_path = args.out_dir / "decision-rationale.md" + md_path.write_text(md_out, encoding="utf-8") + log.info("wrote %s (%.1f kB)", md_path, md_path.stat().st_size / 1024) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/style-variants.yaml b/scripts/style-variants.yaml index c27c77ca60..01a9f6edf9 100644 --- a/scripts/style-variants.yaml +++ b/scripts/style-variants.yaml @@ -265,13 +265,14 @@ variants: - {regex: 'stroke_width\s*=\s*3\b', replace: "stroke_width=9"} palette_tableau: - description: "Tableau-10 categorical palette in place of the anyplot palette (visual sanity check, not a real proposal)." + description: "Tableau-10 categorical palette in place of the anyplot imprint palette (visual sanity check, not a real proposal)." patches: "*": - {find: "#009E73", replace: "#4E79A7"} - - {find: "#9418DB", replace: "#F28E2B"} - - {find: "#B71D27", replace: "#59A14F"} - - {find: "#16B8F3", replace: "#E15759"} - - {find: "#99B314", replace: "#B07AA1"} - - {find: "#D359A7", replace: "#76B7B2"} - - {find: "#BA843E", replace: "#EDC948"} + - {find: "#C475FD", replace: "#F28E2B"} + - {find: "#4467A3", replace: "#59A14F"} + - {find: "#BD8233", replace: "#E15759"} + - {find: "#AE3030", replace: "#B07AA1"} + - {find: "#2ABCCD", replace: "#76B7B2"} + - {find: "#954477", replace: "#EDC948"} + - {find: "#99B314", replace: "#FF9DA7"}