Эх сурвалжийг харах

What if we just report literally everything

Kirk Trombley 3 жил өмнө
parent
commit
cdc4e37711
3 өөрчлөгдсөн 165 нэмэгдсэн , 74 устгасан
  1. 23 7
      nearest.css
  2. 19 5
      nearest.html
  3. 123 62
      nearest.js

+ 23 - 7
nearest.css

@@ -73,6 +73,21 @@ body {
     align-items: flex-end;
 }
 
+.hideable_control {
+    /* container + control, copied to a single class for toggling */
+    width: 100%;
+    display: flex;
+    flex-flow: row nowrap;
+    justify-content: space-between;
+    align-items: flex-start;
+    height: 32px;
+    align-items: flex-end;
+}
+
+.hideable_control--hidden {
+    display: none;
+}
+
 .pkmn-list {
     list-style-type: none;
     padding: 0;
@@ -107,6 +122,7 @@ body {
 }
 
 .pokemon_tile-info_panel {
+    flex: 1;
     display: flex;
     flex-flow: column nowrap;
     justify-content: flex-start;
@@ -118,6 +134,7 @@ body {
 }
 
 .pokemon_tile-results {
+    width: 100%;
     display: flex;
 }
 
@@ -131,7 +148,7 @@ body {
 
 .pokemon_tile-score_column {
     margin-left: 4px;
-    min-width: 6em;
+    min-width: 10em;
     display: flex;
     flex-flow: column nowrap;
 }
@@ -141,7 +158,8 @@ body {
 }
 
 .pokemon_tile-hex_column {
-    flex: 0 1 10%;
+    /* flex: 1 1 10%; */
+    flex: 1;
     margin-left: 8px;
     display: flex;
     flex-flow: column nowrap;
@@ -151,13 +169,11 @@ body {
     flex: 1;
     padding: 0 0.5em 0 0.5em;
     display: inline-flex;
+    justify-content: space-between;
     align-items: center;
-    justify-content: center;
     font-size: 10px;
 }
 
-.pokemon_tile-vector_column {
-    margin-left: 4px;
-    display: flex;
-    flex-flow: column nowrap;
+.pokemon_tile-vector {
+    margin-left: 8px;
 }

+ 19 - 5
nearest.html

@@ -27,16 +27,26 @@
                 </div>
 
                 <div class="container control">
+                    <label for="metric">Metric:</label>
+                    <select type="checkbox" onchange="onUpdate()" id="metric">
+                        <option selected>RMS/Std Dev</option>
+                        <option>Mean Angle</option>
+                        <option>Chroma/Hue Angle</option>
+                        <option>Custom</option>
+                    </select>
+                </div>
+
+                <div class="hideable_control hideable_control--hidden">
                     <label for="include-x">Include X:</label>
                     <input type="checkbox" checked oninput="onUpdate()" id="include-x">
                 </div>
 
-                <div class="container control">
+                <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">
                 </div>
 
-                <div class="container control">
+                <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">
                 </div>
@@ -66,9 +76,13 @@
                 </div>
 
                 <div class="container center-aligned center-justified">
-                    <div class="panel">
-                        <span id="obj-fn"></span>
-                    </div>
+                    <span style="margin-right: 0.5em;">Optimizing:</span>
+                    <span id="obj-fn"></span>
+                </div>
+
+                <div class="container center-aligned center-justified">
+                    <span style="margin-right: 0.5em;">Displaying:</span>
+                    <math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mo>(</mo><mrow><msub><mtext>RMS</mtext><mi>P</mi></msub><mrow><mo>(</mo><mi>q</mi><mo>)</mo></mrow><mo>,</mo><mo>∠</mo><mrow><mo>(</mo><mrow><mover><mi>q</mi><mo stretchy="false">⇀</mo></mover><mo>,</mo><mover><mi>Y</mi><mo stretchy="false">⇀</mo></mover><mrow><mo>(</mo><mi>P</mi><mo>)</mo></mrow></mrow><mo>)</mo></mrow><mo>,</mo><mo>∠</mo><mrow><mo>(</mo><mrow><msub><mover><mi>q</mi><mo stretchy="false">⇀</mo></mover><mo>⊥</mo></msub><mo>,</mo><mover><mi>Y</mi><mo stretchy="false">⇀</mo></mover><msub><mrow><mo>(</mo><mi>P</mi><mo>)</mo></mrow><mo>⊥</mo></msub></mrow><mo>)</mo></mrow></mrow><mo>)</mo></mrow><annotation encoding="TeX">\\left( \\text{RMS}_P\\left(q\\right), \\angle \\left(\\vec{q}, \\vec{Y}\\left(P\\right)\\right), \\angle \\left( \\vec{q}_{\\perp} , \\vec{Y}\\left(P\\right)_{\\perp} \\right) \\right)</annotation></semantics></math>
                 </div>
             </div>
 

+ 123 - 62
nearest.js

@@ -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)}&deg;, ${metrics?.chromaAngle?.toFixed(1)}&deg;)
           </span>
           <span class="pokemon_tile-no_flex ${rgbClass}">
-            ${scoreRGB?.toFixed(2)}
+            (${metrics?.stdDevRGB?.toFixed(2)}, ${metrics?.angleRGB?.toFixed(1)}&deg;, ${metrics?.hueAngle?.toFixed(1)}&deg;)
           </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))