// 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: {}, }; // Metrics const getBestKMean = (stats, q) => argMin(stats.kMeans.map((z, i) => vectorSqDist(z.vector, q.vector) / stats.kWeights[i])); const getWorstKMean = (stats, q) => argMax(stats.kMeans.map((z, i) => vectorSqDist(z.vector, q.vector) / stats.kWeights[i])); 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); 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); // divide by scale since we're negative return -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 vectorSqDist(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); // divide by scale since we're negative 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| \\ \end{aligned} `, "angle-definition": String.raw` \begin{aligned} \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) \end{aligned} `, "rms-definition": String.raw` \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}} `, "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[ \frac{1}{\pi_i} \left|\left| \vec{q} - \vec{\mu}\left(P_i\right) \right|\right| \right] \\ \omega\left(P\right) &= ${mathArgBest("max", "P_i")} \left[ \frac{1}{\pi_i} \left|\left| \vec{q} - \vec{\mu}\left(P_i\right) \right|\right| \right] \end{aligned} `, }; const includeScaleFactor = () => state.clusterChoice > 0 && state.includeScale const metricText = [ muArg => String.raw` ${mathArgBest("min", "P")}\left[ ${includeScaleFactor() ? String.raw`\frac{\left|P\right|}{\left|${muArg}\right|}\left(` : ""} I\left(P\right) - 2\vec{q}\cdot \vec{\mu}\left(${muArg}\right) ${includeScaleFactor() ? String.raw`\right)` : ""} \right]`, muArg => String.raw`${mathArgBest("min", "P")}\left[-${includeScaleFactor() ? String.raw`\frac{\left|${muArg}\right|}{\left|P\right|}` : ""}\cos\left(\angle \left(\vec{q}, \vec{\mu}\left(${muArg}\right)\right)\right)\right]`, muArg => String.raw`${mathArgBest("min", "P")}\left[${includeScaleFactor() ? String.raw`\frac{\left|P\right|}{\left|${muArg}\right|}` : ""}\left|\left| \vec{q} - \vec{\mu}\left(${muArg}\right) \right|\right|^2\right]`, muArg => String.raw`${mathArgBest("min", "P")}\left[${includeScaleFactor() ? String.raw`\frac{\left|P\right|}{\left|${muArg}\right|}` : ""}\angle \left(\vec{q}_{\perp}, \vec{\mu}\left(${muArg}\right)_{\perp} \right)\right]`, muArg => String.raw`${mathArgBest("min", "P")}\left[-${includeScaleFactor() ? String.raw`\frac{\left|${muArg}\right|}{\left|P\right|}` : ""}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`I\left(P\right)` : ""} ${closeCoeff === 0 ? "" : String.raw` - ${closeCoeff} ${qyMod("\\vec{q}")} \cdot ${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`; }; // TODO make the M m alpha omega labels more visible const renderCluster = ({ index, big, small, best, worst, pi, theta, delta, phi, hex, vector, }) => `