// Selectors + DOM Manipulation const getColorInputNode = () => document.getElementById("color-input"); const getMetricDropdownNode = () => document.getElementById("metric"); const getClusterChoiceDropdownNode = () => document.getElementById("image-summary"); const getClusterScaleToggleNode = () => document.getElementById("scale-by-cluster-size"); const getClusterMeanWarning = () => document.getElementById("cluster-mean-warning"); const getIncludeXToggleNode = () => document.getElementById("include-x"); const getNormQYToggleNode = () => document.getElementById("norm-q-y"); const getCloseCoeffSliderNode = () => document.getElementById("close-coeff"); const getCloseCoeffDisplayNode = () => document.getElementById("close-coeff-display"); const getLimitSliderNode = () => document.getElementById("num-poke"); const getLimitDisplayNode = () => document.getElementById("num-poke-display"); const getNameInputNode = () => document.getElementById("pokemon-name"); const getScoreListJABNode = () => document.getElementById("best-list-jab"); const getScoreListRGBNode = () => document.getElementById("best-list-rgb"); const getSearchSpaceDisplayNode = () => document.getElementById("search-space-display"); const getSearchListNode = () => document.getElementById("search-list"); const getHideableControlNodes = () => document.querySelectorAll(".hideable_control"); const getQJABDisplay = () => document.getElementById("q-vec-jab"); const getQRGBDisplay = () => document.getElementById("q-vec-rgb"); const getObjFnDisplay = () => document.getElementById("obj-fn"); const clearNodeContents = node => { node.innerHTML = ""; }; const hideCustomControls = () => getHideableControlNodes() .forEach(n => n.setAttribute("class", "hideable_control hideable_control--hidden")); const showCustomControls = () => getHideableControlNodes() .forEach(n => n.setAttribute("class", "hideable_control")); // Vector Math const vectorDot = (u, v) => u.map((x, i) => x * v[i]).reduce((x, y) => x + y); const vectorSqMag = v => vectorDot(v, v); const vectorMag = v => Math.sqrt(vectorSqMag(v)); const vectorSqDist = (u, v) => vectorSqMag(u.map((x, i) => x - v[i])); const vectorDist = (u, v) => Math.sqrt(vectorSqDist(u, v)); const vectorNorm = v => { const n = vectorMag(v); return [ n, v.map(c => c / n) ]; }; // Angle Math const angleDiff = (a, b) => { const raw = Math.abs(a - b); return raw < 180 ? raw : (360 - raw); }; const rad2deg = 180 / Math.PI; // Conversions const jab2hex = jab => d3.jab(...jab).formatHex(); const rgb2hex = rgb => d3.rgb(...rgb).formatHex(); const jab2hue = ([, a, b]) => rad2deg * Math.atan2(b, a); const rgb2hue = rgb => d3.hsl(d3.rgb(...rgb)).h || 0; const hex2rgb = hex => { const { r, g, b } = d3.color(hex); return [r, g, b]; }; // Arg Compare const argComp = comp => ra => ra.map((x, i) => [x, i]).reduce((a, b) => comp(a[0], b[0]) > 0 ? b : a)[1]; const argMin = argComp((a, b) => a - b); const argMax = argComp((a, b) => b - a); // Pre-Compute Data const computeVectorData = (vector, toHex, toHue) => { const [ magnitude, unit ] = vectorNorm(vector); return { vector, magnitude, magSq: magnitude * magnitude, unit, hex: toHex(vector), hue: toHue(vector), }; }; const computeStats = (inertia, trueMeanVec, kMeanStruct, toHex, toHue) => ({ inertia, trueMean: computeVectorData(trueMeanVec, toHex, toHue), kMeans: kMeanStruct.slice(0, 3).map(z => computeVectorData(z, toHex, toHue)), kWeights: kMeanStruct[3], largestCluster: argMax(kMeanStruct[3]), smallestCluster: argMin(kMeanStruct[3]), }); const pokemonColorData = database.map(({ name, xJAB, xRGB, yJAB, yRGB, zJAB, zRGB, }) => ({ name, jabStats: computeStats(xJAB, yJAB, zJAB, jab2hex, jab2hue), rgbStats: computeStats(xRGB, yRGB, zRGB, rgb2hex, rgb2hue), })); const pokemonLookup = new Fuse(pokemonColorData, { keys: [ "name" ] }); // Color Calculations const getContrastingTextColor = rgb => vectorDot(rgb, [0.3, 0.6, 0.1]) >= 128 ? "#222" : "#ddd"; const readColorInput = () => { const colorInput = "#" + (getColorInputNode()?.value?.replace("#", "") ?? "FFFFFF"); if (colorInput.length !== 7) { return; } const rgb = d3.color(colorInput); const { J, a, b } = d3.jab(rgb); return { jabData: computeVectorData([ J, a, b ], jab2hex, jab2hue), rgbData: computeVectorData([ rgb.r, rgb.g, rgb.b ], rgb2hex, rgb2hue), }; }; // State const state = { metric: null, clusterChoice: null, includeScale: null, includeX: null, normQY: null, closeCoeff: null, numPoke: null, searchTerm: null, searchSpace: null, targetColor: null, searchResults: null, clusterToggles: {}, currentScores: {}, }; // Metrics const getBestKMean = (stats, q) => argMin(stats.kMeans.map(z => vectorDist(z.vector, q.vector))); const getWorstKMean = (stats, q) => argMax(stats.kMeans.map(z => vectorDist(z.vector, q.vector))); const getScale = weight => state.includeScale ? (1 / weight) : 1; const summarySelectors = [ // true mean stats => [stats.trueMean, 1], // largest cluster stats => [stats.kMeans[stats.largestCluster], getScale(stats.kWeights[stats.largestCluster])], // smallest cluster stats => [stats.kMeans[stats.smallestCluster], getScale(stats.kWeights[stats.smallestCluster])], // best fit cluster (stats, q) => { const best = getBestKMean(stats, q); return [stats.kMeans[best], getScale(stats.kWeights[best])]; }, // worst fit cluster (stats, q) => { const worst = getWorstKMean(stats, q); return [stats.kMeans[worst], getScale(stats.kWeights[worst])]; }, ]; const selectedSummary = (stats, q) => summarySelectors[state.clusterChoice](stats, q); // TODO unfortunately the addition of the scaling factor means we have to compute *real* // metrics instead of cheaper approximations. would be nice to fix this const metrics = [ // RMS (stats, q) => { const [ mean, scale ] = selectedSummary(stats, q); return (stats.inertia - 2 * vectorDot(mean.vector, q.vector)) * scale; }, // mean angle (stats, q) => { const [ mean, scale ] = selectedSummary(stats, q); return rad2deg * Math.acos(vectorDot(mean.unit, q.unit)) * scale; }, // mean dist (stats, q) => { const [ mean, scale ] = selectedSummary(stats, q); // TODO I know there's some way to avoid recalculation here but I'm just too lazy right now return vectorDist(mean.vector, q.vector) * scale; }, // hue angle (stats, q) => { const [ mean, scale ] = selectedSummary(stats, q); return angleDiff(mean.hue, q.hue) * scale; }, // max inertia (stats, q) => { const [ , scale ] = selectedSummary(stats, q); return -stats.inertia * scale; }, // custom (stats, q) => { const [ mean, scale ] = selectedSummary(stats, q); return ( (state.includeX ? stats.inertia : 0) - state.closeCoeff * vectorDot( mean[state.normQY ? "unit" : "vector"], state.normQY ? q.unit : q.vector, ) ) * scale; }, ]; const scorePokemon = pkmn => ({ jab: metrics[state.metric](pkmn.jabStats, state.targetColor.jabData), rgb: metrics[state.metric](pkmn.rgbStats, state.targetColor.rgbData), }); const calcDisplayMetrics = (meanData, q) => ({ theta: rad2deg * Math.acos(vectorDot(q.unit, meanData.unit)), delta: vectorDist(q.vector, meanData.vector), phi: angleDiff(q.hue, meanData.hue), }); // Math Rendering const renderQVec = (q, node, sub) => { node.innerHTML = TeXZilla.toMathMLString(String.raw`\vec{q}_{\text{${sub}}} = \left(\text{${q.join(", ")}}\right)`); }; const mathArgBest = (mxn, arg) => `\\underset{${arg}}{\\arg\\${mxn}}`; const mathDefinitions = { "main-definition": String.raw` \begin{aligned} \vec{\mu}\left(P\right) &= \frac{1}{\left|P\right|}\sum_{p\in P}{\vec{p}} \\ I\left(P\right) &= \frac{1}{\left|P\right|}\sum_{p\in P}{\left|\left|\vec{p}\right|\right|^2} \\ \delta\left(P\right) &= \left|\left| \vec{q} - \vec{\mu}\left(P\right) \right|\right| \\ \theta\left(P\right) &= \angle \left(\vec{q}, \vec{\mu}\left(P\right)\right) \\ \vec{x}_{\perp} &= \text{oproj}_{\left\{\vec{J}, \vec{L}\right\}}{\vec{x}} \\ \phi\left(P\right) &= \angle \left(\vec{q}_{\perp}, \vec{\mu}\left(P\right)_{\perp} \right) \\ \sigma\left(P\right) &= \sqrt{E\left[\left(\vec{q} - P\right)^2\right]} \\ &= \sqrt{\frac{1}{|P|}\sum_{p \in P}{\left|\left|\vec{p} - \vec{q}\right|\right|^2}} \end{aligned} `, "cluster-definition": String.raw` \begin{aligned} \left\{P_1, P_2, P_3\right\} &= ${mathArgBest("max", String.raw`\left\{P_1, P_2, P_3\right\}`)} \sum_{i=1}^3 \sum_{p\inP_i} \left|\left| \vec{p} - \vec{\mu}\left(P_i\right) \right|\right|^2 \\ \pi_i &= \frac{\left|P_i\right|}{\left|P\right|} \\ M\left(P\right) &= ${mathArgBest("max", "P_i")} \left( \left|P_i\right| \right) \\ m\left(P\right) &= ${mathArgBest("min", "P_i")} \left( \left|P_i\right| \right) \\ \alpha\left(P\right) &= ${mathArgBest("min", "P_i")} \left( \left|\left| \vec{q} - \vec{\mu}\left(P_i\right) \right|\right| \right) \\ \omega\left(P\right) &= ${mathArgBest("max", "P_i")} \left( \left|\left| \vec{q} - \vec{\mu}\left(P_i\right) \right|\right| \right) \end{aligned} `, }; const includeScaleFactor = muArg => state.clusterChoice > 0 && state.includeScale ? String.raw`\frac{\left|P\right|}{\left|${muArg}\right|}` : "" const metricText = [ muArg => String.raw` ${mathArgBest("min", "P")}\left[ ${includeScaleFactor(muArg)}I\left(P\right) - 2\vec{q}\cdot ${includeScaleFactor(muArg)}\vec{\mu}\left(${muArg}\right) \right]`, muArg => String.raw`${mathArgBest("min", "P")}\left[${includeScaleFactor(muArg)}\angle \left(\vec{q}, \vec{\mu}\left(${muArg}\right)\right)\right]`, muArg => String.raw`${mathArgBest("min", "P")}\left[${includeScaleFactor(muArg)}\left|\left|\vec{q} - \vec{\mu}\left(${muArg}\right) \right|\right|\right]`, muArg => String.raw`${mathArgBest("min", "P")}\left[${includeScaleFactor(muArg)}\angle \left(\vec{q}_{\perp}, \vec{\mu}\left(${muArg}\right)_{\perp}\right)\right]`, muArg => String.raw`${mathArgBest("min", "P")}\left[-${includeScaleFactor(muArg)}I\left(P\right)\right]`, ].map(s => muArg => TeXZilla.toMathML(s(muArg))); const muArgs = [ "P", String.raw`M\left(P\right)`, String.raw`m\left(P\right)`, String.raw`\alpha\left(P\right)`, String.raw`\omega\left(P\right)`, ]; const renderVec = math => String.raw`\vec{${math.charAt(0)}}${math.substr(1)}`; const renderNorm = vec => String.raw`\frac{${vec}}{\left|\left|${vec}\right|\right|}`; const updateObjective = () => { const muArg = muArgs[state.clusterChoice]; let tex = metricText?.[state.metric]?.(muArg); if (!tex) { const { includeX, normQY, closeCoeff } = state; if (!includeX && closeCoeff === 0) { tex = TeXZilla.toMathML(String.raw`\text{Malamar-ness}`); } else { const qyMod = normQY ? renderNorm : c => c; tex = TeXZilla.toMathML(String.raw` ${mathArgBest("min", "P")} \left[ ${includeX ? String.raw`${includeScaleFactor(muArg)}I\left(P\right)` : ""} ${closeCoeff === 0 ? "" : String.raw` - ${closeCoeff} ${qyMod("\\vec{q}")} \cdot ${includeScaleFactor(muArg)}${qyMod(String.raw`\vec{\mu}\left(${muArg}\right)`)} `} \right] `); } } const objFnNode = getObjFnDisplay(); clearNodeContents(objFnNode); objFnNode.appendChild(tex); }; // Pokemon Rendering const stripForm = ["flabebe", "floette", "florges", "vivillon", "basculin", "furfrou", "magearna"]; const getSprite = pokemon => { pokemon = pokemon .replace("-alola", "-alolan") .replace("-galar", "-galarian") .replace("darmanitan-galarian", "darmanitan-galarian-standard"); if (stripForm.find(s => pokemon.includes(s))) { pokemon = pokemon.replace(/-.*$/, ""); } return `https://img.pokemondb.net/sprites/sword-shield/icon/${pokemon}.png`; }; const renderCluster = ({ index, big, small, best, worst, pi, theta, delta, phi, hex, vector, }) => `
${index === big ? "M" : ""} ${index === small ? "m" : ""} ${index === best ? "α" : ""} ${index === worst ? "ω" : ""}
μ =
π =
θ =
δ =
ϕ =
${hex}
(${vector.map(c => c.toFixed(2)).join(", ")})
${(pi * 100).toFixed(1)}%
${theta.toFixed(2)}°
${delta.toFixed(2)}
${phi.toFixed(2)}°
`; const getPokemonRenderer = targetList => (name, stats, q, score, idPostfix) => { let sigma, metrics, kMeanInfo, kMeanResults; if (q) { sigma = Math.sqrt(stats.inertia - 2 * vectorDot(stats.trueMean.vector, q.vector) + q.magSq) metrics = calcDisplayMetrics(stats.trueMean, q) kMeanInfo = { big: stats.largestCluster, small: stats.smallestCluster, best: getBestKMean(stats, q), worst: getWorstKMean(stats, q), // TODO yeah yeah this is a recalc whatever }; kMeanResults = stats.kMeans.map(k => calcDisplayMetrics(k, q)); } else { // no target color, just do all zeros sigma = 0; metrics = { theta: 0, delta: 0, phi: 0 }; kMeanInfo = { big: 0, small: 0, best: 0, worst: 0 }; kMeanResults = [ metrics, metrics, metrics ]; } const clusterToggleId = `reveal_clusters-${name}-${idPostfix}`; const li = document.createElement("li"); li.innerHTML = `
${name.split("-").map(part => part.charAt(0).toUpperCase() + part.substr(1)).join(" ")}
${score?.toFixed(3) ?? ""}
μ =
${stats.trueMean.hex}
(${stats.trueMean.vector.map(c => c.toFixed(2)).join(", ")})
𝖨 = ${stats.inertia.toFixed(2)}
σ = ${sigma.toFixed(2)}
θ = ${metrics.theta.toFixed(2)}°
δ = ${metrics.delta.toFixed(2)}
ϕ = ${metrics.phi.toFixed(2)}°
${stats.kMeans.map((data, index) => renderCluster({ index, ...kMeanInfo, pi: stats.kWeights[index], ...kMeanResults[index], hex: data.hex, vector: data.vector, })).join("\n")}
`; targetList.appendChild(li); }; // Update Search Results const renderSearch = () => { const resultsNode = getSearchListNode(); const append = getPokemonRenderer(resultsNode); clearNodeContents(resultsNode); const argMapper = state.searchSpace === "RGB" ? pkmn => [pkmn.rgbStats, state.targetColor?.rgbData, state.currentScores?.[pkmn.name]?.rgb ?? null] : pkmn => [pkmn.jabStats, state.targetColor?.jabData, state.currentScores?.[pkmn.name]?.jab ?? null] state.searchResults?.forEach(pkmn => append( pkmn.name, ...argMapper(pkmn), "search" )); }; // Scoring const renderScored = () => { const jabList = getScoreListJABNode(); const appendJAB = getPokemonRenderer(jabList); const rgbList = getScoreListRGBNode(); const appendRGB = getPokemonRenderer(rgbList); const clonedData = pokemonColorData.slice(); // extract best CIECAM02 results const bestJAB = clonedData .sort((a, b) => state.currentScores[a.name].jab - state.currentScores[b.name].jab) .slice(0, state.numPoke); clearNodeContents(jabList); bestJAB.forEach(data => appendJAB( data.name, data.jabStats, state.targetColor.jabData, state.currentScores[data.name].jab, "jab" )); // extract best RGB results const bestRGB = clonedData .sort((a, b) => state.currentScores[a.name].rgb - state.currentScores[b.name].rgb) .slice(0, state.numPoke); clearNodeContents(rgbList); bestRGB.forEach(data => appendRGB( data.name, data.rgbStats, state.targetColor.rgbData, state.currentScores[data.name].rgb, "rgb" )); }; const rescore = () => { if (!state.targetColor) { return; } state.currentScores = {}; pokemonColorData.forEach(data => { state.currentScores[data.name] = scorePokemon(data); }); // update displays renderScored(); // update the rendered search results as well renderSearch(); }; // Listeners const onColorChanged = skipScore => { const readColor = readColorInput(); if (readColor) { state.targetColor = readColor; renderQVec(state.targetColor.jabData.vector.map(c => c.toFixed(3)), getQJABDisplay(), "Jab"); renderQVec(state.targetColor.rgbData.vector.map(c => c.toFixed()), getQRGBDisplay(), "RGB"); const rootElem = document.querySelector(":root"); rootElem.style.setProperty("--background", state.targetColor.rgbData.hex); rootElem.style.setProperty("--highlight", getContrastingTextColor(state.targetColor.rgbData.vector)); if (!skipScore) { rescore(); } } }; const onRandomColor = () => { const color = [Math.random(), Math.random(), Math.random()].map(c => c * 255); getColorInputNode().value = d3.rgb(...color).formatHex(); onColorChanged(); // triggers rescore }; const onCustomControlsChanged = skipScore => { state.includeX = getIncludeXToggleNode()?.checked ?? false; state.normQY = getNormQYToggleNode()?.checked ?? false; state.closeCoeff = parseFloat(getCloseCoeffSliderNode()?.value ?? 2); getCloseCoeffDisplayNode().innerHTML = state.closeCoeff; updateObjective(); if (!skipScore) { rescore(); } } const checkClusterMeanWarning = () => { const warning = getClusterMeanWarning(); const unhidden = warning.getAttribute("class").replaceAll("hide", ""); if (state.clusterChoice !== 0 && (state.metric === 0 || state.metric === 4)) { warning.setAttribute("class", unhidden); } else { warning.setAttribute("class", unhidden + " hide"); } } const checkScaleByClusterToggle = () => { const toggle = getClusterScaleToggleNode()?.parentNode; const unhidden = toggle.getAttribute("class").replaceAll("hide", ""); if (state.clusterChoice !== 0) { toggle.setAttribute("class", unhidden); } else { toggle.setAttribute("class", unhidden + " hide"); } } const onScaleByClusterChanged = skipScore => { state.includeScale = getClusterScaleToggleNode()?.checked ?? true; updateObjective(); if (!skipScore) { rescore(); } } const onClusterChoiceChanged = skipScore => { const clusterChoice = getClusterChoiceDropdownNode()?.selectedIndex ?? 0; if (clusterChoice === state.clusterChoice) { return; } state.clusterChoice = clusterChoice; checkClusterMeanWarning(); checkScaleByClusterToggle(); updateObjective(); if (!skipScore) { rescore(); } } const onMetricChanged = skipScore => { const metric = getMetricDropdownNode()?.selectedIndex ?? 0; if (metric === state.metric) { return; } state.metric = metric; checkClusterMeanWarning(); checkScaleByClusterToggle(); if (state.metric === 5) { // Custom showCustomControls(); onCustomControlsChanged(skipScore); // triggers rescore } else { hideCustomControls(); updateObjective(); if (!skipScore) { rescore(); } } }; const onLimitChanged = skipRenderScore => { state.numPoke = parseInt(getLimitSliderNode()?.value ?? 10); getLimitDisplayNode().textContent = state.numPoke; if (!skipRenderScore) { renderScored(); } }; const onSearchChanged = () => { state.searchTerm = getNameInputNode()?.value?.toLowerCase() ?? ""; if (state.searchTerm.length === 0) { state.searchResults = []; } else { state.searchResults = pokemonLookup .search(state.searchTerm, { limit: 10 }) .map(({ item }) => item); } renderSearch(); }; const onSearchSpaceChanged = () => { const old = state.searchSpace ?? "Jab"; state.searchSpace = old === "RGB" ? "Jab" : "RGB"; getSearchSpaceDisplayNode().textContent = old; renderSearch(); }; const onRandomPokemon = () => { getNameInputNode().value = ""; state.searchResults = Array.from({ length: 10 }, () => pokemonColorData[Math.floor(Math.random() * pokemonColorData.length)]); renderSearch(); }; const onPageLoad = () => { // render static explanations Object.entries(mathDefinitions).forEach(([id, tex]) => { document.getElementById(id)?.appendChild(TeXZilla.toMathML(tex)); }); // fake some events but don't do any scoring onColorChanged(true); onMetricChanged(true); onClusterChoiceChanged(true); onScaleByClusterChanged(true); onLimitChanged(true); // then do a rescore directly, which will do nothing unless old data was loaded rescore(); // finally render search in case rescore didn't onSearchChanged(); };