Kirk Trombley 3 gadi atpakaļ
vecāks
revīzija
dd5ae6e98c
2 mainītis faili ar 301 papildinājumiem un 263 dzēšanām
  1. 10 10
      nearest.html
  2. 291 253
      nearest.js

+ 10 - 10
nearest.html

@@ -11,24 +11,24 @@
     <script src="https://unpkg.com/texzilla@1.0.2/TeXZilla.js"></script>
     <script src="database.js"></script>
     <script src="nearest.js"></script>
-    <script lang="javascript">window.onload = () => { onUpdate(); }</script>
+    <script lang="javascript">window.onload = () => { onPageLoad(); }</script>
 </head>
 
 <body>
     <noscript>Requires javascript</noscript>
     <div class="container start-justified">
         <div id="left-panel" class="padded panel">
-            <form class="panel config" onsubmit="onUpdate(event)">
+            <form class="panel config" onsubmit="event.preventDefault()">
                 <div class="container control">
                     <img src="https://img.pokemondb.net/sprites/sword-shield/icon/bulbasaur.png" />
                     <button class="padded" type="button" onclick="onRandomColor()">Random color</button>
-                    <input size="7" maxlength="7" id="color-input" oninput="onUpdate()"
+                    <input size="7" maxlength="7" id="color-input" oninput="onColorChanged()"
                         value="#ffffff" />
                 </div>
 
                 <div class="container control">
                     <label for="metric">Metric:</label>
-                    <select type="checkbox" onchange="onUpdate()" id="metric">
+                    <select type="checkbox" onchange="onMetricChanged()" id="metric">
                         <option selected>RMS/Std Dev</option>
                         <option>Mean Angle</option>
                         <option>Chroma/Hue Angle</option>
@@ -38,24 +38,24 @@
 
                 <div class="hideable_control hideable_control--hidden">
                     <label for="include-x">Include X:</label>
-                    <input type="checkbox" checked oninput="onUpdate()" id="include-x">
+                    <input type="checkbox" checked oninput="onCustomControlsChanged()" id="include-x">
                 </div>
 
                 <div class="hideable_control hideable_control--hidden">
                     <label for="norm-q-y">Normalize q and Y:</label>
-                    <input type="checkbox" oninput="onUpdate()" id="norm-q-y">
+                    <input type="checkbox" oninput="onCustomControlsChanged()" id="norm-q-y">
                 </div>
 
                 <div class="hideable_control hideable_control--hidden">
                     <label for="close-coeff">Closeness coefficient: <span id="close-coeff-display">2</span></label>
-                    <input type="range" min="0" max="10" value="2" step="0.1" oninput="onUpdate()" id="close-coeff">
+                    <input type="range" min="0" max="10" value="2" step="0.1" oninput="onCustomControlsChanged()" id="close-coeff">
                 </div>
 
                 <div class="container control">
                     <label for="num-poke" style="min-width: 200px;">
                         Search limit: <span id="num-poke-display">10</span>
                     </label>
-                    <input type="range" min="1" max="100" value="10" oninput="onUpdate()" id="num-poke">
+                    <input type="range" min="1" max="100" value="10" oninput="onLimitChanged()" id="num-poke">
                 </div>
             </form>
             
@@ -87,9 +87,9 @@
             </div>
 
             <div class="panel bypkmn">
-                <form class="container control" onsubmit="onUpdate(event)">
+                <form class="container control" onsubmit="event.preventDefault(); onSearchChanged()">
                     <label for="pokemon-name">Search By Pokemon</label>
-                    <input id="pokemon-name" size="15" oninput="onUpdate()">
+                    <input id="pokemon-name" size="15" oninput="onSearchChanged()">
                 </form>
                 <ul id="search-list" class="pkmn-list"></ul>
             </div>

+ 291 - 253
nearest.js

@@ -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 vectorMag = v => Math.sqrt(vectorDot(v, 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 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 qRGBNormSq = qRGBNorm * qRGBNorm;
-  const qJABNormSq = qJABNorm * qJABNorm;
+  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),
-    }
+
+  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 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");
   pkmn.setAttribute("class", "pokemon_tile");
@@ -111,210 +203,156 @@ const renderPokemon = (
         </div>
         <div class="pokemon_tile-score_column ${scoreClass}">
           <span class="pokemon_tile-no_flex ${jabClass}">
-            (${metrics?.stdDevJAB?.toFixed(2)}, ${metrics?.angleJAB?.toFixed(1)}&deg;, ${metrics?.chromaAngle?.toFixed(1)}&deg;)
+            (${stdDevJAB.toFixed(2)}, ${angleJAB.toFixed(2)}&deg;, ${chromaAngle.toFixed(2)}&deg;)
           </span>
           <span class="pokemon_tile-no_flex ${rgbClass}">
-            (${metrics?.stdDevRGB?.toFixed(2)}, ${metrics?.angleRGB?.toFixed(1)}&deg;, ${metrics?.hueAngle?.toFixed(1)}&deg;)
+            (${stdDevRGB.toFixed(2)}, ${angleRGB.toFixed(2)}&deg;, ${hueAngle.toFixed(2)}&deg;)
           </span>
         </div>
         <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 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>
   `;
   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();
 };