From 0b4f41735fbf18e9edd101b8c803ac9f640e349f Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sun, 24 May 2026 00:20:34 +0200 Subject: [PATCH 1/5] chore(prompts): bump grid-line opacity token from 0.10 to 0.15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The style-guide had two contradicting values for grid/rule opacity: the Theme-adaptive Chrome token table at 0.10 vs the dedicated Grid Guidelines section "opacity 15-25%, very subtle". Per-library prompts (matplotlib, plotly, bokeh, makie, highcharts, altair, plotnine, seaborn rcParams) all inherited the 0.10 token; seaborn's hardcoded ax.yaxis.grid call already used 0.2 (following the verbal rule). Harmonize the spec at 0.15 — the lower edge of the Grid Guidelines band — so the token table and the verbal rule agree, and propagate to every library prompt that referenced the 0.10 token. Generated plots stay subtle but become clearly readable against the warm paper-ink surfaces. Co-Authored-By: Claude Opus 4.7 (1M context) --- prompts/default-style-guide.md | 4 ++-- prompts/library/altair.md | 2 +- prompts/library/bokeh.md | 4 ++-- prompts/library/highcharts.md | 2 +- prompts/library/makie.md | 8 ++++---- prompts/library/matplotlib.md | 2 +- prompts/library/plotly.md | 2 +- prompts/library/plotnine.md | 2 +- prompts/library/seaborn.md | 2 +- prompts/plot-generator.md | 2 +- 10 files changed, 15 insertions(+), 15 deletions(-) diff --git a/prompts/default-style-guide.md b/prompts/default-style-guide.md index e091ecc745..9f79776c03 100644 --- a/prompts/default-style-guide.md +++ b/prompts/default-style-guide.md @@ -128,7 +128,7 @@ In addition to the background, every non-data element (title, axis labels, tick | Primary text (title, axis labels) | `#1A1A17` | `#F0EFE8` | | Secondary text (tick labels, legend, subtitles) | `#4A4A44` | `#B8B7B0` | | Tertiary text (footnotes, meta annotations) | `#6B6A63` | `#A8A79F` | -| Grid lines, rule dividers, thin borders | `rgba(26,26,23,0.10)` | `rgba(240,239,232,0.10)` | +| Grid lines, rule dividers, thin borders | `rgba(26,26,23,0.15)` | `rgba(240,239,232,0.15)` | | Callout / legend box fill | `#FFFDF6` | `#242420` | **Reference Python snippet** (generators must emit logic equivalent to this — exact syntax is library-specific): @@ -142,7 +142,7 @@ ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420" INK = "#1A1A17" if THEME == "light" else "#F0EFE8" INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F" -RULE = "rgba(26,26,23,0.10)" if THEME == "light" else "rgba(240,239,232,0.10)" +RULE = "rgba(26,26,23,0.15)" if THEME == "light" else "rgba(240,239,232,0.15)" BRAND = "#009E73" # anyplot palette position 1, theme-independent ``` diff --git a/prompts/library/altair.md b/prompts/library/altair.md index 6a122bb3c5..106c3cf058 100644 --- a/prompts/library/altair.md +++ b/prompts/library/altair.md @@ -162,7 +162,7 @@ chart = ( .configure_view(fill=PAGE_BG, stroke=INK_SOFT) .configure_axis( domainColor=INK_SOFT, tickColor=INK_SOFT, - gridColor=INK, gridOpacity=0.10, + gridColor=INK, gridOpacity=0.15, labelColor=INK_SOFT, titleColor=INK, ) .configure_title(color=INK) diff --git a/prompts/library/bokeh.md b/prompts/library/bokeh.md index 26879e0b18..a8b6348d74 100644 --- a/prompts/library/bokeh.md +++ b/prompts/library/bokeh.md @@ -191,8 +191,8 @@ p.yaxis.major_tick_line_color = INK_SOFT p.xgrid.grid_line_color = INK p.ygrid.grid_line_color = INK -p.xgrid.grid_line_alpha = 0.10 -p.ygrid.grid_line_alpha = 0.10 +p.xgrid.grid_line_alpha = 0.15 +p.ygrid.grid_line_alpha = 0.15 if p.legend: p.legend.background_fill_color = ELEVATED_BG diff --git a/prompts/library/highcharts.md b/prompts/library/highcharts.md index ffa926b123..77918aa9f8 100644 --- a/prompts/library/highcharts.md +++ b/prompts/library/highcharts.md @@ -224,7 +224,7 @@ PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420" INK = "#1A1A17" if THEME == "light" else "#F0EFE8" INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" -GRID = "rgba(26,26,23,0.10)" if THEME == "light" else "rgba(240,239,232,0.10)" +GRID = "rgba(26,26,23,0.15)" if THEME == "light" else "rgba(240,239,232,0.15)" chart.options.chart = { 'type': 'column', diff --git a/prompts/library/makie.md b/prompts/library/makie.md index cc55c5ccb2..112a763e17 100644 --- a/prompts/library/makie.md +++ b/prompts/library/makie.md @@ -188,8 +188,8 @@ ax = Axis( bottomspinecolor = INK_SOFT, topspinevisible = false, rightspinevisible = false, - xgridcolor = RGBAf(INK.r, INK.g, INK.b, 0.10), - ygridcolor = RGBAf(INK.r, INK.g, INK.b, 0.10), + xgridcolor = RGBAf(INK.r, INK.g, INK.b, 0.15), + ygridcolor = RGBAf(INK.r, INK.g, INK.b, 0.15), xminorgridvisible = false, yminorgridvisible = false, ) @@ -263,8 +263,8 @@ ax = Axis( rightspinevisible = false, leftspinecolor = INK_SOFT, bottomspinecolor = INK_SOFT, - xgridcolor = RGBAf(INK.r, INK.g, INK.b, 0.10), - ygridcolor = RGBAf(INK.r, INK.g, INK.b, 0.10), + xgridcolor = RGBAf(INK.r, INK.g, INK.b, 0.15), + ygridcolor = RGBAf(INK.r, INK.g, INK.b, 0.15), ) scatter!(ax, x, y; color = ANYPLOT_PALETTE[1], markersize = 12, strokewidth = 0) diff --git a/prompts/library/matplotlib.md b/prompts/library/matplotlib.md index f215da0390..ca51f990db 100644 --- a/prompts/library/matplotlib.md +++ b/prompts/library/matplotlib.md @@ -149,7 +149,7 @@ ax.set_title(..., color=INK) ax.set_xlabel(..., color=INK); ax.set_ylabel(..., color=INK) ax.tick_params(colors=INK_SOFT, labelcolor=INK_SOFT) for s in ('left', 'bottom'): ax.spines[s].set_color(INK_SOFT) -ax.grid(True, alpha=0.10, color=INK) +ax.grid(True, alpha=0.15, color=INK) leg = ax.legend(...) if leg: diff --git a/prompts/library/plotly.md b/prompts/library/plotly.md index afab3e4a6e..a986ed90d5 100644 --- a/prompts/library/plotly.md +++ b/prompts/library/plotly.md @@ -108,7 +108,7 @@ PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420" INK = "#1A1A17" if THEME == "light" else "#F0EFE8" INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" -GRID = "rgba(26,26,23,0.10)" if THEME == "light" else "rgba(240,239,232,0.10)" +GRID = "rgba(26,26,23,0.15)" if THEME == "light" else "rgba(240,239,232,0.15)" fig.update_layout( paper_bgcolor=PAGE_BG, diff --git a/prompts/library/plotnine.md b/prompts/library/plotnine.md index 1459d99180..d9f6bb3df7 100644 --- a/prompts/library/plotnine.md +++ b/prompts/library/plotnine.md @@ -137,7 +137,7 @@ INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" anyplot_theme = theme( plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG), panel_background=element_rect(fill=PAGE_BG), - panel_grid_major=element_line(color=INK, size=0.3, alpha=0.10), + panel_grid_major=element_line(color=INK, size=0.3, alpha=0.15), panel_grid_minor=element_line(color=INK, size=0.2, alpha=0.05), panel_border=element_rect(color=INK_SOFT, fill=None), axis_title=element_text(color=INK), diff --git a/prompts/library/seaborn.md b/prompts/library/seaborn.md index 37f226aa28..748af9cb79 100644 --- a/prompts/library/seaborn.md +++ b/prompts/library/seaborn.md @@ -160,7 +160,7 @@ sns.set_theme( "xtick.color": INK_SOFT, "ytick.color": INK_SOFT, "grid.color": INK, - "grid.alpha": 0.10, + "grid.alpha": 0.15, "legend.facecolor": ELEVATED_BG, "legend.edgecolor": INK_SOFT, }, diff --git a/prompts/plot-generator.md b/prompts/plot-generator.md index f92302cc4b..1b801eafd2 100644 --- a/prompts/plot-generator.md +++ b/prompts/plot-generator.md @@ -130,7 +130,7 @@ ax.spines['top'].set_visible(False) ax.spines['right'].set_visible(False) for s in ('left', 'bottom'): ax.spines[s].set_color(INK_SOFT) -ax.yaxis.grid(True, alpha=0.10, linewidth=0.8, color=INK) +ax.yaxis.grid(True, alpha=0.15, linewidth=0.8, color=INK) plt.tight_layout() plt.savefig(f'plot-{THEME}.png', dpi=400, bbox_inches='tight', facecolor=PAGE_BG) From ef29a0183d5e01186c435a70da1b7890e5a3ea87 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sun, 24 May 2026 00:20:59 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat(palette):=20variants=20v1=20=E2=80=94?= =?UTF-8?q?=20D-baseline=20+=204=20candidates=20with=20CAM02-UCS=20color?= =?UTF-8?q?=20wheel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second round of palette exploration (#5817 follow-up). v0 (variants A-F) compared against Okabe-Ito; v1 (this) measures everything against live D, which has shipped as ANYPLOT_PALETTE since the v0 round. New deliverable at docs/reference/palette-variants-v1/: - D-baseline.html — live anyplot palette rendered with the same template as candidates, so it sits in the lineup as the bar to beat - D1 tight-chroma — C ∈ [24, 32], position-1 pinned to red band [15°, 35°] so the corridor still yields a true crimson (#AE3030) rather than a rust pick - D3 expand-8 — live D's 7 hues + 1 freely-picked 8th hue (indigo #7981FD), diametrically opposite tan; fills both remaining wheel gaps without forcing a swap - T tetradic — 4 hue anchors 90° apart starting at brand green, 3 mid-quadrant fillers - W warm-pole — D's max-min selection plus a warm-pole scoring bonus centred at 55°, with #B71D27 pinned for semantic-red availability - index.html — hero color wheel with candidate-overlay toggles + baseline-card layout flip + Δ-vs-D coloring - compare.html — side-by-side card grid with D as the reference row New generator scripts/palette-variants-v1.py extends the v0 algorithm with: - forbidden_hue_bands / warm_bonus knobs in select_palette - tetradic + d-* strategy branches in _strategy_bands - n_hues parameter on Variant (D3 uses 8) - render_color_wheel — pre-rendered CAM02-UCS PNG disk (perceptually honest chroma fade, no slice seams) with palette dots placed at their actual (C, H) coords, optional chroma-corridor toggle, overlay live-D comparison scripts/_palette_common.py: bump v1 sample-chart gridlines from 0.06 to 0.15 (catch-up to the new style-guide token), bump PAGE_CSS --rule from 0.10 to 0.15 for the same consistency. v0 (palette-variants/) untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../palette-variants-v1/D-baseline.html | 760 ++++++ .../palette-variants-v1/D1-tight-chroma.html | 760 ++++++ .../palette-variants-v1/D3-expand-8.html | 760 ++++++ .../palette-variants-v1/T-tetradic.html | 760 ++++++ .../palette-variants-v1/W-warm-pole.html | 760 ++++++ .../palette-variants-v1/compare.html | 770 ++++++ docs/reference/palette-variants-v1/index.html | 981 ++++++++ scripts/_palette_common.py | 10 +- scripts/palette-variants-v1.py | 2093 +++++++++++++++++ 9 files changed, 7649 insertions(+), 5 deletions(-) create mode 100644 docs/reference/palette-variants-v1/D-baseline.html create mode 100644 docs/reference/palette-variants-v1/D1-tight-chroma.html create mode 100644 docs/reference/palette-variants-v1/D3-expand-8.html create mode 100644 docs/reference/palette-variants-v1/T-tetradic.html create mode 100644 docs/reference/palette-variants-v1/W-warm-pole.html create mode 100644 docs/reference/palette-variants-v1/compare.html create mode 100644 docs/reference/palette-variants-v1/index.html create mode 100644 scripts/palette-variants-v1.py 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..7b174f7d33 --- /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-tight-chroma.html b/docs/reference/palette-variants-v1/D1-tight-chroma.html new file mode 100644 index 0000000000..4d17207e72 --- /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..da1c21ea59 --- /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..4e88a085b1 --- /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..f6aa112d1a --- /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..c8fc92244c --- /dev/null +++ b/docs/reference/palette-variants-v1/compare.html @@ -0,0 +1,770 @@ + + + + + +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) +
+
+
+ +
+
+
+ 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..ae7d7e093d --- /dev/null +++ b/docs/reference/palette-variants-v1/index.html @@ -0,0 +1,981 @@ + + + + + +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 →
+
+ + +
+ 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/scripts/_palette_common.py b/scripts/_palette_common.py index 5863e3c67b..3b0f90fcfe 100644 --- a/scripts/_palette_common.py +++ b/scripts/_palette_common.py @@ -311,7 +311,7 @@ def render_sample_chart( ink_muted = theme["ink_muted"] rule = theme.get("rule", "#DFDDD6") is_light = bg.upper().startswith("#F") - grid = "rgba(26,26,23,0.06)" if is_light else "rgba(240,239,232,0.06)" + grid = "rgba(26,26,23,0.15)" if is_light else "rgba(240,239,232,0.15)" n_points = 48 @@ -384,7 +384,7 @@ def render_sample_bars( ink_muted = theme["ink_muted"] rule = theme.get("rule", "#DFDDD6") is_light = bg.upper().startswith("#F") - grid = "rgba(26,26,23,0.06)" if is_light else "rgba(240,239,232,0.06)" + grid = "rgba(26,26,23,0.15)" if is_light else "rgba(240,239,232,0.15)" n_groups = 4 n_series = len(series_hexes) @@ -459,7 +459,7 @@ def render_sample_scatter( ink_muted = theme["ink_muted"] rule = theme.get("rule", "#DFDDD6") is_light = bg.upper().startswith("#F") - grid = "rgba(26,26,23,0.06)" if is_light else "rgba(240,239,232,0.06)" + grid = "rgba(26,26,23,0.15)" if is_light else "rgba(240,239,232,0.15)" # Reproducible "random" jitter via a fixed sequence import random as _r @@ -780,7 +780,7 @@ def render_legend() -> str: --ink: #1A1A17; --ink-soft: #4A4A44; --ink-muted: #6B6A63; - --rule: rgba(26, 26, 23, 0.10); + --rule: rgba(26, 26, 23, 0.15); --ok-green: #009E73; --ok-amber: #E69F00; --ok-bad: #D55E00; @@ -793,7 +793,7 @@ def render_legend() -> str: --ink: #F0EFE8; --ink-soft: #B8B7B0; --ink-muted: #A8A79F; - --rule: rgba(240, 239, 232, 0.10); + --rule: rgba(240, 239, 232, 0.15); } * { box-sizing: border-box; } body { diff --git a/scripts/palette-variants-v1.py b/scripts/palette-variants-v1.py new file mode 100644 index 0000000000..e7237a8422 --- /dev/null +++ b/scripts/palette-variants-v1.py @@ -0,0 +1,2093 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "colorspacious>=1.1.2", +# "numpy>=2.0", +# "matplotlib>=3.10", +# "pillow>=11.0", +# ] +# /// +"""Palette variant generator v1 for anyplot (Issue #5817 — second round). + +This is the **v1 follow-up** to ``scripts/palette-variants.py``. The first +round (variants A–F) compared candidates against Okabe-Ito. From that round, +**variant D ("balanced")** was adopted as the live ``ANYPLOT_PALETTE`` in +``core/images.py``. v1 therefore changes the baseline — every candidate here +is measured against **live D**, not Okabe-Ito. + +Five new candidates explore "refine vs. rethink": + + D1 — d-tight-chroma (D's max-min but C ∈ [24, 32] — narrower paper-ink) + D2 — d-wide-spread (D's max-min with 60° pairwise hue spread target) + D3 — d-swap-tan (D's max-min but hue band [50°, 90°] banned at pos 6 + — forces an alternative to the live tan #BA843E) + T — tetradic (4 anchors 90° apart, brand-green anchored, 3 fillers) + W — warm-pole (D's max-min plus a warm-hue scoring bonus 30°–80°) + +For each, the script: + + 1. Picks 7 hues respecting the strategy's hue rule, the paper-ink + chroma/lightness corridor (J' ∈ [45,72], C ∈ [22,50]), and gamut. + 2. Reorders positions 2..4 so the first 4 maximise their internal + min worst-CVD ΔE — the "most beautiful subset" criterion. + 3. Builds a perceptually-uniform continuous colormap starting at the + brand green. + 4. Renders a self-contained HTML page with the same diagnostic blocks + as v0, plus a new CAM02-UCS **color wheel** section that places every + palette hue at its actual (C, H) coordinates — Adobe Color / Dracula + Pro style — so the geometry of the palette is visible at a glance. + +The live D palette itself is rendered as ``D-baseline.html`` using the same +template, so it sits in the lineup as "the one to beat". + +Output: ``docs/reference/palette-variants-v1/{D-baseline,D1..D3,T,W}-…html`` +plus ``index.html`` (hero wheel + candidate cards) and ``compare.html``. + +Run:: + + uv run --script scripts/palette-variants-v1.py +""" + +from __future__ import annotations + +import argparse +import itertools +import logging +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +import numpy as np +from colorspacious import cspace_convert + + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +# Brand anchor — kept inline so this variant-search script stays decoupled +# from runtime core.images imports. +OK_GREEN = "#009E73" + +# Live anyplot palette — mirrored verbatim from core/images.py +# (ANYPLOT_PALETTE, lines 48–63). This is the v1 baseline-to-beat; every +# candidate in this script is measured against ANYPLOT_D_PALETTE. If you +# change one, change the other. +ANYPLOT_D_PALETTE = [ + "#009E73", # ANYPLOT_GREEN — brand anchor (also Okabe-Ito green) + "#9418DB", # ANYPLOT_PURPLE + "#B71D27", # ANYPLOT_RED + "#16B8F3", # ANYPLOT_SKY + "#99B314", # ANYPLOT_LIME + "#D359A7", # ANYPLOT_PINK + "#BA843E", # ANYPLOT_TAN +] +from _palette_common import ( # noqa: E402 + CVD_ORDER, + DARK_THEME_FULL, + LIGHT_THEME_FULL, + NEUTRAL_DARK, + NEUTRAL_LIGHT, + PAGE_CSS, + PAGE_JS, + cell_class, + hex_to_rgb1, + pairwise_delta_e, + _peaks_png_b64, + render_cmap_demo, + render_colormap_row, + render_first_n_summary, + render_gradient, + render_hero_mockup_pair, + render_legend, + render_matrix_block, + render_sample_charts, + render_swatch_table, + rgb1_to_hex, + simulate_cvd, + to_jab, + worst_cvd_pairwise_delta_e, +) + + +DEFAULT_OUT_DIR = REPO_ROOT / "docs" / "reference" / "palette-variants-v1" + +# ── Paper-ink corridor in CAM02-UCS (J' = lightness, C = chroma, H = hue) ───── +# Lower J' bound: at 45 the colour is dark enough to read against #F5F3EC light bg. +# Upper J' bound: at 72 the colour is still light enough to read against #121210 dark bg. +# Chroma corridor is the paper-ink lever (Caligo sits at C≈60-90, Okabe-Ito at +# C≈40-75). Per-variant overrides below let D be the most muted ("dusty") and +# E honour Okabe-Ito's hotter native chroma. +J_MIN, J_MAX, J_STEP = 45.0, 72.0, 2.0 +C_MIN, C_MAX, C_STEP = 22.0, 50.0, 2.0 +H_STEP_DEG = 5.0 + + +# Per-variant chroma corridors — tightening C is the single biggest paper-ink lever. +# At hue ≈ 25° (red) the in-gamut max chroma in CAM02-UCS reaches ~70; capping C +# means warm picks stay matte instead of going neon-red. A/B/C sit at "muted", +# D at "dusty" (most restrained), E at "okabe-honest" (slightly hotter to +# respect Okabe-Ito's native saturation). +PER_VARIANT_C_RANGE: dict[str, tuple[float, float]] = { + # v0 strategies — kept so v1 can still call select_palette("balanced") if needed + "analogous": (24.0, 40.0), + "triadic": (26.0, 42.0), + "split-comp": (26.0, 42.0), + "balanced": (22.0, 36.0), + "harmonic": (22.0, 60.0), + "okabe-anchored": (22.0, 42.0), + # v1 strategies + "d-tight-chroma": (24.0, 32.0), # D1 — narrowest paper-ink corridor; cleanest co-existence prediction + "d-expand-8": (22.0, 36.0), # D3 — same C as live D; an 8th slot is greedy-picked in the largest remaining hue gap + "tetradic": (24.0, 38.0), # T — slight C bump so the 4 forced anchors don't all land at the muted floor + "warm-pole": (22.0, 36.0), # W — same C as live D; the warm-bonus does the work +} + + +# Minimum pairwise hue spacing target per strategy (degrees on the colour wheel). +# Every strategy now enforces this via the diversity penalty inside +# ``score_candidates``; without it, max-min ΔE optimisation cheerfully picks +# two near-identical blues or two yellow-greens whenever the chroma corridor +# leaves headroom for only one warm/cool region. +PER_VARIANT_HUE_SPREAD: dict[str, float] = { + # v0 strategies + "analogous": 35.0, + "triadic": 45.0, + "split-comp": 45.0, + "balanced": 50.0, # 360/7 ≈ 51°, the ideal even spacing + "harmonic": 50.0, + "okabe-anchored": 45.0, + # v1 strategies + "d-tight-chroma": 50.0, # same as live D — only chroma differs + "d-expand-8": 50.0, # same as live D; the 8th pick fills naturally where the wheel gap is biggest + "tetradic": 50.0, + "warm-pole": 50.0, # warm bonus is additive at scoring; spacing target unchanged +} + + +# ----------------------------------------------------------------------------- +# CAM02-UCS / LCh helpers +# ----------------------------------------------------------------------------- + + +def jab_to_rgb1(jab: np.ndarray) -> np.ndarray: + """Inverse of `to_jab`. Output may go out of gamut — caller clips/checks.""" + return cspace_convert(jab, "CAM02-UCS", "sRGB1") + + +def jab_batch_to_rgb1(jab_arr: np.ndarray) -> np.ndarray: + return cspace_convert(jab_arr, "CAM02-UCS", "sRGB1") + + +def lch_to_jab(L: float, C: float, H_deg: float) -> np.ndarray: + h = np.deg2rad(H_deg) + return np.array([L, C * np.cos(h), C * np.sin(h)]) + + +def jab_to_lch(jab: np.ndarray) -> tuple[float, float, float]: + L, a, b = float(jab[0]), float(jab[1]), float(jab[2]) + C = float(np.hypot(a, b)) + H = float(np.rad2deg(np.arctan2(b, a))) % 360 + return L, C, H + + +def hue_in_band(hue: float, center: float, half_width: float) -> bool: + """Circular hue containment, half_width in degrees.""" + d = abs((hue - center + 180) % 360 - 180) + return d <= half_width + + +# ----------------------------------------------------------------------------- +# Candidate grid — generated once per run, reused across all variants +# ----------------------------------------------------------------------------- + + +@dataclass +class CandidatePool: + """All candidate colours within the global paper-ink corridor, plus + precomputed Jab coordinates under each of the 4 conditions. Per-variant + chroma corridors are applied later as a candidate-mask.""" + + rgb1: np.ndarray # (N, 3) sRGB-1 + hues_deg: np.ndarray # (N,) circular hue 0-360 + chromas: np.ndarray # (N,) C in CAM02-UCS + lightnesses: np.ndarray # (N,) J' in CAM02-UCS + jab_per_cond: dict[str, np.ndarray] # cond → (N, 3) + + @classmethod + def build(cls, log: logging.Logger) -> "CandidatePool": + js = np.arange(J_MIN, J_MAX + 0.01, J_STEP) + cs_ = np.arange(C_MIN, C_MAX + 0.01, C_STEP) + hs = np.arange(0.0, 360.0, H_STEP_DEG) + + log.info("building candidate grid (J×C×H = %d×%d×%d) …", len(js), len(cs_), len(hs)) + + rows = [] + for J in js: + for C in cs_: + for H in hs: + rows.append((J, C, H)) + grid = np.array(rows) # (M, 3) where columns are J, C, H + + jab_arr = np.stack( + [grid[:, 0], grid[:, 1] * np.cos(np.deg2rad(grid[:, 2])), grid[:, 1] * np.sin(np.deg2rad(grid[:, 2]))], + axis=1, + ) + rgb_arr = jab_batch_to_rgb1(jab_arr) + + # Strict gamut: tol=0.001. Looser tolerances let near-gamut Jab points + # clip into oversaturated sRGB (a "muted red" Jab projects to a vivid + # #F81118 once R clamps to 1.0), which silently breaks the paper-ink + # intent of the chroma corridor. + in_gamut = np.all((rgb_arr >= -0.001) & (rgb_arr <= 1.001), axis=1) + rgb_arr = np.clip(rgb_arr[in_gamut], 0.0, 1.0) + hues_arr = grid[in_gamut, 2] + chromas_arr = grid[in_gamut, 1] + lightnesses_arr = grid[in_gamut, 0] + + # Warm-hue mud filter: warm picks at low J' read as olive/brown, not + # as the colour name they algorithmically represent. The floor is + # sub-band specific because the mud zone shifts with hue: deep reds + # (H ≈ 25-45) still look like reds down to J' ≈ 50, but yellows + # (H ≈ 65-100) need J' ≥ 62 or they read as olive/khaki. The previous + # uniform J' ≥ 58 over the full [30, 100] band killed too many useful + # warm picks in analogous's narrow wedge. + red_orange = (hues_arr >= 30.0) & (hues_arr < 65.0) + yellow_lime = (hues_arr >= 65.0) & (hues_arr <= 100.0) + red_ok = ~red_orange | (lightnesses_arr >= 52.0) + yellow_ok = ~yellow_lime | (lightnesses_arr >= 62.0) + no_mud = red_ok & yellow_ok + rgb_arr = rgb_arr[no_mud] + hues_arr = hues_arr[no_mud] + chromas_arr = chromas_arr[no_mud] + lightnesses_arr = lightnesses_arr[no_mud] + + log.info("kept %d / %d in-gamut, non-muddy candidates", rgb_arr.shape[0], grid.shape[0]) + + jab_per_cond: dict[str, np.ndarray] = {} + for cond in CVD_ORDER: + sim = simulate_cvd(rgb_arr, cond) + jab_per_cond[cond] = to_jab(sim) + + return cls( + rgb1=rgb_arr, + hues_deg=hues_arr, + chromas=chromas_arr, + lightnesses=lightnesses_arr, + jab_per_cond=jab_per_cond, + ) + + +# ----------------------------------------------------------------------------- +# Greedy selection +# ----------------------------------------------------------------------------- + + +def selected_jabs(selected_rgb: list[np.ndarray]) -> dict[str, np.ndarray]: + """Pre-compute Jab for the already-selected colours under each condition. + K is small (≤7) so this is cheap and called every pick.""" + arr = np.array(selected_rgb) + out: dict[str, np.ndarray] = {} + for cond in CVD_ORDER: + out[cond] = to_jab(simulate_cvd(arr, cond)) + return out + + +def score_candidates( + pool: CandidatePool, sel_jabs: dict[str, np.ndarray] +) -> np.ndarray: + """For every candidate, return the min ΔE to any selected colour, taken + across the 4 conditions. Higher is better (more distinct).""" + n_cand = pool.rgb1.shape[0] + best = np.full(n_cand, np.inf) + for cond in CVD_ORDER: + # pool.jab_per_cond[cond] is (N, 3); sel_jabs[cond] is (K, 3) + diff = pool.jab_per_cond[cond][:, None, :] - sel_jabs[cond][None, :, :] + dist = np.linalg.norm(diff, axis=2) # (N, K) + min_per_cand = dist.min(axis=1) + best = np.minimum(best, min_per_cand) + return best + + +def hue_diversity_penalty( + pool: CandidatePool, sel_hues: list[float], target_spread_deg: float +) -> np.ndarray: + """Penalty subtracted from the ΔE score: grows as the candidate hue gets + closer than ``target_spread_deg`` to any already-selected hue. Without + this, greedy max-min lands on three nearly-identical purples for the + balanced strategy because the warm/purple corner of CAM02-UCS is where + "farthest from green" lives for several conditions at once. Weight 1.2 is + a tiebreaker — the hard min-hue-gap mask in ``select_palette`` does the + actual no-clash enforcement; this penalty just prefers maximally-spread + picks among equally-distinct ones. + """ + if not sel_hues: + return np.zeros(pool.rgb1.shape[0]) + sel = np.array(sel_hues) + diff = pool.hues_deg[:, None] - sel[None, :] + circ = np.abs(((diff + 180) % 360) - 180) + min_hue_dist = circ.min(axis=1) + return np.maximum(0.0, target_spread_deg - min_hue_dist) * 1.2 + + +def hue_gap_mask( + pool: CandidatePool, sel_hues: list[float], min_gap_deg: float +) -> np.ndarray: + """Hard mask: True for candidates ≥ min_gap_deg away from every selected + hue on the colour wheel. This is the no-clash guarantee — band fallback + can drop the per-position bands, but the gap mask still keeps every pick + distinguishable from its siblings.""" + if not sel_hues: + return np.ones(pool.rgb1.shape[0], dtype=bool) + sel = np.array(sel_hues) + diff = pool.hues_deg[:, None] - sel[None, :] + circ = np.abs(((diff + 180) % 360) - 180) + return circ.min(axis=1) >= min_gap_deg + + +def select_palette( + strategy: str, + pool: CandidatePool, + n_hues: int = 7, + extra_seeds: tuple[str, ...] = (), + forbidden_hue_bands: tuple[tuple[float, float], ...] = (), + warm_bonus: tuple[float, float, float] | None = None, +) -> list[str]: + """Pick 7 hues for a variant. Greedy max-min ΔE selection under all 4 + CVD conditions, with per-position hue bands and the per-variant chroma + corridor as candidate masks. If no candidate matches the strictest band, + the band half-width is widened in 10° steps until something fits. + + ``extra_seeds`` are pinned hex strings that follow brand-green in the + output. Used by okabe-anchored to keep #D55E00 (vermillion) in the + palette regardless of where greedy max-min would put it. + + v1 additions: + - ``forbidden_hue_bands``: a list of (center_deg, half_width_deg) bands + to EXCLUDE from every position globally. Used by D3 (d-swap-tan) to + ban the tan band [50°, 90°] so a different 7th hue gets picked. + - ``warm_bonus``: (center_deg, half_width_deg, weight) — a soft additive + score bonus for candidates whose hue is within (half-width) of the + center. Used by W (warm-pole) to bias picks toward 30°–80°. + """ + + brand_rgb = hex_to_rgb1(OK_GREEN) + _, _, brand_H = jab_to_lch(to_jab(brand_rgb.reshape(1, 3))[0]) + + bands_per_pos = _strategy_bands(strategy, brand_H, n_hues) + c_min, c_max = PER_VARIANT_C_RANGE[strategy] + chroma_mask = (pool.chromas >= c_min) & (pool.chromas <= c_max) + + # Global hue-exclusion mask (v1 — D3 swap-tan uses this). + if forbidden_hue_bands: + forbidden = np.zeros_like(pool.hues_deg, dtype=bool) + for fc, fhw in forbidden_hue_bands: + d = np.abs((pool.hues_deg - fc + 180) % 360 - 180) + forbidden |= d <= fhw + chroma_mask = chroma_mask & ~forbidden + + # Warm-pole bonus: additive at scoring time. Computed once because hues + # don't change between picks. + if warm_bonus is not None: + wc, whw, ww = warm_bonus + d_warm = np.abs((pool.hues_deg - wc + 180) % 360 - 180) + warm_score_bonus = ww * np.maximum(0.0, 1.0 - d_warm / whw) + else: + warm_score_bonus = None + + # The hue-diversity penalty is a soft tiebreaker; the hard guarantee that + # no two picks land within ``min_gap`` of each other on the colour wheel + # comes from ``hue_gap_mask``. Set to 60% of the target spread — tight + # enough to forbid the old two-azures / two-blues clashes, loose enough to + # keep the candidate set non-empty even in cramped variants. + diversity_target_deg = PER_VARIANT_HUE_SPREAD[strategy] + min_gap_deg = diversity_target_deg * 0.6 + + selected_rgb: list[np.ndarray] = [brand_rgb] + selected_hues: list[float] = [brand_H] + for seed_hex in extra_seeds: + seed_rgb = hex_to_rgb1(seed_hex) + _, _, seed_H = jab_to_lch(to_jab(seed_rgb.reshape(1, 3))[0]) + selected_rgb.append(seed_rgb) + selected_hues.append(seed_H) + start = len(selected_rgb) + for i in range(start, n_hues): + sel_jabs = selected_jabs(selected_rgb) + scores = score_candidates(pool, sel_jabs) + scores = scores - hue_diversity_penalty(pool, selected_hues, diversity_target_deg) + if warm_score_bonus is not None: + scores = scores + warm_score_bonus + + gap_mask = hue_gap_mask(pool, selected_hues, min_gap_deg) + bands = bands_per_pos[i] + mask = _bands_mask(pool.hues_deg, bands) & chroma_mask & gap_mask + # Widen the per-position hue band first (cheap), then loosen the chroma + # corridor, then finally drop the per-position band — but the gap mask + # is never relaxed: a near-clash is worse than an off-corridor pick. + widen = 0 + while not mask.any() and widen < 60: + widen += 10 + widened = [(c, w + widen) for (c, w) in bands] if bands else None + mask = _bands_mask(pool.hues_deg, widened) & chroma_mask & gap_mask + if not mask.any(): + mask = chroma_mask & gap_mask # drop hue rule, keep gap + if not mask.any(): + mask = gap_mask # last-resort: drop chroma too, keep gap + + masked_scores = np.where(mask, scores, -np.inf) + best_idx = int(np.argmax(masked_scores)) + selected_rgb.append(pool.rgb1[best_idx]) + selected_hues.append(float(pool.hues_deg[best_idx])) + + return [rgb1_to_hex(rgb) for rgb in selected_rgb] + + +def _bands_mask(hues: np.ndarray, bands: list[tuple[float, float]] | None) -> np.ndarray: + """Union of (center, half_width) hue bands. None = no constraint.""" + if bands is None: + return np.ones_like(hues, dtype=bool) + mask = np.zeros_like(hues, dtype=bool) + for center, hw in bands: + d = np.abs((hues - center + 180) % 360 - 180) + mask |= d <= hw + return mask + + +def _strategy_bands( + strategy: str, brand_hue: float, n_hues: int +) -> list[list[tuple[float, float]] | None]: + """Return a list of length n_hues; entry i is the acceptable hue bands + for position i. Each entry is a list of (center_deg, half_width_deg) + tuples, or None to mean "no hue constraint". + + For strategies with a fixed hue identity (triadic / split-comp / analogous), + every position has a unique hue target so the palette can't accidentally + end up with three purples; positions 0-2 carry the scheme's primary + anchors and 3-6 fill the gaps between them. + """ + # Default band half-width. Tighter for triadic/split-comp (12°) where the + # primary anchors differ by only 30° between the two strategies — wider + # bands would overlap and let both strategies converge on the same hue + # picks. Analogous keeps 22° because its strategy is intrinsically "stay + # near brand", not "hit a specific anchor". + bw = 22 + + if strategy == "analogous": + # ±90° around brand, spread the 7 picks across the band rather than + # letting greedy max-min cluster them at the band edges. + targets = [ + brand_hue, + (brand_hue + 30) % 360, + (brand_hue - 30) % 360, + (brand_hue + 60) % 360, + (brand_hue - 60) % 360, + (brand_hue + 90) % 360, + (brand_hue - 90) % 360, + ] + return [[(t, bw)] for t in targets][:n_hues] + + if strategy == "triadic": + # 3 primary anchors at positions 0-2 + 3 midpoints at 3-5 (filling the + # gaps between primaries). Position 6 is left unconstrained so the + # algorithm picks the biggest remaining hue gap under the ≥60% diversity + # mask — the old hardcoded brand+30° filler landed at H=196° (cyan) + # which was only 29° from brand-green and 35° from the brand+60° azure. + # Very tight 4° bands at primaries (positions 1-2) so the algorithm + # cannot drift to ≈305° where split-comp and balanced both land — at + # H_STEP=5° this snaps to ±1 grid hue. Filler bands stay at 12°. + targets = [ + brand_hue, + (brand_hue + 120) % 360, + (brand_hue + 240) % 360, + (brand_hue + 60) % 360, + (brand_hue + 180) % 360, + (brand_hue + 300) % 360, + ] + widths = [12, 4, 4, 12, 12, 12] + bands: list[list[tuple[float, float]] | None] = [ + [(t, w)] for t, w in zip(targets, widths) + ] + bands.append(None) # position 6: free pick + return bands[:n_hues] + + if strategy == "split-comp": + # brand + two split anchors (±150°/210°) at positions 0-2; positions + # 3-5 fill three of the four non-anchor quadrants. Position 6 is + # left free for the algorithm to fill the largest remaining hue gap + # (otherwise the hardcoded brand+300° would land at H=106° = lime, + # close to brand-green). Very tight 4° bands at primaries to keep + # them clearly magenta + red rather than the purple+orange-red that + # triadic and balanced also converge on. + targets = [ + brand_hue, + (brand_hue + 150) % 360, + (brand_hue + 210) % 360, + (brand_hue + 90) % 360, + (brand_hue + 270) % 360, + (brand_hue + 60) % 360, + ] + widths = [12, 4, 4, 12, 12, 12] + bands: list[list[tuple[float, float]] | None] = [ + [(t, w)] for t, w in zip(targets, widths) + ] + bands.append(None) # position 6: free pick + return bands[:n_hues] + + if strategy == "balanced": + # No hue rule at any position; the hue-diversity penalty at score time + # is doing all the work. Most "harmonic but max-distinct" results. + return [None for _ in range(n_hues)] + + if strategy == "harmonic": + # Same as balanced (no hue rule) but with a relaxed C corridor — tests + # whether more chroma headroom yields more pleasing hue choices. + return [None for _ in range(n_hues)] + + if strategy == "okabe-anchored": + # Brand-green (pos 0) and vermillion (pos 1) are pinned via + # extra_seeds; positions 2-6 fill freely under chroma + gap masks. + return [None for _ in range(n_hues)] + + if strategy in ("d-expand-8", "warm-pole"): + # v1 D-family expand-8 + warm-pole: no per-position hue rule. The + # strategy signature lives in PER_VARIANT_C_RANGE / PER_VARIANT_HUE_SPREAD + # or in select_palette's extra_seeds / warm_bonus knobs. + return [None for _ in range(n_hues)] + + if strategy == "d-tight-chroma": + # D1 — pin position 1 to the pure-red band [15°, 35°] so the palette + # gets a true red inside the tight chroma corridor (C ∈ [24, 32]) — + # no hard #B71D27 seed needed. Band kept narrow (±10°) so max-min ΔE + # doesn't drift to the orange edge (which happened at ±20°). + bands: list[list[tuple[float, float]] | None] = [None, [(25.0, 10.0)]] + bands.extend([None] * (n_hues - 2)) + return bands[:n_hues] + + if strategy == "tetradic": + # T — 4 hue anchors 90° apart starting at brand-green, then 3 + # mid-quadrant fillers at +45° offsets. Forces explicit + # opposite-axis coverage that balanced max-min sometimes skips. + targets = [ + brand_hue, + (brand_hue + 90) % 360, + (brand_hue + 180) % 360, + (brand_hue + 270) % 360, + (brand_hue + 45) % 360, + (brand_hue + 135) % 360, + (brand_hue + 225) % 360, + ] + widths = [12, 8, 8, 8, 16, 16, 16] + bands: list[list[tuple[float, float]] | None] = [ + [(t, w)] for t, w in zip(targets, widths) + ] + return bands[:n_hues] + + raise ValueError(f"unknown strategy: {strategy}") + + +# ----------------------------------------------------------------------------- +# Reorder so the first 4 are the "most beautiful" subset +# ----------------------------------------------------------------------------- + + +def reorder_first_4( + hexes: list[str], pinned: tuple[int, ...] = () +) -> list[str]: + """Position 0 (brand green) stays. ``pinned`` positions (e.g. (1, 2) for + triadic/split-comp where pos 1-2 are the strategy primaries) also stay + in their slots; the function only searches the remaining indices for the + best 4th member. Otherwise: among {1..6}, find the 3-tuple whose + inclusion in the first-4 maximises the min worst-CVD ΔE inside that + 4-set, subject to a ≥60° pairwise hue-gap constraint (degrades 5° at a + time if the pool can't satisfy it). Positions 5–7 follow in order of + decreasing min-distance-to-the-first-4.""" + n = len(hexes) + assert n >= 7 # v1 allows 8-color palettes (D3 expand-8) — first-4 logic stays the same + + rgb_all = np.array([hex_to_rgb1(hx) for hx in hexes]) + M_worst, _ = worst_cvd_pairwise_delta_e(rgb_all) + hues_all = np.array( + [jab_to_lch(to_jab(rgb_all[i:i + 1])[0])[2] for i in range(n)] + ) + + def triple_meets_hue_gap(triple: tuple[int, ...], gap_deg: float) -> bool: + sub = (0, *triple) + sub_hues = hues_all[list(sub)] + diff = sub_hues[:, None] - sub_hues[None, :] + circ = np.abs(((diff + 180) % 360) - 180) + np.fill_diagonal(circ, 360.0) + return bool(circ.min() >= gap_deg) + + # Strategy-anchor preservation: the pinned indices stay; we only search + # the non-pinned-non-zero positions for the 4th slot. The triple length + # is still 3 (pinned + 4th slot fillers as needed). + pinned_set = set(pinned) + search_pool = [i for i in range(1, n) if i not in pinned_set] + fill_count = 3 - len(pinned) + if fill_count < 0: + raise ValueError(f"too many pinned positions: {pinned}") + + # Try 60° first; if the 7-hue pool can't satisfy that (analogous wedge + # geometry, mostly), step down in 5° increments. Below 30° we'd be back + # to the no-clash threshold from select_palette — no improvement. + best_triple: tuple[int, ...] | None = None + best_score = -1.0 + for gap in (60.0, 55.0, 50.0, 45.0, 40.0, 35.0, 30.0): + for extras in itertools.combinations(search_pool, fill_count): + triple = tuple(pinned) + extras + if not triple_meets_hue_gap(triple, gap): + continue + sub = (0, *triple) + sub_M = M_worst[np.ix_(sub, sub)] + triu = np.triu_indices(len(sub), k=1) + score = float(sub_M[triu].min()) + if score > best_score: + best_score = score + best_triple = triple + if best_triple is not None: + break + + assert best_triple is not None + chosen = [0, *best_triple] + rest = [i for i in range(1, n) if i not in best_triple] + + # Sort remainder by min worst-CVD ΔE to the chosen first-4 (descending) + rest_scores: list[tuple[float, int]] = [] + for i in rest: + col = M_worst[i, chosen] + rest_scores.append((float(col.min()), i)) + rest_scores.sort(reverse=True) + + final_order = chosen + [i for _, i in rest_scores] + return [hexes[i] for i in final_order] + + +def measure_first_4(hexes: list[str]) -> float: + rgb = np.array([hex_to_rgb1(hx) for hx in hexes[:4]]) + M_worst, _ = worst_cvd_pairwise_delta_e(rgb) + triu = np.triu_indices(4, k=1) + return float(M_worst[triu].min()) + + +def measure_all_normal_min(hexes: list[str]) -> float: + rgb = np.array([hex_to_rgb1(hx) for hx in hexes]) + M = pairwise_delta_e(rgb, "normal") + triu = np.triu_indices(len(hexes), k=1) + return float(M[triu].min()) + + +# ----------------------------------------------------------------------------- +# Naming colours by hue band +# ----------------------------------------------------------------------------- + + +HUE_BANDS = [ + (15, "red"), (35, "orange"), (55, "amber"), (70, "yellow"), + (95, "lime"), (135, "green"), (165, "teal"), (200, "cyan"), + (235, "azure"), (255, "blue"), (285, "indigo"), (315, "purple"), + (345, "magenta"), (360, "pink"), +] + + +def hue_to_name(hex_str: str) -> str: + jab = to_jab(hex_to_rgb1(hex_str).reshape(1, 3))[0] + _, _, h = jab_to_lch(jab) + for boundary, name in HUE_BANDS: + if h < boundary: + return name + return HUE_BANDS[-1][1] + + +def names_for_palette(hexes: list[str]) -> list[str]: + return [hue_to_name(hx) for hx in hexes] + + +# ----------------------------------------------------------------------------- +# Continuous colormap construction (perceptually uniform in CAM02-UCS) +# ----------------------------------------------------------------------------- + + +def _interp_two(start: np.ndarray, end: np.ndarray, n: int) -> np.ndarray: + ts = np.linspace(0, 1, n).reshape(-1, 1) + jabs = (1 - ts) * start + ts * end + return np.clip(jab_batch_to_rgb1(jabs), 0, 1) + + +def _interp_three(start: np.ndarray, mid: np.ndarray, end: np.ndarray, n: int) -> np.ndarray: + half = n // 2 + a = _interp_two(start, mid, half) + b = _interp_two(mid, end, n - half) + return np.vstack([a, b]) + + +def _find_closest_hue(palette: list[str], target_h: float) -> tuple[str, float]: + """Return (hex, hue) of the palette member with hue closest to ``target_h`` + on the colour wheel.""" + best_hex, best_h, best_d = palette[0], 0.0, 360.0 + for hx in palette: + _, _, h = jab_to_lch(to_jab(hex_to_rgb1(hx).reshape(1, 3))[0]) + d = abs(((h - target_h + 180) % 360) - 180) + if d < best_d: + best_d, best_hex, best_h = d, hx, h + return best_hex, best_h + + +def build_sequential_cmap(palette: list[str], n: int = 256) -> tuple[np.ndarray, str]: + """Sequential colormap: brand-green → dark version of the palette member + whose hue sits closest to 240° (blue territory). Hue is palette-derived + so the cmap shares identity with the categorical; J' and C are tuned + for monotonic lightness descent (J' 59 → 22) and good chroma headroom + (C 35) regardless of where the source palette member sits in J/C space.""" + brand_jab = to_jab(hex_to_rgb1(OK_GREEN).reshape(1, 3))[0] + cool_hex, cool_h = _find_closest_hue(palette[1:], 240.0) + end = lch_to_jab(22.0, 35.0, cool_h) + cmap = _interp_two(brand_jab, end, n) + label = f"green → dark {hue_to_name(cool_hex)}" + return cmap, label + + +def build_diverging_cmap(palette: list[str], n: int = 256) -> tuple[np.ndarray, str]: + """Diverging colormap: warmest palette member ↔ near-neutral ↔ coolest + palette member. Both poles normalised to J'=45 C=38 for symmetric + visual weight. Mid-grey uses the average of the two hues so the + transition reads as continuous rather than two cmaps stitched.""" + warm_hex, warm_h = _find_closest_hue(palette[1:], 30.0) + cool_hex, cool_h = _find_closest_hue(palette[1:], 240.0) + warm_jab = lch_to_jab(45.0, 38.0, warm_h) + cool_jab = lch_to_jab(45.0, 38.0, cool_h) + mid = lch_to_jab(70.0, 5.0, (warm_h + cool_h) / 2.0) + cmap = _interp_three(warm_jab, mid, cool_jab, n) + label = f"{hue_to_name(warm_hex)} ↔ {hue_to_name(cool_hex)} diverging" + return cmap, label + + +# ----------------------------------------------------------------------------- +# CAM02-UCS color wheel renderer (v1) +# ----------------------------------------------------------------------------- +# +# Adobe-Color / Dracula-Pro style: a continuous hue ring rendered at fixed +# (L=60, C=40), with each palette colour placed at its actual (C, H) +# coordinates. The ring shows the colour-space "neighbourhood" each pick +# lives in; the dots show how the picks geometrically relate to each other. +# +# Two modes: +# - "small" (≈180 px): hue ring + dots only. Slots into index cards. +# - "large" (≈520 px): hue ring + chroma-corridor rings (toggleable) + +# dots + labels + optional overlay of live D dots. + + +WHEEL_BG_L = 60.0 +WHEEL_BG_C = 40.0 +# Use 5° slices = 72 segments. Smooth enough visually, ~30× lighter SVG than +# 1° slices and the slice boundaries are invisible at typical view distance. +WHEEL_SLICE_DEG = 5.0 + + +def _polar_xy(cx: float, cy: float, r: float, theta_deg: float) -> tuple[float, float]: + """Convert polar (r, θ°) into SVG cartesian coords. θ=0 points right + (east); θ grows counter-clockwise like a math wheel. SVG y is inverted + so we negate the sin term.""" + t = np.deg2rad(theta_deg) + return cx + r * float(np.cos(t)), cy - r * float(np.sin(t)) + + +def _arc_slice_path(cx: float, cy: float, r_in: float, r_out: float, a0: float, a1: float) -> str: + """SVG path for a single annular slice from angle a0 to a1 (degrees).""" + x0o, y0o = _polar_xy(cx, cy, r_out, a0) + x1o, y1o = _polar_xy(cx, cy, r_out, a1) + x0i, y0i = _polar_xy(cx, cy, r_in, a0) + x1i, y1i = _polar_xy(cx, cy, r_in, a1) + large_arc = 1 if (a1 - a0) % 360 > 180 else 0 + return ( + f"M{x0o:.2f} {y0o:.2f} " + f"A{r_out:.2f} {r_out:.2f} 0 {large_arc} 0 {x1o:.2f} {y1o:.2f} " + f"L{x1i:.2f} {y1i:.2f} " + f"A{r_in:.2f} {r_in:.2f} 0 {large_arc} 1 {x0i:.2f} {y0i:.2f} " + f"Z" + ) + + + +def _pie_slice_path(cx: float, cy: float, r_out: float, a0: float, a1: float) -> str: + """SVG path for a pie slice from centre to angle a0..a1 (degrees). + Used to draw the filled hue disk — Adobe-Color / Dracula-Pro style — where + the perceived chroma fade toward the centre is applied by a separate + radial-gradient overlay rather than per-slice gradients.""" + x0, y0 = _polar_xy(cx, cy, r_out, a0) + x1, y1 = _polar_xy(cx, cy, r_out, a1) + large_arc = 1 if (a1 - a0) % 360 > 180 else 0 + return ( + f"M{cx:.2f} {cy:.2f} " + f"L{x0:.2f} {y0:.2f} " + f"A{r_out:.2f} {r_out:.2f} 0 {large_arc} 0 {x1:.2f} {y1:.2f} " + f"Z" + ) + + +_WHEEL_PNG_CACHE: dict[int, str] = {} + + +def _wheel_png_b64(disk_size: int) -> str: + """Render the CAM02-UCS colour disk as a base64 PNG, cached by size. + + Every pixel at radius r and angle θ is coloured (L=60, C=40·r/r_max, H=θ) + — perceptually honest chroma fade across the whole disk. Pixel-rendered so + there are no slice seams, no per-position hue jumps, no SVG gradient defs. + Compared to the 72-slice SVG version: smoother, smaller defs, but a slightly + larger embedded image (~12 kB for 164 px disks, ~80 kB for 476 px). + """ + import base64 + import io + from PIL import Image + + if disk_size in _WHEEL_PNG_CACHE: + return _WHEEL_PNG_CACHE[disk_size] + + arr = np.zeros((disk_size, disk_size, 4), dtype=np.uint8) + cx_pix = cy_pix = disk_size / 2.0 + r_out = disk_size / 2.0 + + yy, xx = np.mgrid[0:disk_size, 0:disk_size] + dx = xx + 0.5 - cx_pix + dy = cy_pix - (yy + 0.5) + r = np.hypot(dx, dy) + theta = np.degrees(np.arctan2(dy, dx)) % 360.0 + + inside = r <= r_out + in_idx = np.where(inside) + n_in = len(in_idx[0]) + L = np.full(n_in, WHEEL_BG_L) + C = (r[in_idx] / r_out) * WHEEL_BG_C + H = theta[in_idx] + H_rad = np.deg2rad(H) + jab_arr = np.stack([L, C * np.cos(H_rad), C * np.sin(H_rad)], axis=1) + rgb_arr = jab_batch_to_rgb1(jab_arr) + rgb_arr = np.clip(rgb_arr, 0.0, 1.0) + rgb_u8 = (rgb_arr * 255).astype(np.uint8) + + arr[in_idx[0], in_idx[1], 0:3] = rgb_u8 + arr[in_idx[0], in_idx[1], 3] = 255 + + img = Image.fromarray(arr, mode="RGBA") + buf = io.BytesIO() + img.save(buf, format="PNG", optimize=True) + data_url = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii") + _WHEEL_PNG_CACHE[disk_size] = data_url + return data_url + + +def _wheel_hue_ring_paths(cx: float, cy: float, r_in: float, r_out: float) -> str: + """Pre-rendered 72 annular slices coloured by (L=60, C=40, H) sweep.""" + parts: list[str] = [] + n = int(round(360.0 / WHEEL_SLICE_DEG)) + for k in range(n): + a0 = k * WHEEL_SLICE_DEG + a1 = (k + 1) * WHEEL_SLICE_DEG + h_mid = (a0 + a1) / 2.0 + rgb = jab_to_rgb1(lch_to_jab(WHEEL_BG_L, WHEEL_BG_C, h_mid)) + rgb_clipped = np.clip(rgb, 0.0, 1.0) + hex_str = rgb1_to_hex(rgb_clipped) + d = _arc_slice_path(cx, cy, r_in, r_out, a0, a1) + parts.append(f'') + return "\n".join(parts) + + +def _chroma_corridor_rings(cx: float, cy: float, r_max_wheel: float, c_max_wheel: float, + c_lo: float, c_hi: float, klass: str) -> str: + """Two faint circles marking the per-variant chroma corridor.""" + r_lo = (c_lo / c_max_wheel) * r_max_wheel + r_hi = (c_hi / c_max_wheel) * r_max_wheel + return ( + f'' + f'' + f'' + f'' + ) + + +def _palette_dots(cx: float, cy: float, r_max_wheel: float, c_max_wheel: float, + hexes: list[str], dot_r: float, show_labels: bool, + show_brand_star: bool, klass: str) -> str: + """Place each hex at its (C, H) coords on the wheel.""" + parts: list[str] = [] + for i, hx in enumerate(hexes): + rgb = hex_to_rgb1(hx).reshape(1, 3) + jab = to_jab(rgb)[0] + _, C_val, H_val = jab_to_lch(jab) + # Clamp the radius so dots from off-corridor hexes stay inside the wheel. + r = min(C_val / c_max_wheel, 1.0) * r_max_wheel + x, y = _polar_xy(cx, cy, r, H_val) + if show_brand_star and i == 0: + # 5-point star marker for brand anchor + star_pts = [] + for k in range(10): + ang = 90 + k * 36 + rr = dot_r * (1.7 if k % 2 == 0 else 0.75) + sx, sy = _polar_xy(x, y, rr, ang) + star_pts.append(f"{sx:.2f},{sy:.2f}") + parts.append( + f'' + f'{i + 1}·{hx.upper()} (brand anchor)' + f'' + ) + else: + parts.append( + f'' + f'{i + 1}·{hx.upper()}' + f'' + ) + if show_labels: + # Hex label outside the wheel along the same angle as the dot. + # Position number is implicit from the dot order (matches the + # swatch table above the wheel section). + lx, ly = _polar_xy(cx, cy, r_max_wheel + 14, H_val) + anchor = "middle" + if lx > cx + 4: + anchor = "start" + elif lx < cx - 4: + anchor = "end" + parts.append( + f'{hx.upper()}' + ) + return f'{"".join(parts)}' + + +def render_color_wheel( + hexes: list[str], + *, + size_px: int, + mode: str, # "small" | "large" + chroma_corridor: tuple[float, float] | None = None, + overlay_hexes: list[str] | None = None, + dom_id: str | None = None, +) -> str: + """Render an SVG color wheel placing every hex at its actual (C, H) coords. + + The disk itself is a pre-rendered CAM02-UCS PNG embedded as a base64 data + URL — pixel-correct chroma fade from neutral grey at the centre to L=60, + C=40 at the rim, with no slice seams. Dots, corridor rings, labels and + the overlay live on top as SVG. + + "small" mode: disk + dots only. No labels, no corridor, no overlay. + "large" mode: disk + dots + labels + optional chroma-corridor rings + + optional overlay dots (e.g. live D dots as outlined circles). + Includes inline JS toggles for the corridor and overlay. + """ + cx = cy = size_px / 2.0 + pad = 22 if mode == "large" else 8 + r_out = (size_px / 2.0) - pad + c_max_wheel = 60.0 + r_max_for_dots = r_out + + wheel_id = dom_id or f"wheel-{abs(hash(tuple(hexes))) & 0xFFFFFF:06x}" + + # Disk = embedded PNG; render at a tight pixel size matching r_out so the + # rendered pixels = on-screen pixels at typical view sizes. The PNG cache + # keeps a single image per disk_size across the whole HTML output. + disk_size = int(round(2 * r_out)) + wheel_png = _wheel_png_b64(disk_size) + disk = ( + f'' + ) + dot_r = 7.5 if mode == "large" else 4.5 + + corridor = "" + if chroma_corridor is not None and mode == "large": + c_lo, c_hi = chroma_corridor + corridor = _chroma_corridor_rings(cx, cy, r_max_for_dots, c_max_wheel, + c_lo, c_hi, klass="wheel-corridor") + + # Cardinal-angle text labels only (no radial tick lines — they visually + # carved the disk into segments without adding information). + ticks = "" + if mode == "large": + tick_parts: list[str] = [] + for h_deg in (0, 90, 180, 270): + tx, ty = _polar_xy(cx, cy, r_out + 32, h_deg) + tick_parts.append( + f'{h_deg}°' + ) + ticks = "".join(tick_parts) + + dots = _palette_dots(cx, cy, r_max_for_dots, c_max_wheel, hexes, dot_r, + show_labels=(mode == "large"), + show_brand_star=True, klass="wheel-dots") + + overlay = "" + if overlay_hexes is not None and mode == "large": + overlay_parts: list[str] = [] + for i, hx in enumerate(overlay_hexes): + rgb = hex_to_rgb1(hx).reshape(1, 3) + jab = to_jab(rgb)[0] + _, C_val, H_val = jab_to_lch(jab) + r = min(C_val / c_max_wheel, 1.0) * r_max_for_dots + x, y = _polar_xy(cx, cy, r, H_val) + # Each overlay marker = thick coloured ring with a high-contrast + # ink-coloured halo behind it (so it stays visible no matter which + # part of the disk it lands on). + outer_r = dot_r + 8 + overlay_parts.append( + f'' + ) + overlay_parts.append( + f'' + ) + overlay = f'' + + # overflow="visible" so the hex labels can extend past the SVG viewBox + # without getting clipped — they sit ≈14px outside r_out on each side. + wheel_svg = ( + f'' + f'{disk}{ticks}{corridor}{overlay}{dots}' + f'' + ) + + if mode != "large": + return wheel_svg + + toggles = [] + if chroma_corridor is not None: + toggles.append( + f'' + ) + if overlay_hexes is not None: + toggles.append( + f'' + ) + toggles_html = "" + if toggles: + toggles_html = f'
{" ".join(toggles)}
' + + return f'
{wheel_svg}{toggles_html}
' + + +WHEEL_CSS = """ +.wheel-frame { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} +.color-wheel { display: block; } +.color-wheel-large { max-width: 100%; height: auto; } +.color-wheel text { user-select: none; pointer-events: none; } +.wheel-toggles { + display: flex; + gap: 14px; + flex-wrap: wrap; + font-size: 11px; + color: var(--ink-soft); +} +.wheel-toggles label { + display: inline-flex; + align-items: center; + gap: 5px; + cursor: pointer; +} +.wheel-toggles input { accent-color: var(--ok-green); } +""" + + +WHEEL_JS = """ +document.querySelectorAll('[data-wheel-toggle]').forEach(function (cb) { + cb.addEventListener('change', function () { + var kind = cb.getAttribute('data-wheel-toggle'); + var wheelId = cb.getAttribute('data-wheel'); + var wheel = document.getElementById(wheelId); + if (!wheel) return; + var cls = kind === 'corridor' ? 'wheel-corridor' : 'wheel-overlay'; + var target = wheel.querySelector('.' + cls); + if (!target) return; + target.style.display = cb.checked ? '' : 'none'; + }); +}); +""" + + +# ----------------------------------------------------------------------------- +# Variant definitions +# ----------------------------------------------------------------------------- + + +@dataclass +class Variant: + key: str # "A".."E", "D1".. etc. + slug: str # filename-safe + title: str # short name + strategy: str # algorithm identifier + one_liner: str # human description shown on each page + index + n_hues: int = 7 # override for non-standard palette length (e.g. D3 = 8) + + +VARIANTS = [ + Variant( + "D1", "tight-chroma", "d-tight-chroma", + "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", + ), + Variant( + "D3", "expand-8", "expand-8", + "d-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", + n_hues=8, + ), + Variant( + "T", "tetradic", "tetradic", + "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", + ), + Variant( + "W", "warm-pole", "warm-pole", + "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", + ), +] + + +# ----------------------------------------------------------------------------- +# Per-variant HTML rendering +# ----------------------------------------------------------------------------- + + +def render_variant_page( + variant: Variant, + hues: list[str], + seq_rgb: np.ndarray, + seq_label: str, + div_rgb: np.ndarray, + div_label: str, + *, + is_baseline: bool = False, +) -> str: + names = names_for_palette(hues) + full_hexes = [*hues, NEUTRAL_LIGHT, NEUTRAL_DARK] + full_labels = [*names, "neutral·light", "neutral·dark"] + + c_min_v, c_max_v = PER_VARIANT_C_RANGE[variant.strategy] + + first_4_score = measure_first_4(hues) + normal_min = measure_all_normal_min(hues) + + swatches = render_swatch_table(full_hexes, full_labels) + full_rgb = np.array([hex_to_rgb1(hx) for hx in full_hexes]) + matrix = render_matrix_block(full_rgb, full_labels) + sample_charts = render_sample_charts(hues, n_series=4) + first_n = render_first_n_summary(hues, names) + seq_row = render_colormap_row(seq_label, samples_rgb=seq_rgb) + seq_demo = render_cmap_demo(seq_rgb, label=f"sequential · {seq_label}") + div_row = render_colormap_row(div_label, samples_rgb=div_rgb) + div_demo = render_cmap_demo(div_rgb, label=f"diverging · {div_label}") + cmap_block = f"{seq_row}\n{seq_demo}\n{div_row}\n{div_demo}" + hero_pair = render_hero_mockup_pair(hues[0]) + + # v1 — baseline is live D, not Okabe-Ito. + baseline_4 = measure_first_4(ANYPLOT_D_PALETTE) + if is_baseline: + score_html = ( + f'first-4 worst-CVD min ΔE{first_4_score:.2f} ' + f'(this is the bar)' + ) + else: + delta_vs_baseline = first_4_score - baseline_4 + delta_sign = "+" if delta_vs_baseline >= 0 else "" + delta_class = 'delta-pos' if delta_vs_baseline >= 0 else 'delta-neg' + score_html = ( + f'first-4 worst-CVD min ΔE{first_4_score:.2f} ' + f'({delta_sign}{delta_vs_baseline:.2f} vs live D {baseline_4:.2f})' + ) + + legend = render_legend() + + # Color wheel: large interactive, with chroma-corridor toggle and (for + # non-baseline pages) an overlay of live D dots so you can compare geometry. + wheel_overlay = None if is_baseline else ANYPLOT_D_PALETTE + wheel = render_color_wheel( + hues, size_px=520, mode="large", + chroma_corridor=(c_min_v, c_max_v), + overlay_hexes=wheel_overlay, + dom_id=f"wheel-{variant.key.lower()}", + ) + + nav_links = [ + f'★ D · baseline' + ] + for v in VARIANTS: + nav_links.append( + f'{v.key} · {v.title}' + ) + nav = "".join(nav_links) + + page_title = "D · baseline (live anyplot palette)" if is_baseline else f"variant {variant.key}. {variant.title}" + strategy_text = ( + "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." + if is_baseline + else variant.one_liner + "." + ) + + return f""" + + + + +{page_title} — anyplot palette v1 + + + +
+

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. + PINNED: dict[str, tuple[int, ...]] = { + "tetradic": (1, 2, 3), + } + + # 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), + ) + 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()) From ef6a6589003ac8819938d63af0c4b9f56ce2781e Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sun, 24 May 2026 23:26:05 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat(palette):=20add=20D1-8=20variant=20?= =?UTF-8?q?=E2=80=94=20D1=20tight-chroma=20expanded=20to=208=20hues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1-8 mirrors D3's expand-8 approach for the tight-chroma corridor: D1's 7 picks leave a 75° purple→red back-gap, the 8th slot is greedy-picked there for a matte rosé (#954477) that bridges purple and red while staying inside C ∈ [24, 32]. Also introduces ``reorder_pure_cvd_greedy`` (opt-in via USE_PURE_CVD_REORDER) for D1-8 — the original wheel-gap-first heuristic in ``reorder_first_4`` was picking a 60°-valid but CVD-weak first-4 like {green, blue, tan, mauve} whenever the 8th hue opened new gap-valid quadruples. Pure-CVD greedy keeps the worst-pair curve high: n=4 lifts from 10.70 → 17.44 (now beats D3's 15.61 at the same n). Co-Authored-By: Claude Opus 4.7 --- .../palette-variants-v1/D-baseline.html | 2 +- .../D1-8-tight-chroma-8.html | 760 ++++++++++++++++++ .../palette-variants-v1/D1-tight-chroma.html | 2 +- .../palette-variants-v1/D3-expand-8.html | 2 +- .../palette-variants-v1/T-tetradic.html | 2 +- .../palette-variants-v1/W-warm-pole.html | 2 +- .../palette-variants-v1/compare.html | 30 +- docs/reference/palette-variants-v1/index.html | 27 +- scripts/palette-variants-v1.py | 75 +- 9 files changed, 888 insertions(+), 14 deletions(-) create mode 100644 docs/reference/palette-variants-v1/D1-8-tight-chroma-8.html diff --git a/docs/reference/palette-variants-v1/D-baseline.html b/docs/reference/palette-variants-v1/D-baseline.html index 7b174f7d33..cd669e34dd 100644 --- a/docs/reference/palette-variants-v1/D-baseline.html +++ b/docs/reference/palette-variants-v1/D-baseline.html @@ -590,7 +590,7 @@

any.plot() — D · baseline (live anyplot palette)
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 index 4d17207e72..4037144bb3 100644 --- a/docs/reference/palette-variants-v1/D1-tight-chroma.html +++ b/docs/reference/palette-variants-v1/D1-tight-chroma.html @@ -590,7 +590,7 @@

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

diff --git a/docs/reference/palette-variants-v1/D3-expand-8.html b/docs/reference/palette-variants-v1/D3-expand-8.html index da1c21ea59..f09ccba297 100644 --- a/docs/reference/palette-variants-v1/D3-expand-8.html +++ b/docs/reference/palette-variants-v1/D3-expand-8.html @@ -590,7 +590,7 @@

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

diff --git a/docs/reference/palette-variants-v1/T-tetradic.html b/docs/reference/palette-variants-v1/T-tetradic.html index 4e88a085b1..d9a4ece705 100644 --- a/docs/reference/palette-variants-v1/T-tetradic.html +++ b/docs/reference/palette-variants-v1/T-tetradic.html @@ -590,7 +590,7 @@

any.plot() — variant T. tetradic

diff --git a/docs/reference/palette-variants-v1/W-warm-pole.html b/docs/reference/palette-variants-v1/W-warm-pole.html index f6aa112d1a..2d507e46d9 100644 --- a/docs/reference/palette-variants-v1/W-warm-pole.html +++ b/docs/reference/palette-variants-v1/W-warm-pole.html @@ -590,7 +590,7 @@

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

diff --git a/docs/reference/palette-variants-v1/compare.html b/docs/reference/palette-variants-v1/compare.html index c8fc92244c..5f50bbd7fb 100644 --- a/docs/reference/palette-variants-v1/compare.html +++ b/docs/reference/palette-variants-v1/compare.html @@ -608,7 +608,7 @@

any.plot() — palette variants v1 · compare

grid compare ★ D · baseline - D1 · d-tight-chromaD3 · expand-8T · tetradicW · warm-pole + D1 · d-tight-chromaD1-8 · d-tight-chroma-8D3 · expand-8T · tetradicW · warm-pole

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.

@@ -669,6 +669,34 @@

d-tight-chroma

+
+
+
+ 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) +
+
+
+
diff --git a/docs/reference/palette-variants-v1/index.html b/docs/reference/palette-variants-v1/index.html index ae7d7e093d..7f0c3e6836 100644 --- a/docs/reference/palette-variants-v1/index.html +++ b/docs/reference/palette-variants-v1/index.html @@ -698,7 +698,7 @@

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

grid compare ★ D · baseline - D1 · d-tight-chromaD3 · expand-8T · tetradicW · warm-pole + D1 · d-tight-chromaD1-8 · d-tight-chroma-8D3 · expand-8T · tetradicW · warm-pole
@@ -727,7 +727,7 @@

live D on the color wheel

chroma/hue cost of each refinement.

- +
@@ -781,6 +781,29 @@

d-tight-chroma

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 diff --git a/scripts/palette-variants-v1.py b/scripts/palette-variants-v1.py index e7237a8422..155288833c 100644 --- a/scripts/palette-variants-v1.py +++ b/scripts/palette-variants-v1.py @@ -140,6 +140,7 @@ "okabe-anchored": (22.0, 42.0), # v1 strategies "d-tight-chroma": (24.0, 32.0), # D1 — narrowest paper-ink corridor; cleanest co-existence prediction + "d-tight-chroma-8": (24.0, 32.0), # D1-8 — same tight corridor as D1, expanded to 8 slots so the 75° purple→red back-gap gets a matte rosé "d-expand-8": (22.0, 36.0), # D3 — same C as live D; an 8th slot is greedy-picked in the largest remaining hue gap "tetradic": (24.0, 38.0), # T — slight C bump so the 4 forced anchors don't all land at the muted floor "warm-pole": (22.0, 36.0), # W — same C as live D; the warm-bonus does the work @@ -161,6 +162,7 @@ "okabe-anchored": 45.0, # v1 strategies "d-tight-chroma": 50.0, # same as live D — only chroma differs + "d-tight-chroma-8": 50.0, # same as D1; the 8th pick fills the back-gap naturally "d-expand-8": 50.0, # same as live D; the 8th pick fills naturally where the wheel gap is biggest "tetradic": 50.0, "warm-pole": 50.0, # warm bonus is additive at scoring; spacing target unchanged @@ -556,11 +558,13 @@ def _strategy_bands( # or in select_palette's extra_seeds / warm_bonus knobs. return [None for _ in range(n_hues)] - if strategy == "d-tight-chroma": - # D1 — pin position 1 to the pure-red band [15°, 35°] so the palette - # gets a true red inside the tight chroma corridor (C ∈ [24, 32]) — - # no hard #B71D27 seed needed. Band kept narrow (±10°) so max-min ΔE - # doesn't drift to the orange edge (which happened at ±20°). + if strategy in ("d-tight-chroma", "d-tight-chroma-8"): + # D1 / D1-8 — pin position 1 to the pure-red band [15°, 35°] so the + # palette gets a true red inside the tight chroma corridor (C ∈ [24, 32]) + # — no hard #B71D27 seed needed. Band kept narrow (±10°) so max-min ΔE + # doesn't drift to the orange edge (which happened at ±20°). D1-8 just + # extends n_hues to 8; the 8th slot stays unconstrained because greedy + # max-min naturally lands in the 75° purple→red back-gap (≈ H340° rosé). bands: list[list[tuple[float, float]] | None] = [None, [(25.0, 10.0)]] bands.extend([None] * (n_hues - 2)) return bands[:n_hues] @@ -664,6 +668,46 @@ def triple_meets_hue_gap(triple: tuple[int, ...], gap_deg: float) -> bool: return [hexes[i] for i in final_order] + +def reorder_pure_cvd_greedy( + hexes: list[str], pinned: tuple[int, ...] = () +) -> list[str]: + """Pure CVD max-min greedy reorder — no hue-gap heuristic. + + Position 0 (brand green) stays, and any ``pinned`` positions stay in their + original slots in the order they were passed. The remaining positions are + appended one at a time by picking the not-yet-placed hue whose minimum + worst-CVD ΔE to the already-placed set is the highest. + + Guarantees the worst pairwise ΔE-under-CVD curve degrades as slowly as + possible as `n` grows from 2 upward — at the cost of giving up the + "first-4 spans the wheel" property that ``reorder_first_4`` enforces. + Used by D1-8, where the wheel-gap-first heuristic was picking a 60°-valid + but CVD-weak first-4 like {green, blue, tan, mauve} whenever the 8th hue + opened new gap-valid quadruples. + """ + n = len(hexes) + rgb_all = np.array([hex_to_rgb1(hx) for hx in hexes]) + M_worst, _ = worst_cvd_pairwise_delta_e(rgb_all) + + pinned_set = set(pinned) + chosen: list[int] = [0, *pinned] + remaining = [i for i in range(1, n) if i not in pinned_set] + + while remaining: + best_idx = remaining[0] + best_score = float(M_worst[best_idx, chosen].min()) + for i in remaining[1:]: + score = float(M_worst[i, chosen].min()) + if score > best_score: + best_score = score + best_idx = i + chosen.append(best_idx) + remaining.remove(best_idx) + + return [hexes[i] for i in chosen] + + def measure_first_4(hexes: list[str]) -> float: rgb = np.array([hex_to_rgb1(hx) for hx in hexes[:4]]) M_worst, _ = worst_cvd_pairwise_delta_e(rgb) @@ -1140,6 +1184,12 @@ class Variant: "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", ), + Variant( + "D1-8", "tight-chroma-8", "d-tight-chroma-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", + n_hues=8, + ), Variant( "D3", "expand-8", "expand-8", "d-expand-8", @@ -1989,9 +2039,19 @@ def main() -> int: # 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], ...]] = { @@ -2032,7 +2092,10 @@ def main() -> int: forbidden_hue_bands=FORBIDDEN_BANDS.get(variant.strategy, ()), warm_bonus=WARM_BONUS.get(variant.strategy), ) - hues = reorder_first_4(hues, pinned=PINNED.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) From 5b47e6ebaba3912c2d29b933faf18ef02b5fb4e3 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Mon, 25 May 2026 00:26:14 +0200 Subject: [PATCH 4/5] =?UTF-8?q?feat(palette):=20v2=20head-to-head=20viewer?= =?UTF-8?q?=20=E2=80=94=20vivid-8=20vs=20muted-8=20across=204=20sortings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit palette-variants-v2 narrows the field from v1's 5 candidates to the two real contenders (vivid-8 = D3, muted-8 = D1-8) and adds tooling to compare slot orderings side-by-side: - hero with both color wheels (chroma corridor always visible) and hue-sorted strips for direct hue-by-hue comparison between palettes - sticky TOC linking the 4 sortings: pure-CVD greedy, wheel-gap-first, hue-order (rainbow), every-other-hue (interleaved) - per-sorting scorecard with per-n winner strips for CVD + normal vision (green = vivid, blue = muted, grey = tie) - per-cell chart stack: light theme block + dark theme block, each with lines, bars (all 8), pie (first 4), stocks (first 4), edge-cluster overlap scatter (centre mix) - column-major pair grid so strips/tables align between palettes even when intros wrap to different line counts - per-n worst-pair ΔE table with normal-vision row + CVD-min row Reuses v1 utilities via importlib (hyphen in filename). Co-Authored-By: Claude Opus 4.7 --- docs/reference/palette-variants-v2/index.html | 619 ++++++++++++ scripts/palette-variants-v2.py | 916 ++++++++++++++++++ 2 files changed, 1535 insertions(+) create mode 100644 docs/reference/palette-variants-v2/index.html create mode 100644 scripts/palette-variants-v2.py 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/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()) From 51016cdb2ed64c70224e483912876ee899cfcd61 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Mon, 25 May 2026 00:50:25 +0200 Subject: [PATCH 5/5] feat(palette): add expert review synthesis for muted-8 - Documented unanimous expert verdict favoring muted-8 over vivid-8 - Highlighted recurring themes and critiques from five independent reviews - Recommended next steps for palette optimization and semantic picking --- .../palette-variants-v2/expert-reviews.md | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 docs/reference/palette-variants-v2/expert-reviews.md 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.