"use client"; import { useMemo, useRef, useState } from "react"; import { motion, useReducedMotion } from "motion/react"; export type Paint = { name: string; color: string; /** How strongly it tints a mix. White needs a lot, black very little. */ power?: number; }; type PaintMixerProps = { /** Five paints work best. */ paints?: Paint[]; /** Indexes of paints already in the well at start. */ defaultDabs?: number[]; /** Form field name. Sends the hex colour. */ name?: string; /** Called with the new hex colour, or "" when the well is clean. */ onChange?: (hex: string) => void; /** Read out by screen readers. */ label?: string; /** Set the width here. Everything scales with it. */ className?: string; }; type Drop = { id: number; x: number; y: number; color: string; turn: number }; const PAINTS: Paint[] = [ { name: "Madder Red", color: "#d62a54", power: 1 }, { name: "Cadmium Yellow", color: "#f8c82a", power: 1.6 }, { name: "Ultramarine", color: "#2f55b8", power: 1 }, { name: "Titanium White", color: "#f8f6f0", power: 11 }, { name: "Ivory Black", color: "#1a1712", power: 0.4 }, ]; // Paint seats on the 520 x 330 palette, and the mixing well. const SEATS = [ [112, 92], [206, 52], [312, 50], [404, 84], [462, 160], ]; const WELL = { x: 262, y: 178, r: 74 }; const BLOBS = ["58% 42% 55% 45% / 52% 60% 40% 48%", "46% 54% 42% 58% / 60% 44% 56% 40%", "54% 46% 60% 40% / 44% 56% 44% 56%", "42% 58% 48% 52% / 54% 42% 58% 46%", "56% 44% 46% 54% / 46% 58% 42% 54%"]; const SPIRAL = (() => { let d = ""; for (let t = 0; t <= 1; t += 0.02) { const a = t * Math.PI * 5.2; const r = 8 + t * 54; d += `${t ? "L" : "M"}${(WELL.x + Math.cos(a) * r).toFixed(1)} ${(WELL.y + Math.sin(a) * r).toFixed(1)}`; } return d; })(); const channels = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); const toByte = (c: number) => Math.round(255 * (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055)); /* Kubelka-Munk, the sum real pigment follows: each paint absorbs and scatters light, so yellow and blue give green, not grey. */ function mix(dabs: number[], paints: Paint[]) { if (!dabs.length) return ""; const out = [0, 1, 2].map((ch) => { let k = 0; let s = 0; for (const i of dabs) { const r = Math.max(channels(paints[i].color)[ch], 0.002); const power = paints[i].power ?? 1; k += (power * (1 - r) ** 2) / (2 * r); s += power; } const q = k / s; return 1 + q - Math.sqrt(q * q + 2 * q); }); return `#${out.map((c) => toByte(c).toString(16).padStart(2, "0")).join("")}`; } /* PaintMixer: a colour field on an artist's palette. Each press drops a dab of real paint into the well, it swirls in, and the colour you get is what the pigments would give. */ export function PaintMixer({ paints = PAINTS, defaultDabs = [0, 1, 1], name, onChange, label = "Mix a colour", className = "w-full max-w-lg" }: PaintMixerProps) { const reduce = useReducedMotion(); const next = useRef(0); const [dabs, setDabs] = useState(defaultDabs); const [drops, setDrops] = useState([]); const hex = useMemo(() => mix(dabs, paints), [dabs, paints]); const update = (list: number[]) => { setDabs(list); onChange?.(mix(list, paints)); }; const add = (i: number) => { update([...dabs, i]); if (reduce) return; next.current += 1; const [x, y] = SEATS[i % SEATS.length]; setDrops((all) => [...all, { id: next.current, x, y, color: paints[i].color, turn: next.current * 137 }]); }; const small = "rounded-[0.7cqw] bg-[#f3e6c8] px-[1.8cqw] py-[0.6cqw] font-mono text-[2.9cqw] text-[#4c3b22] shadow-[0_0.3cqw_0_#a07c4e] transition-transform active:translate-y-[0.3cqw] active:shadow-none focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white disabled:opacity-50 disabled:shadow-none"; return (
{drops.map((d) => ( setDrops((all) => all.filter((x) => x.id !== d.id))} /> ))} {hex && } {paints.slice(0, SEATS.length).map((paint, i) => ( add(i)} aria-label={`Add ${paint.name}`} title={paint.name} whileTap={reduce ? undefined : { scale: 0.86 }} className="absolute size-[12.5cqw] -translate-x-1/2 -translate-y-1/2 shadow-[inset_-0.6cqw_-0.8cqw_1cqw_rgba(0,0,0,0.3),inset_0.5cqw_0.6cqw_0.8cqw_rgba(255,255,255,0.5),0_0.4cqw_0.6cqw_rgba(0,0,0,0.3)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" style={{ left: `${(SEATS[i][0] / 520) * 100}%`, top: `${(SEATS[i][1] / 330) * 100}%`, background: paint.color, borderRadius: BLOBS[i % BLOBS.length] }} /> ))}
{hex || "clean"}
{name && }

{hex ? `Colour ${hex}, ${dabs.length} dabs` : "Well is clean"}

); }