|
@@ -17,6 +17,10 @@ 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" ] });
|
|
@@ -34,19 +38,42 @@ const hex2rgb = hex => {
|
|
|
return [ r, g, b ];
|
|
|
};
|
|
|
|
|
|
-// scoring functions
|
|
|
-const getNormedScorer = (c, qRGB, qJAB) => {
|
|
|
- const fRGB = c / vectorMag(qRGB);
|
|
|
- const fJAB = c / vectorMag(qJAB);
|
|
|
- return ({ yRGB, yJAB }) => ({
|
|
|
- scoreRGB: fRGB * vectorDot(qRGB, yRGB) / vectorMag(yRGB),
|
|
|
- scoreJAB: fJAB * vectorDot(qJAB, yJAB) / vectorMag(yJAB),
|
|
|
- });
|
|
|
+// 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),
|
|
|
+ }
|
|
|
+ }
|
|
|
};
|
|
|
-const getUnnormedScorer = (c, qRGB, qJAB) => ({ yRGB, yJAB }) => ({
|
|
|
- scoreRGB: c * vectorDot(qRGB, yRGB),
|
|
|
- scoreJAB: c * vectorDot(qJAB, yJAB),
|
|
|
-});
|
|
|
|
|
|
// create a tile of a given hex color
|
|
|
const createTile = hexColor => {
|
|
@@ -54,11 +81,11 @@ const createTile = hexColor => {
|
|
|
tile.setAttribute("class", "color-tile");
|
|
|
tile.setAttribute("style", `background-color: ${hexColor};`)
|
|
|
tile.textContent = hexColor;
|
|
|
- return tile;
|
|
|
+ return tile;
|
|
|
}
|
|
|
|
|
|
const renderPokemon = (
|
|
|
- { name, scoreRGB = null, scoreJAB = null, yRGB, yJAB },
|
|
|
+ { name, metrics = null, scoreRGB = null, scoreJAB = null, yRGB, yJAB },
|
|
|
{ labelClass = "", rgbClass = "", jabClass = "" } = {},
|
|
|
) => {
|
|
|
const titleName = titleCase(name);
|
|
@@ -66,9 +93,9 @@ const renderPokemon = (
|
|
|
const jabHex = jab2hex(yJAB);
|
|
|
const textHex = getContrastingTextColor(yRGB);
|
|
|
const rgbVec = yRGB.map(c => c.toFixed()).join(", ")
|
|
|
- const jabVec = yJAB.map(c => c.toFixed(2)).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 = `
|
|
@@ -84,30 +111,34 @@ const renderPokemon = (
|
|
|
</div>
|
|
|
<div class="pokemon_tile-score_column ${scoreClass}">
|
|
|
<span class="pokemon_tile-no_flex ${jabClass}">
|
|
|
- ${scoreJAB?.toFixed(2)}
|
|
|
+ (${metrics?.stdDevJAB?.toFixed(2)}, ${metrics?.angleJAB?.toFixed(1)}°, ${metrics?.chromaAngle?.toFixed(1)}°)
|
|
|
</span>
|
|
|
<span class="pokemon_tile-no_flex ${rgbClass}">
|
|
|
- ${scoreRGB?.toFixed(2)}
|
|
|
+ (${metrics?.stdDevRGB?.toFixed(2)}, ${metrics?.angleRGB?.toFixed(1)}°, ${metrics?.hueAngle?.toFixed(1)}°)
|
|
|
</span>
|
|
|
</div>
|
|
|
<div class="pokemon_tile-hex_column">
|
|
|
<div class="pokemon_tile-hex_color ${jabClass}" style="background-color: ${jabHex}; color: ${textHex}">
|
|
|
- ${jabHex}
|
|
|
+ <span>${jabHex}</span><span class="pokemon_tile-vector">(${jabVec})</span>
|
|
|
</div>
|
|
|
<div class="pokemon_tile-hex_color ${rgbClass}" style="background-color: ${rgbHex}; color: ${textHex}">
|
|
|
- ${rgbHex}
|
|
|
+ <span>${rgbHex}</span><span class="pokemon_tile-vector">(${rgbVec})</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
- <div class="pokemon_tile-vector_column">
|
|
|
- <span class="pokemon_tile-no_flex ${rgbClass}">(${rgbVec})</span>
|
|
|
- <span class="pokemon_tile-no_flex ${jabClass}">(${jabVec})</span>
|
|
|
- </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
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;
|
|
|
|
|
@@ -121,10 +152,22 @@ const renderVec = math => `\\vec{${math.charAt(0)}}${math.substr(1)}`;
|
|
|
|
|
|
const renderNorm = vec => `\\frac{${vec}}{\\left|\\left|${vec}\\right|\\right|}`;
|
|
|
|
|
|
-const renderMath = (includeX, normQY, closeCoeff) => {
|
|
|
+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 TeXZilla.toMathML(`\\arg\\min_{P}\\left[${xTerm}-${closeCoeff}${qyMod("q")}\\cdot ${qyMod("Y\\left(P\\right)")}\\right]`);
|
|
|
+ return `\\arg\\min_{P}\\left[${xTerm}-${closeCoeff === 1 ? "" : closeCoeff}${qyMod("q")}\\cdot ${qyMod("Y\\left(P\\right)")}\\right]`;
|
|
|
}
|
|
|
|
|
|
const renderQVec = (q, id, sub) => {
|
|
@@ -146,11 +189,41 @@ const onUpdate = (event) => {
|
|
|
if (event) {
|
|
|
event.preventDefault();
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
// Configuration Loading
|
|
|
- const includeX = document.getElementById("include-x")?.checked ?? false;
|
|
|
- const normQY = document.getElementById("norm-q-y")?.checked ?? false;
|
|
|
- const closeCoeff = document.getElementById("close-coeff")?.value ?? 2;
|
|
|
+ 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() ?? "";
|
|
@@ -163,7 +236,7 @@ const onUpdate = (event) => {
|
|
|
}
|
|
|
|
|
|
// Check if parameters have changed
|
|
|
- const newParams = paramsChanged(includeX, normQY, closeCoeff, useRGB, numPoke, colorInput);
|
|
|
+ const newParams = paramsChanged(metric, includeX, normQY, closeCoeff, useRGB, numPoke, colorInput);
|
|
|
|
|
|
if (newParams) {
|
|
|
// Update display values
|
|
@@ -171,11 +244,11 @@ const onUpdate = (event) => {
|
|
|
document.getElementById("num-poke-display").textContent = numPoke;
|
|
|
const objFnElem = document.getElementById("obj-fn");
|
|
|
objFnElem.innerHTML = "";
|
|
|
- objFnElem.appendChild(renderMath(includeX, normQY, closeCoeff));
|
|
|
+ objFnElem.appendChild(TeXZilla.toMathML(renderMath(metric, includeX, normQY, closeCoeff)));
|
|
|
}
|
|
|
|
|
|
// Only modified if current color is valid
|
|
|
- let totalScorer = info => info;
|
|
|
+ let calculator = () => {};
|
|
|
|
|
|
// Lookup by color
|
|
|
if (colorInput.length === 7) {
|
|
@@ -188,57 +261,45 @@ const onUpdate = (event) => {
|
|
|
renderQVec(targetRGB.map(c => c.toFixed()), "q-vec-rgb", "RGB");
|
|
|
renderQVec(targetJAB.map(c => c.toFixed(2)), "q-vec-jab", "Jab");
|
|
|
|
|
|
- // Determine metrics from configuration
|
|
|
- const xSelector = includeX ? ({ xRGB, xJAB }) => [ xRGB, xJAB ] : () => [ 0, 0 ];
|
|
|
- const yScorer = (normQY ? getNormedScorer : getUnnormedScorer)(closeCoeff, targetRGB, targetJAB);
|
|
|
-
|
|
|
- // Set the scoring function
|
|
|
- totalScorer = info => {
|
|
|
- const [ xRGB, xJAB ] = xSelector(info);
|
|
|
- const { scoreRGB, scoreJAB } = yScorer(info);
|
|
|
- return {
|
|
|
- ...info,
|
|
|
- scoreRGB: xRGB - scoreRGB,
|
|
|
- scoreJAB: xJAB - scoreJAB,
|
|
|
- }
|
|
|
- };
|
|
|
+ // 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 => totalScorer(info));
|
|
|
-
|
|
|
- const bestListRGB = document.getElementById("best-list-rgb");
|
|
|
- bestListRGB.innerHTML = '';
|
|
|
+ const scored = database.map(info => ({ ...info, ...calculator(info) }));
|
|
|
+
|
|
|
+ const bestListJAB = document.getElementById("best-list-jab");
|
|
|
+ bestListJAB.innerHTML = '';
|
|
|
scored
|
|
|
- .sort((a, b) => a.scoreRGB - b.scoreRGB)
|
|
|
+ .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", jabClass: "hide" }))
|
|
|
- bestListRGB.appendChild(li);
|
|
|
+ li.appendChild(renderPokemon(info, { labelClass: "hide", rgbClass: "hide" }))
|
|
|
+ bestListJAB.appendChild(li);
|
|
|
});
|
|
|
-
|
|
|
- const bestListJAB = document.getElementById("best-list-jab");
|
|
|
- bestListJAB.innerHTML = '';
|
|
|
+
|
|
|
+ const bestListRGB = document.getElementById("best-list-rgb");
|
|
|
+ bestListRGB.innerHTML = '';
|
|
|
scored
|
|
|
- .sort((a, b) => a.scoreJAB - b.scoreJAB)
|
|
|
+ .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", rgbClass: "hide" }))
|
|
|
- bestListJAB.appendChild(li);
|
|
|
+ li.appendChild(renderPokemon(info, { labelClass: "hide", jabClass: "hide" }))
|
|
|
+ bestListRGB.appendChild(li);
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Lookup by name
|
|
|
+ // Lookup by name
|
|
|
if (lastPkmnSearch !== pokemonName || newParams) {
|
|
|
const searchList = document.getElementById("search-list");
|
|
|
searchList.innerHTML = '';
|
|
|
pokemonLookup
|
|
|
.search(pokemonName, { limit: 10 })
|
|
|
// If scoring is impossible, totalScorer will just be identity
|
|
|
- .map(({ item }) => totalScorer(item))
|
|
|
+ .map(({ item }) => ({ ...item, ...calculator(item) }))
|
|
|
.forEach(item => {
|
|
|
const li = document.createElement("li");
|
|
|
li.appendChild(renderPokemon(item))
|