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 titleCase = s => s.charAt(0).toUpperCase() + s.substr(1); const vectorDot = (u, v) => u.map((x, i) => x * v[i]).reduce((x, y) => x + y); const vectorMag = v => Math.sqrt(vectorDot(v, v)); const vectorNorm = v => { const n = vectorMag(v); return [ n, v.map(c => c / n) ]; }; const acosDeg = v => Math.acos(v) * 180 / Math.PI; const getContrastingTextColor = rgb => vectorDot(rgb, [0.3, 0.6, 0.1]) >= 128 ? "#222" : "#ddd" const pokemonLookup = new Fuse(database, { keys: [ "name" ] }); // hex codes already include leading # in these functions // rgb values are [0, 255] in these functions const jab2hex = jab => d3.jab(...jab).formatHex(); const rgb2hex = rgb => d3.rgb(...rgb).formatHex(); const rgb2jab = rgb => { const { J, a, b } = d3.jab(d3.rgb(...rgb)); return [ J, a, b ]; } const hex2rgb = hex => { const { r, g, b } = d3.rgb(hex); return [ r, g, b ]; }; // scoring function const getCalculator = (closeCoeff, includeX, normQY, qRGB, qJAB) => { const [ qRGBNorm, qRGBHat ] = vectorNorm(qRGB); const [ qJABNorm, qJABHat ] = vectorNorm(qJAB); const qRGBNormSq = qRGBNorm * qRGBNorm; const qJABNormSq = qJABNorm * qJABNorm; const [ _, qChromaHat ] = vectorNorm(qJAB.slice(1)); const qHueAngle = d3.hsl(d3.rgb(...qRGB)).h; return ({ xRGB, yRGB, xJAB, yJAB }) => { // in an ideal world we wouldn't calculate all these when they might not all be used // but honestly, we're in the browser, and I'm tired, let's just be lazy for once... const [ yRGBNorm, yRGBHat ] = vectorNorm(yRGB); const [ yJABNorm, yJABHat ] = vectorNorm(yJAB); const [ _, yChromaHat ] = vectorNorm(yJAB.slice(1)); const cosAngleRGB = vectorDot(qRGBHat, yRGBHat); const cosAngleJAB = vectorDot(qJABHat, yJABHat); const cosChromaAngle = vectorDot(qChromaHat, yChromaHat); const yTermRGB = cosAngleRGB * yRGBNorm * qRGBNorm; const yTermJAB = cosAngleJAB * yJABNorm * qJABNorm; return { metrics: { angleRGB: acosDeg(cosAngleRGB), angleJAB: acosDeg(cosAngleJAB), chromaAngle: acosDeg(cosChromaAngle), hueAngle: Math.acos(Math.cos((qHueAngle - d3.hsl(d3.rgb(...yRGB)).h) * Math.PI / 180)), stdDevRGB: Math.sqrt(xRGB - 2 * yTermRGB + qRGBNormSq), stdDevJAB: Math.sqrt(xJAB - 2 * yTermJAB + qJABNormSq), }, scoreRGB: (includeX ? xRGB : 0) - closeCoeff * (normQY ? cosAngleRGB : yTermRGB), scoreJAB: (includeX ? xJAB : 0) - closeCoeff * (normQY ? cosAngleJAB : yTermJAB), } } }; // create a tile of a given hex color const createTile = hexColor => { const tile = document.createElement("div"); tile.setAttribute("class", "color-tile"); tile.setAttribute("style", `background-color: ${hexColor};`) tile.textContent = hexColor; return tile; } const renderPokemon = ( { name, metrics = null, scoreRGB = null, scoreJAB = null, yRGB, yJAB }, { labelClass = "", rgbClass = "", jabClass = "" } = {}, ) => { const titleName = titleCase(name); const rgbHex = rgb2hex(yRGB); const jabHex = jab2hex(yJAB); const textHex = getContrastingTextColor(yRGB); const rgbVec = yRGB.map(c => c.toFixed()).join(", ") const jabVec = yJAB.map(c => c.toFixed(1)).join(", ") const scoreClass = scoreRGB === null || scoreJAB === null ? "hide" : ""; const pkmn = document.createElement("div"); pkmn.setAttribute("class", "pokemon_tile"); pkmn.innerHTML = `
${titleName}
Jab: RGB:
(${metrics?.stdDevJAB?.toFixed(2)}, ${metrics?.angleJAB?.toFixed(1)}°, ${metrics?.chromaAngle?.toFixed(1)}°) (${metrics?.stdDevRGB?.toFixed(2)}, ${metrics?.angleRGB?.toFixed(1)}°, ${metrics?.hueAngle?.toFixed(1)}°)
${jabHex}(${jabVec})
${rgbHex}(${rgbVec})
`; return pkmn; } const hideCustomControls = () => document .querySelectorAll(".hideable_control") .forEach(n => n.setAttribute("class", "hideable_control hideable_control--hidden")); const showCustomControls = () => document .querySelectorAll(".hideable_control") .forEach(n => n.setAttribute("class", "hideable_control")); let lastColorSearch = null; let lastPkmnSearch = null; const paramsChanged = (...args) => { const old = lastColorSearch; lastColorSearch = args; return old === null || old.filter((p, i) => p !== args[i]).length > 0 } const renderVec = math => `\\vec{${math.charAt(0)}}${math.substr(1)}`; const renderNorm = vec => `\\frac{${vec}}{\\left|\\left|${vec}\\right|\\right|}`; const metricText = [ "\\text{RMS}_{P} ~ \\arg\\min_{P}\\left[X\\left(P\\right) - 2\\vec{q}\\cdot \\vec{Y}\\left(P\\right)\\right]", `\\angle \\left(\\vec{q}, \\vec{Y}\\left(P\\right)\\right) ~ \\arg\\min_{P}\\left[-${renderNorm(renderVec("q"))}\\cdot ${renderNorm(renderVec("Y\\left(P\\right)"))}\\right]`, "\\angle \\left(\\vec{q}_{\\perp}, \\vec{Y}\\left(P\\right)_{\\perp} \\right)", ]; const metricIncludeMinus = [true, false, false, true]; const renderMath = (metric, includeX, normQY, closeCoeff) => { const found = metricText?.[metric]; if (found) { return found; } const xTerm = includeX ? "X\\left(P\\right)" : ""; const qyMod = normQY ? c => renderNorm(renderVec(c)) : renderVec; return `\\arg\\min_{P}\\left[${xTerm}-${closeCoeff === 1 ? "" : closeCoeff}${qyMod("q")}\\cdot ${qyMod("Y\\left(P\\right)")}\\right]`; } const renderQVec = (q, id, sub) => { document.getElementById(id).innerHTML = TeXZilla.toMathMLString(`\\vec{q}_{\\text{${sub}}} = \\left(${q.join(", ")}\\right)`); } const changePageColors = color => { // calculate luminance to determine if text should be dark or light const textColor = getContrastingTextColor([color.r, color.g, color.b]); document.querySelector("body").setAttribute("style", `background: ${color.formatHex()}; color: ${textColor}`); } const readColor = rgb => { const { J, a, b } = d3.jab(rgb); return [[rgb.r, rgb.g, rgb.b], [J, a, b]]; } const onUpdate = (event) => { if (event) { event.preventDefault(); } // Configuration Loading const metric = document.getElementById("metric")?.selectedIndex ?? 0; let sortBy; switch (metric) { case 0: // Variance/RMS hideCustomControls(); includeX = true; normQY = false; closeCoeff = 2; sortBy = ({ scoreJAB, scoreRGB }) => [ scoreJAB, scoreRGB ]; break; case 1: // Mean Angle hideCustomControls(); includeX = false; normQY = true; closeCoeff = 1; sortBy = ({ scoreJAB, scoreRGB }) => [ scoreJAB, scoreRGB ]; break; case 2: // Chroma hideCustomControls(); includeX = false; normQY = false; closeCoeff = 0; sortBy = ({ metrics: { chromaAngle, hueAngle } }) => [ chromaAngle, hueAngle ]; break; default: // Custom showCustomControls(); includeX = document.getElementById("include-x")?.checked ?? false; normQY = document.getElementById("norm-q-y")?.checked ?? false; closeCoeff = document.getElementById("close-coeff")?.value ?? 2; sortBy = ({ scoreJAB, scoreRGB }) => [ scoreJAB, scoreRGB ]; break; } const useRGB = document.getElementById("color-space")?.textContent === "RGB"; const numPoke = document.getElementById("num-poke")?.value ?? 20; const pokemonName = document.getElementById("pokemon-name")?.value?.toLowerCase() ?? ""; const colorInput = "#" + (document.getElementById("color-input")?.value?.replace("#", "") ?? "FFFFFF"); // Clear pokemon search if (pokemonName.length === 0) { const searchList = document.getElementById("search-list"); searchList.innerHTML = ''; } // Check if parameters have changed const newParams = paramsChanged(metric, includeX, normQY, closeCoeff, useRGB, numPoke, colorInput); if (newParams) { // Update display values document.getElementById("close-coeff-display").innerHTML = closeCoeff; document.getElementById("num-poke-display").textContent = numPoke; const objFnElem = document.getElementById("obj-fn"); objFnElem.innerHTML = ""; objFnElem.appendChild(TeXZilla.toMathML(renderMath(metric, includeX, normQY, closeCoeff))); } // Only modified if current color is valid let calculator = () => {}; // Lookup by color if (colorInput.length === 7) { // Convert input color const targetColor = d3.color(colorInput); const [ targetRGB, targetJAB ] = readColor(targetColor); // Update the color display changePageColors(targetColor); renderQVec(targetRGB.map(c => c.toFixed()), "q-vec-rgb", "RGB"); renderQVec(targetJAB.map(c => c.toFixed(2)), "q-vec-jab", "Jab"); // Set the scoring and sorting functions calculator = getCalculator(closeCoeff, includeX, normQY, targetRGB, targetJAB); // Rescore Pokemon and update lists if config has changed if (newParams) { const scored = database.map(info => ({ ...info, ...calculator(info) })); const bestListJAB = document.getElementById("best-list-jab"); bestListJAB.innerHTML = ''; scored .sort((a, b) => sortBy(a)[0] - sortBy(b)[0]) .slice(0, numPoke) .forEach(info => { const li = document.createElement("li"); li.appendChild(renderPokemon(info, { labelClass: "hide", rgbClass: "hide" })) bestListJAB.appendChild(li); }); const bestListRGB = document.getElementById("best-list-rgb"); bestListRGB.innerHTML = ''; scored .sort((a, b) => sortBy(a)[1] - sortBy(b)[1]) .slice(0, numPoke) .forEach(info => { const li = document.createElement("li"); li.appendChild(renderPokemon(info, { labelClass: "hide", jabClass: "hide" })) bestListRGB.appendChild(li); }); } } // Lookup by name if (lastPkmnSearch !== pokemonName || newParams) { let found; if (pokemonName.trim().toLowerCase() === "!random") { found = Array.from({ length: 10 }, () => database[Math.floor(Math.random() * database.length)]); } else { found = pokemonLookup.search(pokemonName, { limit: 10 }).map(({ item }) => item); } const searchList = document.getElementById("search-list"); searchList.innerHTML = ''; // If scoring is impossible, calculator will just return {} found.map((info) => ({ ...info, ...calculator(info) })) .forEach(item => { const li = document.createElement("li"); li.appendChild(renderPokemon(item)) searchList.appendChild(li); }); } lastPkmnSearch = pokemonName; }; const onRandomColor = () => { document.getElementById("color-input").value = rgb2hex([Math.random(), Math.random(), Math.random()].map(c => c * 255)); onUpdate(); };