// ---- Math and Utilities ---- // Vector Math const vectorDot = (u, v) => u.map((x, i) => x * v[i]).reduce((x, y) => x + y); const vectorMag = (v) => Math.sqrt(vectorDot(v, v)); // Angle Math const rad2deg = 180 / Math.PI; // Misc const clamp = (mn, v, mx) => Math.min(Math.max(v, mn), mx); const productLift = (...factors) => (...args) => factors .filter((fn) => !!fn) .map((fn) => fn(...args)) .reduce((x, y) => x * y, 1); const mapValues = (obj, fn) => Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, fn(value, key)])); // Contrast + Shadow + Hover Colors const getContrastingTextColor = (hex) => { const { r, g, b } = d3.color(hex); return vectorDot([r, g, b], [0.3, 0.6, 0.1]) >= 128 ? "var(--color-dark)" : "var(--color-light)"; }; // "Visual Importance" const calcImportance = (chroma, lightness, proportion) => chroma + Math.tanh(100 * (chroma - 0.25)) + // penalty for being <25% Math.tanh(100 * (chroma - 0.4)) + // penalty for being <40% lightness + Math.tanh(100 * (lightness - 0.5)) + // penalty for being <50% proportion + Math.tanh(100 * (proportion - 0.05)) + // penalty for being <5% Math.tanh(100 * (proportion - 0.1)) + // penalty for being <15% Math.tanh(100 * (proportion - 0.15)) + // penalty for being <15% Math.tanh(100 * (proportion - 0.25)) + // penalty for being <25% Math.tanh(100 * (proportion - 0.8)); // penalty for being <50% // Conversions const jab2hex = (jab) => d3.jab(...jab).formatHex(); const rgb2hex = (rgb) => d3.rgb(...rgb).formatHex(); const jab2hue = (jab) => d3.jch(d3.jab(...jab)).h || 0; const rgb2hue = (rgb) => d3.hsl(d3.rgb(...rgb)).h || 0; const jab2lit = ([j]) => j / 100; const rgb2lit = (rgb) => d3.hsl(d3.rgb(...rgb)).l || 0; const jab2chroma = (jab) => d3.jch(d3.jab(...jab)).C / 100; const rgb2chroma = (rgb) => d3.jch(d3.rgb(...rgb)).C / 100; // Pre-computation const buildVectorData = (vector, toHue, toLightness, toChroma, toHex) => { const sqMag = vectorDot(vector, vector); const mag = Math.sqrt(sqMag); const unit = vector.map((c) => c / mag); const hue = toHue(vector); const lightness = toLightness(vector); const chroma = toChroma(vector); const hex = toHex(vector); return { vector, sqMag, mag, unit, hue, lightness, chroma, hex }; }; const buildClusterData = ( size, inertia, mu1, mu2, mu3, nu1, nu2, nu3, totalSize, toHue, toLightness, toChroma, toHex ) => { const mu = buildVectorData([mu1, mu2, mu3], toHue, toLightness, toChroma, toHex); const nu = [nu1, nu2, nu3]; const muNuAngle = rad2deg * Math.acos(vectorDot(mu.unit, nu) / vectorMag(nu)); const proportion = size / totalSize; const importance = calcImportance(mu.chroma, mu.lightness, proportion); return { size, inverseSize: 1 / size, inertia, mu, nu, muNuAngle, proportion, inverseProportion: 1 / proportion, importance, }; }; const buildPokemonData = ([name, size, ...values]) => ({ name, jab: { total: buildClusterData( size, ...values.slice(0, 7), size, jab2hue, jab2lit, jab2chroma, jab2hex ), clusters: [ buildClusterData( ...values.slice(7, 15), size, jab2hue, jab2lit, jab2chroma, jab2hex ), buildClusterData( ...values.slice(15, 23), size, jab2hue, jab2lit, jab2chroma, jab2hex ), buildClusterData( ...values.slice(23, 31), size, jab2hue, jab2lit, jab2chroma, jab2hex ), buildClusterData( ...values.slice(31, 39), size, jab2hue, jab2lit, jab2chroma, jab2hex ), ].filter((c) => c.size !== 0), }, rgb: { total: buildClusterData( size, ...values.slice(39, 46), size, rgb2hue, rgb2lit, rgb2chroma, rgb2hex ), clusters: [ buildClusterData( ...values.slice(46, 54), size, rgb2hue, rgb2lit, rgb2chroma, rgb2hex ), buildClusterData( ...values.slice(54, 62), size, rgb2hue, rgb2lit, rgb2chroma, rgb2hex ), buildClusterData( ...values.slice(62, 70), size, rgb2hue, rgb2lit, rgb2chroma, rgb2hex ), buildClusterData( ...values.slice(70, 78), size, rgb2hue, rgb2lit, rgb2chroma, rgb2hex ), ].filter((c) => c.size !== 0), }, }); const pokemonData = databaseV3.map((row) => buildPokemonData(row)); const calcScores = (data, target) => { const sigma = Math.sqrt( data.inertia - 2 * vectorDot(data.mu.vector, target.vector) + target.sqMag ); const bigTheta = 1 - vectorDot(data.nu, target.unit); const rawPhi = Math.abs(data.mu.hue - target.hue); return { sigma, bigTheta, alpha: sigma * Math.pow(bigTheta, target.chroma + target.lightness), theta: rad2deg * Math.acos(vectorDot(data.mu.unit, target.unit)), phi: Math.min(rawPhi, 360 - rawPhi), delta: vectorMag(data.mu.vector.map((x, i) => x - target.vector[i])), manhattan: data.mu.vector .map((x, i) => Math.abs(x - target.vector[i])) .reduce((x, y) => x + y), ch: Math.max(...data.mu.vector.map((x, i) => Math.abs(x - target.vector[i]))), lightnessDiff: Math.abs(data.mu.lightness - target.lightness), inertia: data.inertia, variance: data.inertia - data.mu.sqMag, muNuAngle: data.muNuAngle, size: data.size, lightness: data.mu.lightness, chroma: data.mu.chroma, importance: data.importance, inverseSize: data.inverseSize, proportion: data.proportion, inverseProportion: data.inverseProportion, muHex: data.mu.hex, }; }; // ---- Styling ---- const rootStyle = document.querySelector(":root").style; const setColorStyles = (style, hex) => { const highlight = getContrastingTextColor(hex); style.setProperty("--highlight", highlight); style.setProperty("--background", hex); style.setProperty("--shadow-component", highlight.includes("light") ? "255" : "0"); }; // ---- List Render ---- const renderPokemon = (list, target) => { target.innerHTML = ""; // TODO }; // ---- Shared State ---- const state = { get targetColor() { return this._targetColor || ""; }, set targetColor(newColor) { const hex = `#${newColor?.replace("#", "")}`; if (hex.length !== 7) { return; } setColorStyles(rootStyle, hex); const oldColor = this._targetColor; this._targetColor = hex; if (oldColor) { const prevButton = document.createElement("button"); prevButton.innerText = oldColor; prevButton.classList = "color-select"; setColorStyles(prevButton.style, oldColor); prevButton.addEventListener("click", () => (this.targetColor = oldColor)); document.getElementById("prevColors").prepend(prevButton); } document.forms.targetColorForm.elements.colorText.value = hex; document.forms.targetColorForm.elements.colorPicker.value = hex; // TODO trigger recalc }, get colorSearchResults() { return this._colorSearchResults || []; }, set colorSearchResults(results) { this._colorSearchResults = results; renderColorSearchResults(); }, get nameSearchResults() { return this._nameSearchResults || []; }, set nameSearchResults(results) { this._nameSearchResults = results; renderNameSearchResults(); }, }; const colorSearchResultsTarget = document.getElementById("color-results"); const nameSearchResultsTarget = document.getElementById("name-results"); function renderColorSearchResults() { renderPokemon(state.colorSearchResults, colorSearchResultsTarget); } function renderNameSearchResults() { renderPokemon(state.nameSearchResults, nameSearchResultsTarget); } // ---- Form Controls ---- document.forms.targetColorForm.elements.colorText.addEventListener( "input", ({ target }) => { if (target.willValidate && !target.validity.valid) { target.value = target.dataset.lastValid || ""; } else { state.targetColor = target.dataset.lastValid = target.value; } } ); document.forms.targetColorForm.elements.colorPicker.addEventListener( "change", ({ target }) => { state.targetColor = target.value; } ); const randomizeTargetColor = () => (state.targetColor = d3 .hsl(Math.random() * 360, Math.random(), Math.random()) .formatHex()); document.forms.targetColorForm.elements.randomColor.addEventListener( "click", randomizeTargetColor ); randomizeTargetColor();