|
@@ -1,100 +1,192 @@
|
|
-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);
|
|
|
|
|
|
+// Selectors + DOM Manipulation
|
|
|
|
+const getColorInputNode = () => document.getElementById("color-input");
|
|
|
|
+const getMetricDropdownNode = () => document.getElementById("metric");
|
|
|
|
+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 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 vectorDot = (u, v) => u.map((x, i) => x * v[i]).reduce((x, y) => x + y);
|
|
-
|
|
|
|
const vectorMag = v => Math.sqrt(vectorDot(v, v));
|
|
const vectorMag = v => Math.sqrt(vectorDot(v, v));
|
|
-
|
|
|
|
const vectorNorm = v => { const n = vectorMag(v); return [ n, v.map(c => c / n) ]; };
|
|
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 acosDeg = v => Math.acos(v) * 180 / Math.PI;
|
|
const acosDeg = v => Math.acos(v) * 180 / Math.PI;
|
|
|
|
|
|
-const getContrastingTextColor = rgb => vectorDot(rgb, [0.3, 0.6, 0.1]) >= 128 ? "#222" : "#ddd"
|
|
|
|
|
|
+// Pre-Compute Y Data
|
|
|
|
+const pokemonColorData = database.map(data => {
|
|
|
|
+ const yRGBColor = d3.rgb(...data.yRGB);
|
|
|
|
+ const [ yJABNorm, yJABHat ] = vectorNorm(data.yJAB);
|
|
|
|
+ const [ yRGBNorm, yRGBHat ] = vectorNorm(data.yRGB);
|
|
|
|
+ const [ _, yChromaHat ] = vectorNorm(data.yJAB.slice(1));
|
|
|
|
+
|
|
|
|
+ return {
|
|
|
|
+ ...data,
|
|
|
|
+ yJABHex: d3.jab(...data.yJAB).formatHex(),
|
|
|
|
+ yJABNorm, yJABHat,
|
|
|
|
+ yRGBHex: yRGBColor.formatHex(),
|
|
|
|
+ yRGBNorm, yRGBHat,
|
|
|
|
+ yChromaHat,
|
|
|
|
+ yHueAngle: d3.hsl(yRGBColor).h,
|
|
|
|
+ }
|
|
|
|
+});
|
|
|
|
+const pokemonLookup = new Fuse(pokemonColorData, { keys: [ "name" ] });
|
|
|
|
|
|
-const pokemonLookup = new Fuse(database, { keys: [ "name" ] });
|
|
|
|
|
|
+// Color Calculations
|
|
|
|
+const getContrastingTextColor = rgb => vectorDot(rgb, [0.3, 0.6, 0.1]) >= 128 ? "#222" : "#ddd";
|
|
|
|
|
|
-// 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 ];
|
|
|
|
-};
|
|
|
|
|
|
+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);
|
|
|
|
+
|
|
|
|
+ const qJAB = [ J, a, b ];
|
|
|
|
+ const qRGB = [ rgb.r, rgb.g, rgb.b ];
|
|
|
|
|
|
-// scoring function
|
|
|
|
-const getCalculator = (closeCoeff, includeX, normQY, qRGB, qJAB) => {
|
|
|
|
- const [ qRGBNorm, qRGBHat ] = vectorNorm(qRGB);
|
|
|
|
const [ qJABNorm, qJABHat ] = vectorNorm(qJAB);
|
|
const [ qJABNorm, qJABHat ] = vectorNorm(qJAB);
|
|
- const qRGBNormSq = qRGBNorm * qRGBNorm;
|
|
|
|
- const qJABNormSq = qJABNorm * qJABNorm;
|
|
|
|
|
|
+ const qJABNormSq = qJABNorm * qJABNorm;
|
|
const [ _, qChromaHat ] = vectorNorm(qJAB.slice(1));
|
|
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),
|
|
|
|
- }
|
|
|
|
|
|
+
|
|
|
|
+ const [ qRGBNorm, qRGBHat ] = vectorNorm(qRGB);
|
|
|
|
+ const qRGBNormSq = qRGBNorm * qRGBNorm;
|
|
|
|
+ const qHueAngle = d3.hsl(rgb).h;
|
|
|
|
+
|
|
|
|
+ return {
|
|
|
|
+ qHex: rgb.formatHex(),
|
|
|
|
+ qJAB, qJABHat, qJABNorm, qJABNormSq, qChromaHat,
|
|
|
|
+ qRGB, qRGBHat, qRGBNorm, qRGBNormSq, qHueAngle,
|
|
|
|
+ };
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+// State
|
|
|
|
+const state = {
|
|
|
|
+ metric: null,
|
|
|
|
+ includeX: null,
|
|
|
|
+ normQY: null,
|
|
|
|
+ closeCoeff: null,
|
|
|
|
+ numPoke: null,
|
|
|
|
+ searchTerm: null,
|
|
|
|
+ targetColor: null,
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+// Metrics
|
|
|
|
+const scoringMetrics = [
|
|
|
|
+ ({ xJAB, xRGB, yJAB, yRGB }) => [
|
|
|
|
+ xJAB - 2 * vectorDot(yJAB, state.targetColor.qJAB),
|
|
|
|
+ xRGB - 2 * vectorDot(yRGB, state.targetColor.qRGB),
|
|
|
|
+ ],
|
|
|
|
+ ({ yJABHat, yRGBHat }) => [
|
|
|
|
+ -vectorDot(yJABHat, state.targetColor.qJABHat),
|
|
|
|
+ -vectorDot(yRGBHat, state.targetColor.qRGBHat),
|
|
|
|
+ ],
|
|
|
|
+ ({ yChromaHat, yHueAngle }) => [
|
|
|
|
+ acosDeg(vectorDot(state.targetColor.qChromaHat, yChromaHat)),
|
|
|
|
+ angleDiff(state.targetColor.qHueAngle, yHueAngle),
|
|
|
|
+ ],
|
|
|
|
+ ({ xJAB, xRGB, yJAB, yRGB, yJABHat, yRGBHat }) => [
|
|
|
|
+ (state.includeX ? xJAB : 0) - state.closeCoeff * vectorDot(
|
|
|
|
+ state.normQY ? yJABHat : yJAB,
|
|
|
|
+ state.normQY ? state.targetColor.qJABHat : state.targetColor.qJAB
|
|
|
|
+ ),
|
|
|
|
+ (state.includeX ? xRGB : 0) - state.closeCoeff * vectorDot(
|
|
|
|
+ state.normQY ? yRGBHat : yRGB,
|
|
|
|
+ state.normQY ? state.targetColor.qRGBHat : state.targetColor.qRGB
|
|
|
|
+ ),
|
|
|
|
+ ]
|
|
|
|
+];
|
|
|
|
+
|
|
|
|
+const calcDisplayMetrics = ({
|
|
|
|
+ xJAB, xRGB, yJABHat, yJABNorm, yRGBHat, yRGBNorm, yChromaHat, yHueAngle,
|
|
|
|
+}) => {
|
|
|
|
+ // TODO - case on state.metric to avoid recalculation of subterms?
|
|
|
|
+
|
|
|
|
+ const cosAngleJAB = vectorDot(state.targetColor.qJABHat, yJABHat);
|
|
|
|
+ const yTermJAB = cosAngleJAB * yJABNorm * state.targetColor.qJABNorm;
|
|
|
|
+
|
|
|
|
+ const cosAngleRGB = vectorDot(state.targetColor.qRGBHat, yRGBHat);
|
|
|
|
+ const yTermRGB = cosAngleRGB * yRGBNorm * state.targetColor.qRGBNorm;
|
|
|
|
+
|
|
|
|
+ return {
|
|
|
|
+ stdDevRGB: Math.sqrt(xRGB - 2 * yTermRGB + state.targetColor.qRGBNormSq),
|
|
|
|
+ stdDevJAB: Math.sqrt(xJAB - 2 * yTermJAB + state.targetColor.qJABNormSq),
|
|
|
|
+ angleJAB: acosDeg(cosAngleJAB),
|
|
|
|
+ angleRGB: acosDeg(cosAngleRGB),
|
|
|
|
+ chromaAngle: acosDeg(vectorDot(state.targetColor.qChromaHat, yChromaHat)),
|
|
|
|
+ hueAngle: angleDiff(state.targetColor.qHueAngle, yHueAngle),
|
|
|
|
+ };
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+// Math Rendering
|
|
|
|
+const renderQVec = (q, node, sub) => {
|
|
|
|
+ node.innerHTML = TeXZilla.toMathMLString(`\\vec{q}_{\\text{${sub}}} = \\left(${q.join(", ")}\\right)`);
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+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 updateObjective = () => {
|
|
|
|
+ let tex = metricText?.[state.metric];
|
|
|
|
+ if (!tex) {
|
|
|
|
+ const { includeX, normQY, closeCoeff } = state;
|
|
|
|
+ const xTerm = includeX ? "X\\left(P\\right)" : "";
|
|
|
|
+ const qyMod = normQY ? c => renderNorm(renderVec(c)) : renderVec;
|
|
|
|
+ tex = `\\arg\\min_{P}\\left[${xTerm}-${closeCoeff === 1 ? "" : closeCoeff}${qyMod("q")}\\cdot ${qyMod("Y\\left(P\\right)")}\\right]`;
|
|
}
|
|
}
|
|
|
|
+ const objFnNode = getObjFnDisplay();
|
|
|
|
+ clearNodeContents(objFnNode);
|
|
|
|
+ objFnNode.appendChild(TeXZilla.toMathML(tex));
|
|
};
|
|
};
|
|
|
|
|
|
-// 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;
|
|
|
|
-}
|
|
|
|
|
|
+// 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 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 renderPokemon = (data, classes = {}) => {
|
|
|
|
+ const { name, yJAB, yJABHex, yRGB, yRGBHex } = data;
|
|
|
|
+ const { stdDevJAB, stdDevRGB, angleJAB, angleRGB, chromaAngle, hueAngle } = calcDisplayMetrics(data)
|
|
|
|
+ const { labelClass = "", rgbClass = "", jabClass = "", scoreClass = "" } = classes;
|
|
|
|
+
|
|
|
|
+ const titleName = name.charAt(0).toUpperCase() + name.substr(1);
|
|
const textHex = getContrastingTextColor(yRGB);
|
|
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 rgbVec = yRGB.map(c => c.toFixed()).join(", ");
|
|
|
|
+ const jabVec = yJAB.map(c => c.toFixed(1)).join(", ");
|
|
|
|
|
|
const pkmn = document.createElement("div");
|
|
const pkmn = document.createElement("div");
|
|
pkmn.setAttribute("class", "pokemon_tile");
|
|
pkmn.setAttribute("class", "pokemon_tile");
|
|
@@ -111,210 +203,156 @@ const renderPokemon = (
|
|
</div>
|
|
</div>
|
|
<div class="pokemon_tile-score_column ${scoreClass}">
|
|
<div class="pokemon_tile-score_column ${scoreClass}">
|
|
<span class="pokemon_tile-no_flex ${jabClass}">
|
|
<span class="pokemon_tile-no_flex ${jabClass}">
|
|
- (${metrics?.stdDevJAB?.toFixed(2)}, ${metrics?.angleJAB?.toFixed(1)}°, ${metrics?.chromaAngle?.toFixed(1)}°)
|
|
|
|
|
|
+ (${stdDevJAB.toFixed(2)}, ${angleJAB.toFixed(2)}°, ${chromaAngle.toFixed(2)}°)
|
|
</span>
|
|
</span>
|
|
<span class="pokemon_tile-no_flex ${rgbClass}">
|
|
<span class="pokemon_tile-no_flex ${rgbClass}">
|
|
- (${metrics?.stdDevRGB?.toFixed(2)}, ${metrics?.angleRGB?.toFixed(1)}°, ${metrics?.hueAngle?.toFixed(1)}°)
|
|
|
|
|
|
+ (${stdDevRGB.toFixed(2)}, ${angleRGB.toFixed(2)}°, ${hueAngle.toFixed(2)}°)
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="pokemon_tile-hex_column">
|
|
<div class="pokemon_tile-hex_column">
|
|
- <div class="pokemon_tile-hex_color ${jabClass}" style="background-color: ${jabHex}; color: ${textHex}">
|
|
|
|
- <span>${jabHex}</span><span class="pokemon_tile-vector">(${jabVec})</span>
|
|
|
|
|
|
+ <div class="pokemon_tile-hex_color ${jabClass}" style="background-color: ${yJABHex}; color: ${textHex}">
|
|
|
|
+ <span>${yJABHex}</span><span class="pokemon_tile-vector">(${jabVec})</span>
|
|
</div>
|
|
</div>
|
|
- <div class="pokemon_tile-hex_color ${rgbClass}" style="background-color: ${rgbHex}; color: ${textHex}">
|
|
|
|
- <span>${rgbHex}</span><span class="pokemon_tile-vector">(${rgbVec})</span>
|
|
|
|
|
|
+ <div class="pokemon_tile-hex_color ${rgbClass}" style="background-color: ${yRGBHex}; color: ${textHex}">
|
|
|
|
+ <span>${yRGBHex}</span><span class="pokemon_tile-vector">(${rgbVec})</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
`;
|
|
return pkmn;
|
|
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 getPokemonAppender = targetList => (pokemonData, classes) => {
|
|
|
|
+ const li = document.createElement("li");
|
|
|
|
+ li.appendChild(renderPokemon(pokemonData, classes));
|
|
|
|
+ targetList.appendChild(li);
|
|
|
|
+};
|
|
|
|
|
|
-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)",
|
|
|
|
-];
|
|
|
|
|
|
+// Search
|
|
|
|
+const search = () => {
|
|
|
|
+ const resultsNode = getSearchListNode();
|
|
|
|
+ const append = getPokemonAppender(resultsNode);
|
|
|
|
+ let found;
|
|
|
|
+ if (state.searchTerm.trim().toLowerCase() === "!random") {
|
|
|
|
+ found = Array.from({ length: 10 }, () => pokemonColorData[Math.floor(Math.random() * pokemonColorData.length)]);
|
|
|
|
+ } else {
|
|
|
|
+ found = pokemonLookup.search(state.searchTerm, { limit: 10 }).map(({ item }) => item);
|
|
|
|
+ }
|
|
|
|
+ clearNodeContents(resultsNode);
|
|
|
|
+ found.forEach(item => append(item));
|
|
|
|
+};
|
|
|
|
|
|
-const metricIncludeMinus = [true, false, false, true];
|
|
|
|
|
|
+// Scoring
|
|
|
|
+const rescore = () => {
|
|
|
|
+ const metricFn = scoringMetrics[state.metric ?? 0];
|
|
|
|
+ // TODO might like to save this somewhere instead of recomputing when limit changes
|
|
|
|
+ const scores = pokemonColorData.map(data => ({ ...data, scores: metricFn(data) }));
|
|
|
|
+
|
|
|
|
+ const jabList = getScoreListJABNode();
|
|
|
|
+ const appendJAB = getPokemonAppender(jabList);
|
|
|
|
+ const rgbList = getScoreListRGBNode();
|
|
|
|
+ const appendRGB = getPokemonAppender(rgbList);
|
|
|
|
+
|
|
|
|
+ // extract best CIECAM02 results
|
|
|
|
+ const bestJAB = scores
|
|
|
|
+ .sort((a, b) => a.scores[0] - b.scores[0])
|
|
|
|
+ .slice(0, state.numPoke);
|
|
|
|
+ clearNodeContents(jabList);
|
|
|
|
+ bestJAB.forEach(data => appendJAB(data, { labelClass: "hide", rgbClass: "hide" }));
|
|
|
|
+
|
|
|
|
+ // extract best RGB results
|
|
|
|
+ const bestRGB = scores
|
|
|
|
+ .sort((a, b) => a.scores[1] - b.scores[1])
|
|
|
|
+ .slice(0, state.numPoke);
|
|
|
|
+ clearNodeContents(rgbList);
|
|
|
|
+ bestRGB.forEach(data => appendRGB(data, { labelClass: "hide", jabClass: "hide" }));
|
|
|
|
+
|
|
|
|
+ // update the rendered search results as well
|
|
|
|
+ search();
|
|
|
|
+};
|
|
|
|
|
|
-const renderMath = (metric, includeX, normQY, closeCoeff) => {
|
|
|
|
- const found = metricText?.[metric];
|
|
|
|
- if (found) {
|
|
|
|
- return found;
|
|
|
|
|
|
+// Listeners
|
|
|
|
+const onColorChanged = skipScore => {
|
|
|
|
+ const readColor = readColorInput();
|
|
|
|
+ if (readColor) {
|
|
|
|
+ state.targetColor = readColor;
|
|
|
|
+
|
|
|
|
+ renderQVec(state.targetColor.qJAB.map(c => c.toFixed(2)), getQJABDisplay(), "Jab");
|
|
|
|
+ renderQVec(state.targetColor.qRGB.map(c => c.toFixed()), getQRGBDisplay(), "RGB");
|
|
|
|
+
|
|
|
|
+ const textColor = getContrastingTextColor(state.targetColor.qRGB);
|
|
|
|
+ document.querySelector("body").setAttribute("style", `background: ${state.targetColor.qHex}; color: ${textColor}`);
|
|
|
|
+ state.targetColor
|
|
|
|
+ if (!skipScore) {
|
|
|
|
+ rescore();
|
|
|
|
+ }
|
|
}
|
|
}
|
|
- 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 onRandomColor = () => {
|
|
|
|
+ const color = [Math.random(), Math.random(), Math.random()].map(c => c * 255);
|
|
|
|
+ getColorInputNode().value = d3.rgb(...color).formatHex();
|
|
|
|
+ onColorChanged(); // triggers rescore
|
|
|
|
+};
|
|
|
|
|
|
-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 onCustomControlsChanged = skipScore => {
|
|
|
|
+ state.includeX = getIncludeXToggleNode()?.checked ?? false;
|
|
|
|
+ state.normQY = getNormQYToggleNode()?.checked ?? false;
|
|
|
|
+ state.closeCoeff = parseFloat(getCloseCoeffSliderNode()?.value ?? 2);
|
|
|
|
|
|
-const readColor = rgb => {
|
|
|
|
- const { J, a, b } = d3.jab(rgb);
|
|
|
|
- return [[rgb.r, rgb.g, rgb.b], [J, a, b]];
|
|
|
|
-}
|
|
|
|
|
|
+ getCloseCoeffDisplayNode().innerHTML = state.closeCoeff;
|
|
|
|
+ updateObjective();
|
|
|
|
|
|
-const onUpdate = (event) => {
|
|
|
|
- if (event) {
|
|
|
|
- event.preventDefault();
|
|
|
|
|
|
+ if (!skipScore) {
|
|
|
|
+ rescore();
|
|
}
|
|
}
|
|
|
|
+}
|
|
|
|
|
|
- // 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 onMetricChanged = skipScore => {
|
|
|
|
+ const metric = getMetricDropdownNode()?.selectedIndex ?? 0;
|
|
|
|
+ if (metric === state.metric) {
|
|
|
|
+ return;
|
|
}
|
|
}
|
|
-
|
|
|
|
- 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 = '';
|
|
|
|
|
|
+ state.metric = metric;
|
|
|
|
+ if (state.metric === 3) { // Custom
|
|
|
|
+ showCustomControls();
|
|
|
|
+ onCustomControlsChanged(skipScore); // triggers rescore
|
|
|
|
+ } else {
|
|
|
|
+ hideCustomControls();
|
|
|
|
+ updateObjective();
|
|
|
|
+ if (!skipScore) {
|
|
|
|
+ rescore();
|
|
|
|
+ }
|
|
}
|
|
}
|
|
|
|
+};
|
|
|
|
|
|
- // Check if parameters have changed
|
|
|
|
- const newParams = paramsChanged(metric, includeX, normQY, closeCoeff, useRGB, numPoke, colorInput);
|
|
|
|
|
|
+const onLimitChanged = skipScore => {
|
|
|
|
+ state.numPoke = parseInt(getLimitSliderNode()?.value ?? 10);
|
|
|
|
|
|
- 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)));
|
|
|
|
- }
|
|
|
|
|
|
+ getLimitDisplayNode().textContent = state.numPoke;
|
|
|
|
|
|
- // 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);
|
|
|
|
- });
|
|
|
|
- }
|
|
|
|
|
|
+ if (!skipScore) {
|
|
|
|
+ // TODO don't need to rescore just need to expand
|
|
|
|
+ rescore();
|
|
}
|
|
}
|
|
|
|
+};
|
|
|
|
|
|
- // 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);
|
|
|
|
- });
|
|
|
|
|
|
+const onSearchChanged = skipSearch => {
|
|
|
|
+ state.searchTerm = getNameInputNode()?.value?.toLowerCase() ?? "";
|
|
|
|
+ if (state.searchTerm.length === 0) {
|
|
|
|
+ clearNodeContents(getSearchListNode());
|
|
|
|
+ } else if (!skipSearch) {
|
|
|
|
+ search();
|
|
}
|
|
}
|
|
- lastPkmnSearch = pokemonName;
|
|
|
|
};
|
|
};
|
|
|
|
|
|
-const onRandomColor = () => {
|
|
|
|
- document.getElementById("color-input").value = rgb2hex([Math.random(), Math.random(), Math.random()].map(c => c * 255));
|
|
|
|
- onUpdate();
|
|
|
|
|
|
+const onPageLoad = () => {
|
|
|
|
+ // fake some events but don't do any searching or scoring
|
|
|
|
+ onSearchChanged(true);
|
|
|
|
+ onColorChanged(true);
|
|
|
|
+ onMetricChanged(true);
|
|
|
|
+ onLimitChanged(true);
|
|
|
|
+ // then do a rescore directly, which will itself trigger a search
|
|
|
|
+ rescore();
|
|
};
|
|
};
|