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.abs(qHueAngle - d3.hsl(d3.rgb(...yRGB)).h),
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();
};