| --- |
| const rootId = `palettes-${Math.random().toString(36).slice(2)}`; |
| --- |
|
|
| <div class="palettes" id={rootId} style="width:100%; margin: 10px 0;"> |
| <style is:global> |
| .palettes { |
| box-sizing: border-box; |
| } |
| .palettes .palettes__grid { |
| display: grid; |
| grid-template-columns: 1fr; |
| gap: 12px; |
| max-width: 100%; |
| } |
| .palettes .palette-card { |
| position: relative; |
| display: grid; |
| grid-template-columns: 1fr minmax(0, 220px); |
| align-items: stretch; |
| gap: 12px; |
| border: 1px solid var(--border-color); |
| border-radius: 10px; |
| background: var(--surface-bg); |
| padding: 12px; |
| transition: |
| box-shadow 0.18s ease, |
| transform 0.18s ease, |
| border-color 0.18s ease; |
| min-height: 60px; |
| } |
| .palettes .palette-card__preview { |
| width: 48px; |
| height: 48px; |
| border-radius: 999px; |
| flex: 0 0 auto; |
| background-size: cover; |
| background-position: center; |
| } |
| .palettes .palette-card__copy { |
| position: absolute; |
| top: 50%; |
| left: 100%; |
| transform: translateY(-50%); |
| z-index: 3; |
| border-left: none; |
| border-top-left-radius: 0; |
| border-bottom-left-radius: 0; |
| } |
| .palettes .palette-card__copy svg { |
| width: 18px; |
| height: 18px; |
| fill: currentColor; |
| display: block; |
| color: inherit; |
| } |
| .palettes .palette-card__swatches { |
| display: grid; |
| grid-template-columns: repeat(6, minmax(0, 1fr)); |
| grid-auto-rows: 1fr; |
| gap: 2px; |
| margin: 0; |
| min-height: 60px; |
| } |
| .palettes .palette-card__swatches .sw { |
| width: 100%; |
| min-width: 0; |
| height: auto; |
| border-radius: 0; |
| border: 1px solid var(--border-color); |
| } |
| .palettes .palette-card__swatches .sw:first-child { |
| border-top-left-radius: 8px; |
| border-bottom-left-radius: 8px; |
| } |
| .palettes .palette-card__swatches .sw:last-child { |
| border-top-right-radius: 8px; |
| border-bottom-right-radius: 8px; |
| } |
| .palettes .palette-card__content { |
| display: flex; |
| flex-direction: row; |
| align-items: center; |
| justify-content: flex-start; |
| gap: 12px; |
| min-width: 0; |
| padding-right: 12px; |
| } |
| .palettes .palette-card__preview { |
| width: 48px; |
| height: 48px; |
| border-radius: 999px; |
| position: relative; |
| flex: 0 0 auto; |
| overflow: hidden; |
| } |
| .palettes .palette-card__preview .dot { |
| position: absolute; |
| width: 4px; |
| height: 4px; |
| background: #fff; |
| border-radius: 999px; |
| box-shadow: 0 0 6px rgba(0, 0, 0, 1); |
| } |
| .palettes .palette-card__preview .donut-hole { |
| position: absolute; |
| left: 50%; |
| top: 50%; |
| transform: translate(-50%, -50%); |
| width: 24px; |
| height: 24px; |
| border-radius: 999px; |
| background: var(--surface-bg); |
| box-shadow: 0 0 0 1px var(--border-color) inset; |
| } |
| |
| .palettes .palette-card__content__info { |
| display: flex; |
| flex-direction: column; |
| } |
| .palettes .palette-card__title { |
| text-align: left; |
| font-weight: 800; |
| font-size: 15px; |
| } |
| .palettes .palette-card__desc { |
| text-align: left; |
| color: var(--muted-color); |
| line-height: 1.5; |
| font-size: 12px; |
| } |
| |
| .palettes .palettes__select { |
| width: 100%; |
| max-width: 100%; |
| border: 1px solid var(--border-color); |
| background: var(--surface-bg); |
| color: var(--text-color); |
| padding: 8px 10px; |
| border-radius: 8px; |
| } |
| .palettes .sr-only { |
| position: absolute; |
| width: 1px; |
| height: 1px; |
| padding: 0; |
| margin: -1px; |
| overflow: hidden; |
| clip: rect(0, 0, 1px, 1px); |
| white-space: nowrap; |
| border: 0; |
| } |
| .palettes .palettes__controls { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 16px; |
| align-items: center; |
| margin: 8px 0 14px; |
| } |
| .palettes .palettes__field { |
| display: flex; |
| flex-direction: column; |
| gap: 6px; |
| min-width: 0; |
| flex: 1 1 280px; |
| max-width: 100%; |
| } |
| .palettes .palettes__label { |
| font-size: 12px; |
| color: var(--muted-color); |
| font-weight: 800; |
| } |
| .palettes .palettes__label-row { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 10px; |
| } |
| .palettes .ghost-badge { |
| font-size: 11px; |
| padding: 1px 6px; |
| border-radius: 999px; |
| border: 1px solid var(--border-color); |
| color: var(--muted-color); |
| background: transparent; |
| font-variant-numeric: tabular-nums; |
| } |
| .palettes .palettes__count { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| max-width: 100%; |
| } |
| .palettes .palettes__count input[type="range"] { |
| width: 100%; |
| } |
| .palettes .palettes__count output { |
| min-width: 28px; |
| text-align: center; |
| font-variant-numeric: tabular-nums; |
| font-size: 12px; |
| color: var(--muted-color); |
| } |
| .palettes input[type="range"] { |
| -webkit-appearance: none; |
| appearance: none; |
| height: 24px; |
| background: transparent; |
| cursor: pointer; |
| accent-color: var(--primary-color); |
| } |
| .palettes input[type="range"]:focus { |
| outline: none; |
| } |
| .palettes input[type="range"]::-webkit-slider-runnable-track { |
| height: 6px; |
| background: var(--border-color); |
| border-radius: 999px; |
| } |
| .palettes input[type="range"]::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| appearance: none; |
| margin-top: -6px; |
| width: 18px; |
| height: 18px; |
| background: var(--primary-color); |
| border: 2px solid var(--surface-bg); |
| border-radius: 50%; |
| } |
| .palettes input[type="range"]::-moz-range-track { |
| height: 6px; |
| background: var(--border-color); |
| border: none; |
| border-radius: 999px; |
| } |
| .palettes input[type="range"]::-moz-range-progress { |
| height: 6px; |
| background: var(--primary-color); |
| border-radius: 999px; |
| } |
| .palettes input[type="range"]::-moz-range-thumb { |
| width: 18px; |
| height: 18px; |
| background: var(--primary-color); |
| border: 2px solid var(--surface-bg); |
| border-radius: 50%; |
| } |
| html.cb-grayscale, |
| body.cb-grayscale { |
| filter: grayscale(1) !important; |
| } |
| html.cb-protanopia, |
| body.cb-protanopia { |
| filter: url(#cb-protanopia) !important; |
| } |
| html.cb-deuteranopia, |
| body.cb-deuteranopia { |
| filter: url(#cb-deuteranopia) !important; |
| } |
| html.cb-tritanopia, |
| body.cb-tritanopia { |
| filter: url(#cb-tritanopia) !important; |
| } |
| html.cb-achromatopsia, |
| body.cb-achromatopsia { |
| filter: url(#cb-achromatopsia) !important; |
| } |
| @media (max-width: 1100px) { |
| .palettes .palette-card { |
| grid-template-columns: 1fr; |
| align-items: stretch; |
| gap: 10px; |
| } |
| .palettes .palette-card__swatches { |
| grid-template-columns: repeat(6, minmax(0, 1fr)); |
| } |
| .palettes .palette-card__content { |
| border-right: none; |
| padding-right: 0; |
| } |
| .palettes .palette-card__copy { |
| display: none; |
| } |
| } |
| </style> |
| <div class="palettes__controls"> |
| <div class="palettes__field"> |
| <label class="palettes__label" for="cb-select" |
| >Color vision simulation</label |
| > |
| <select id="cb-select" class="palettes__select"> |
| <option value="none" |
| >Normal color vision — typical for most people</option |
| > |
| <option value="achromatopsia">Achromatopsia — no color at all</option> |
| <option value="protanopia">Protanopia — reduced/absent reds</option> |
| <option value="deuteranopia" |
| >Deuteranopia — reduced/absent greens</option |
| > |
| <option value="tritanopia">Tritanopia — reduced/absent blues</option> |
| </select> |
| </div> |
| <div class="palettes__field"> |
| <div class="palettes__label-row"> |
| <label class="palettes__label" for="color-count">Number of colors</label |
| > |
| <output id="color-count-out" for="color-count" class="ghost-badge" |
| >8</output |
| > |
| </div> |
| <div class="palettes__count"> |
| <input |
| id="color-count" |
| type="range" |
| min="6" |
| max="10" |
| step="1" |
| value="8" |
| aria-label="Number of colors" |
| /> |
| </div> |
| </div> |
| </div> |
| <div class="palettes__grid"></div> |
| <div class="palettes__simu" role="group" aria-labelledby="cb-sim-title"> |
| <svg |
| aria-hidden="true" |
| focusable="false" |
| width="0" |
| height="0" |
| style="position:absolute; left:-9999px; overflow:hidden;" |
| > |
| <defs> |
| <filter id="cb-protanopia" |
| ><feColorMatrix |
| type="matrix" |
| values="0.567 0.433 0 0 0 0.558 0.442 0 0 0 0 0.242 0.758 0 0 0 0 0 1 0" |
| ></feColorMatrix></filter |
| > |
| <filter id="cb-deuteranopia" |
| ><feColorMatrix |
| type="matrix" |
| values="0.625 0.375 0 0 0 0.7 0.3 0 0 0 0 0.3 0.7 0 0 0 0 0 1 0" |
| ></feColorMatrix></filter |
| > |
| <filter id="cb-tritanopia" |
| ><feColorMatrix |
| type="matrix" |
| values="0.95 0.05 0 0 0 0 0.433 0.567 0 0 0 0.475 0.525 0 0 0 0 0 1 0" |
| ></feColorMatrix></filter |
| > |
| <filter id="cb-achromatopsia" |
| ><feColorMatrix |
| type="matrix" |
| values="0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0 0 0 1 0" |
| ></feColorMatrix></filter |
| > |
| </defs> |
| </svg> |
| </div> |
| </div> |
| <script type="module" is:inline> |
| import "/scripts/color-palettes.js"; |
| const ROOT_ID = "{rootId}"; |
| (() => { |
| const cards = [ |
| { |
| key: "categorical", |
| title: "Categorical", |
| desc: "For <strong>non‑numeric categories</strong>; <strong>visually distinct</strong> colors.", |
| }, |
| { |
| key: "sequential", |
| title: "Sequential", |
| desc: "For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.", |
| }, |
| { |
| key: "diverging", |
| title: "Diverging", |
| desc: "For numeric scales with negative and positive; Opposing extremes with smooth contrast around a neutral midpoint.", |
| }, |
| ]; |
| const getPaletteColors = (key, count) => { |
| const total = Number(count) || 6; |
| if ( |
| window.ColorPalettes && |
| typeof window.ColorPalettes.getColors === "function" |
| ) { |
| return window.ColorPalettes.getColors(key, total) || []; |
| } |
| return []; |
| }; |
| const render = () => { |
| const root = |
| document.getElementById(ROOT_ID) || document.querySelector(".palettes"); |
| if (!root) return; |
| const grid = root.querySelector(".palettes__grid"); |
| if (!grid) return; |
| const input = document.getElementById("color-count"); |
| const total = input ? Number(input.value) || 6 : 6; |
| const html = cards |
| .map((c) => { |
| const colors = getPaletteColors(c.key, total); |
| const swatches = colors |
| .map( |
| (col) => `<div class=\"sw\" style=\"background:${col}\"></div>`, |
| ) |
| .join(""); |
| const baseHex = |
| window.ColorPalettes && |
| typeof window.ColorPalettes.getPrimary === "function" |
| ? window.ColorPalettes.getPrimary() |
| : colors[0] || "#FF0000"; |
| const hueDeg = (() => { |
| try { |
| const s = baseHex.replace("#", ""); |
| const v = |
| s.length === 3 |
| ? s |
| .split("") |
| .map((ch) => ch + ch) |
| .join("") |
| : s; |
| const r = parseInt(v.slice(0, 2), 16) / 255, |
| g = parseInt(v.slice(2, 4), 16) / 255, |
| b = parseInt(v.slice(4, 6), 16) / 255; |
| const M = Math.max(r, g, b), |
| m = Math.min(r, g, b), |
| d = M - m; |
| if (d === 0) return 0; |
| let h = 0; |
| if (M === r) h = ((g - b) / d) % 6; |
| else if (M === g) h = (b - r) / d + 2; |
| else h = (r - g) / d + 4; |
| h *= 60; |
| if (h < 0) h += 360; |
| return h; |
| } catch { |
| return 0; |
| } |
| })(); |
| const gradient = |
| c.key === "categorical" |
| ? (() => { |
| const steps = 60; |
| const wheel = Array.from( |
| { length: steps }, |
| (_, i) => |
| `hsl(${Math.round((i / steps) * 360)}, 100%, 50%)`, |
| ).join(", "); |
| return `conic-gradient(${wheel})`; |
| })() |
| : colors.length |
| ? `linear-gradient(90deg, ${colors.join(", ")})` |
| : `linear-gradient(90deg, var(--border-color), var(--border-color))`; |
| const previewInner = (() => { |
| if (c.key !== "categorical" || !colors.length) return ""; |
| const ring = 18; |
| const cx = 24; |
| const cy = 24; |
| const offset = (hueDeg / 360) * 2 * Math.PI; |
| return colors |
| .map((col, i) => { |
| const angle = offset + (i / colors.length) * 2 * Math.PI; |
| const x = cx + ring * Math.cos(angle); |
| const y = cy + ring * Math.sin(angle); |
| return `<span class=\"dot\" style=\"left:${x - 2}px; top:${y - 2}px\"></span>`; |
| }) |
| .join(""); |
| })(); |
| const donutHole = |
| c.key === "categorical" ? '<span class="donut-hole"></span>' : ""; |
| return ` |
| <div class="palette-card" data-colors="${colors.join(",")}"> |
| <div class="palette-card__content"> |
| <div class=\"palette-card__preview\" aria-hidden=\"true\" style=\"background:${gradient}\">${previewInner}${donutHole}</div> |
| <div class="palette-card__content__info"> |
| <div class="palette-card__title">${c.title}</div> |
| <div class="palette-card__desc">${c.desc}</div> |
| </div> |
| </div> |
| <div class="palette-card__swatches" style="grid-template-columns: repeat(${colors.length}, minmax(0, 1fr));">${swatches}</div> |
| <button class="palette-card__copy button--ghost" type="button" aria-label="Copy palette"> |
| <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg> |
| </button> |
| </div>`; |
| }) |
| .join(""); |
| grid.innerHTML = html; |
| }; |
| const MODE_TO_CLASS = { |
| protanopia: "cb-protanopia", |
| deuteranopia: "cb-deuteranopia", |
| tritanopia: "cb-tritanopia", |
| achromatopsia: "cb-achromatopsia", |
| }; |
| const CLEAR_CLASSES = Object.values(MODE_TO_CLASS); |
| const clearCbClasses = () => { |
| const rootEl = document.documentElement; |
| CLEAR_CLASSES.forEach((cls) => rootEl.classList.remove(cls)); |
| }; |
| const applyCbClass = (mode) => { |
| clearCbClasses(); |
| const cls = MODE_TO_CLASS[mode]; |
| if (cls) document.documentElement.classList.add(cls); |
| }; |
| const currentCbMode = () => { |
| const rootEl = document.documentElement; |
| for (const [mode, cls] of Object.entries(MODE_TO_CLASS)) { |
| if (rootEl.classList.contains(cls)) return mode; |
| } |
| return "none"; |
| }; |
| const setupCbSim = () => { |
| const select = document.getElementById("cb-select"); |
| if (!select) return; |
| try { |
| select.value = currentCbMode(); |
| } catch {} |
| select.addEventListener("change", () => applyCbClass(select.value)); |
| }; |
| const setupCountControl = () => { |
| const input = document.getElementById("color-count"); |
| const out = document.getElementById("color-count-out"); |
| if (!input) return; |
| const clamp = (n, min, max) => Math.max(min, Math.min(max, n)); |
| const read = () => clamp(Number(input.value) || 6, 6, 10); |
| const syncOut = () => { |
| if (out) out.textContent = String(read()); |
| }; |
| const onChange = () => { |
| syncOut(); |
| render(); |
| }; |
| syncOut(); |
| input.addEventListener("input", onChange); |
| document.addEventListener("palettes:updated", () => { |
| syncOut(); |
| render(); |
| }); |
| }; |
| let copyDelegationSetup = false; |
| const setupCopyDelegation = () => { |
| if (copyDelegationSetup) return; |
| const grid = document.querySelector(".palettes .palettes__grid"); |
| if (!grid) return; |
| grid.addEventListener("click", async (e) => { |
| const btn = e.target.closest |
| ? e.target.closest(".palette-card__copy") |
| : null; |
| if (!btn) return; |
| const card = btn.closest(".palette-card"); |
| if (!card) return; |
| const colors = (card.dataset.colors || "").split(",").filter(Boolean); |
| const json = JSON.stringify(colors, null, 2); |
| try { |
| await navigator.clipboard.writeText(json); |
| const old = btn.innerHTML; |
| btn.innerHTML = |
| '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>'; |
| setTimeout(() => (btn.innerHTML = old), 900); |
| } catch { |
| window.prompt("Copy palette", json); |
| } |
| }); |
| copyDelegationSetup = true; |
| }; |
| const bootstrap = () => { |
| setupCbSim(); |
| setupCountControl(); |
| setupCopyDelegation(); |
| |
| render(); |
| |
| document.addEventListener("palettes:updated", render); |
| |
| try { |
| if ( |
| window.ColorPalettes && |
| typeof window.ColorPalettes.notify === "function" |
| ) |
| window.ColorPalettes.notify(); |
| else if ( |
| window.ColorPalettes && |
| typeof window.ColorPalettes.refresh === "function" |
| ) |
| window.ColorPalettes.refresh(); |
| } catch {} |
| }; |
| if (document.readyState === "loading") { |
| document.addEventListener("DOMContentLoaded", bootstrap, { once: true }); |
| } else { |
| bootstrap(); |
| } |
| })(); |
| </script> |
|
|