Prechádzať zdrojové kódy

Rewrite web version to run both spaces at once

Kirk Trombley 3 rokov pred
rodič
commit
8921111f86
3 zmenil súbory, kde vykonal 269 pridanie a 119 odobranie
  1. 83 7
      nearest.css
  2. 39 40
      nearest.html
  3. 147 72
      nearest.js

+ 83 - 7
nearest.css

@@ -4,6 +4,10 @@ body {
     font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
 }
 
+.hide {
+    display: none !important;
+}
+
 .container {
     width: 100%;
     display: flex;
@@ -13,14 +17,17 @@ body {
 }
 
 .panel {
-    width: 100%;
-    flex: 1;
     display: flex;
     flex-flow: column nowrap;
     justify-content: flex-start;
     align-items: flex-start;
 }
 
+.config {
+    width: 100%;
+    margin-bottom: 16px;
+}
+
 .center-aligned {
     align-items: center;
 }
@@ -42,9 +49,8 @@ body {
     margin-right: 8px;
 }
 
-#left-panel {
-    min-width: 400px;
-    max-width: 500px;
+.title {
+    font-weight: 1000;
 }
 
 .bycolor {
@@ -76,8 +82,8 @@ body {
 }
 
 .color-tile {
-    width: 50px; 
-    height: 32px; 
+    width: 50px;
+    height: 32px;
     font-size: 10px;
     text-align: center;
     display: inline-flex;
@@ -104,4 +110,74 @@ body {
     margin-top: 32px;
     padding-top: 8px;
     padding-right: 8px;
+    min-width: 500px;
+}
+
+/* Pokemon Tile */
+
+.pokemon_tile {
+    padding-top: 0.5em;
+    padding-bottom: 0.5em;
+    display: flex;
+    flex-flow: row nowrap;
+    justify-content: flex-start;
+    align-items: flex-start;
+    min-width: 500px;
+    width: 100%;
+}
+
+.pokemon_tile-info_panel {
+    display: flex;
+    flex-flow: column nowrap;
+    justify-content: flex-start;
+    align-items: flex-start;
+}
+
+.pokemon_tile-pokemon_name {
+    font-weight: 1000;
+}
+
+.pokemon_tile-results {
+    display: flex;
 }
+
+.pokemon_tile-labels {
+    text-align: right;
+    margin-left: 16px;
+    margin-right: 4px;
+    display: flex;
+    flex-flow: column nowrap;
+}
+
+.pokemon_tile-score_column {
+    margin-left: 4px;
+    min-width: 10em;
+    display: flex;
+    flex-flow: column nowrap;
+}
+
+.pokemon_tile-no_flex {
+    flex: 0 0 auto;
+}
+
+.pokemon_tile-hex_column {
+    flex: 0 1 10%;
+    margin-left: 8px;
+    display: flex;
+    flex-flow: column nowrap;
+}
+
+.pokemon_tile-hex_color {
+    flex: 1;
+    padding: 0 0.5em 0 0.5em;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 10px;
+}
+
+.pokemon_tile-vector_column {
+    margin-left: 4px;
+    display: flex;
+    flex-flow: column nowrap;
+}

+ 39 - 40
nearest.html

@@ -18,6 +18,37 @@
     <noscript>Requires javascript</noscript>
     <div class="container">
         <div id="left-panel" class="padded panel">
+            <form class="panel config" onsubmit="onUpdate(event)">
+                <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()"
+                        value="#ffffff" />
+                </div>
+
+                <div class="container control">
+                    <label for="include-x">Include X:</label>
+                    <input type="checkbox" checked oninput="onUpdate()" id="include-x">
+                </div>
+
+                <div class="container control">
+                    <label for="norm-q-y">Normalize q and Y:</label>
+                    <input type="checkbox" oninput="onUpdate()" id="norm-q-y">
+                </div>
+
+                <div class="container control">
+                    <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>
+
+                <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">
+                </div>
+            </form>
+
             <div class="container center-aligned">
                 <div>
                     <math xmlns="http://www.w3.org/1998/Math/MathML">
@@ -108,30 +139,10 @@
                     </math>
                 </div>
             </div>
+
             <div class="container center-aligned">
                 Minimizing: <span id="obj-fn">X(P) - 2q&sdot;Y(P)</span>
             </div>
-            <form class="panel" onsubmit="onUpdate(event)">
-                <div class="container control">
-                    <label for="include-x">Include X:</label>
-                    <input type="checkbox" checked oninput="onUpdate()" id="include-x">
-                </div>
-
-                <div class="container control">
-                    <label for="norm-q-y">Normalize q and Y:</label>
-                    <input type="checkbox" oninput="onUpdate()" id="norm-q-y">
-                </div>
-
-                <div class="container control">
-                    <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>
-
-                <div class="container control">
-                    <div>Color Space: <span id="color-space">CAM02-UCS</span></div>
-                    <button id="space-toggle" type="button" onclick="onToggleSpace()">Swap to RGB</button>
-                </div>
-            </form>
 
             <div class="panel bypkmn">
                 <form class="container control" onsubmit="onUpdate(event)">
@@ -141,25 +152,13 @@
                 <ul id="search-list" class="pkmn-list"></ul>
             </div>
         </div>
-        <div class="padded panel bycolor">
-            <div>
-                Search By Color - click random, or enter six digit hex code (optional #)
-            </div>
-            <form class="panel" onsubmit="onUpdate(event)">
-                <div class="container bycolor_l1">
-                    <button class="padded" type="button" onclick="onRandomColor()">Random color</button>
-                    <input class="margined" size="7" maxlength="7" id="color-input" oninput="onUpdate()"
-                        value="#ffffff" />
-                    <img src="https://img.pokemondb.net/sprites/sword-shield/icon/bulbasaur.png" />
-                </div>
-
-                <div class="container bycolor_l2">
-                    <label for="num-poke" style="min-width: 200px;">Number to find: <span
-                            id="num-poke-display">10</span></label>
-                    <input type="range" min="1" max="100" value="10" oninput="onUpdate()" id="num-poke">
-                </div>
-            </form>
-            <ul id="best-list" class="pkmn-list"></ul>
+        <div class="padded panel">
+            <div class="title">CIECAM02 Uniform Color Space</div>
+            <ul id="best-list-jab" class="pkmn-list"></ul>
+        </div>
+        <div class="padded panel" style="flex: 1">
+            <div class="title">sRGB Color Space</div>
+            <ul id="best-list-rgb" class="pkmn-list"></ul>
         </div>
     </div>
 </body>

+ 147 - 72
nearest.js

@@ -17,6 +17,8 @@ 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 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
@@ -33,11 +35,18 @@ const hex2rgb = hex => {
 };
 
 // scoring functions
-const getNormedScorer = (c, q) => {
-  const factor = c / vectorMag(q);
-  return yVec => factor * vectorDot(q, yVec) / vectorMag(yVec);
+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),
+  });
 };
-const getUnnormedScorer = (c, q) => yVec => c * vectorDot(q, yVec);
+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 => {
@@ -48,22 +57,52 @@ const createTile = hexColor => {
   return tile;      
 }
 
-const createPokemon = ({ name, score, yRGB, yJAB }) => {
-  const img = document.createElement("img");
-  img.setAttribute("src", getSprite(name));
-
+const renderPokemon = (
+  { name, scoreRGB = null, scoreJAB = null, yRGB, yJAB },
+  { rgbClass = "", jabClass = "" } = {},
+) => {
   const titleName = titleCase(name);
-  const text = score ? `${titleName}: ${score.toFixed(3)}` : titleName
-
+  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(2)).join(", ")
+  const scoreClass = scoreRGB === null || scoreJAB === null ? "hide" : "";
+  
   const pkmn = document.createElement("div");
-  pkmn.setAttribute("class", "pokemon");
-  pkmn.appendChild(img);
-  const textSpan = document.createElement("span");
-  textSpan.textContent = text;
-  textSpan.setAttribute("class", "pokemon_text");
-  pkmn.appendChild(textSpan);
-  pkmn.appendChild(createTile(rgb2hex(yRGB)));
-  pkmn.appendChild(createTile(jab2hex(yJAB)));
+  pkmn.setAttribute("class", "pokemon_tile");
+  pkmn.innerHTML = `
+    <img src="${getSprite(name)}" />
+    <div class="pokemon_tile-info_panel">
+      <span class="pokemon_tile-pokemon_name">${titleName}</span>
+      <div class="pokemon_tile-results">
+        <div class="pokemon_tile-labels">
+          <span class="${jabClass}">Jab: </span>
+          <span class="${rgbClass}">RGB: </span>
+        </div>
+        <div class="pokemon_tile-score_column ${scoreClass}">
+          <span class="pokemon_tile-no_flex ${jabClass}">
+            ${scoreJAB?.toFixed(2)}
+          </span>
+          <span class="pokemon_tile-no_flex ${rgbClass}">
+            ${scoreRGB?.toFixed(2)}
+          </span>
+        </div>
+        <div class="pokemon_tile-hex_column">
+          <div class="pokemon_tile-hex_color ${jabClass}" style="background-color: ${jabHex}; color: ${textHex}">
+            ${jabHex}
+          </div>
+          <div class="pokemon_tile-hex_color ${rgbClass}" style="background-color: ${rgbHex}; color: ${textHex}">
+            ${rgbHex}
+          </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;
 }
 
@@ -80,10 +119,21 @@ const renderVec = math => `\\vec{${math.charAt(0)}}${math.substr(1)}`;
 
 const renderNorm = vec => `\\frac{${vec}}{\\left|\\left|${vec}\\right|\\right|}`;
 
-const renderMath = (includeX, normQY) => {
+const renderMath = (includeX, normQY, closeCoeff) => {
   const xTerm = includeX ? "X\\left(P\\right)" : "";
   const qyMod = normQY ? c => renderNorm(renderVec(c)) : renderVec;
-  return TeXZilla.toMathML(`${xTerm}-2${qyMod("q")}\\cdot ${qyMod("Y\\left(P\\right)")}`);
+  return TeXZilla.toMathML(`${xTerm}-${closeCoeff}${qyMod("q")}\\cdot ${qyMod("Y\\left(P\\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) => {
@@ -98,60 +148,93 @@ const onUpdate = (event) => {
   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 targetColor = "#" + (document.getElementById("color-input")?.value?.replace("#", "") ?? "FFFFFF");
-  const targetRGB = hex2rgb(targetColor);
-
-  // Update display values
-  // document.getElementById("x-term").textContent = includeX ? "X(P)" : "";
-  // document.getElementById("c-value").textContent = closeCoeff;
-  // document.getElementById("q-vec").innerHTML = normQY ? "<mover><m" : "q";
-  // document.getElementById("y-vec").textContent = normQY ? "Ŷ(P)" : "Y(P)";
-  // document.getElementById("close-coeff-display").innerHTML = closeCoeff;
-  // document.getElementById("num-poke-display").textContent = numPoke;
-  const objFnElem = document.getElementById("obj-fn");
-  objFnElem.innerHTML = "";
-  objFnElem.appendChild(renderMath(includeX, normQY));
+  const colorInput = "#" + (document.getElementById("color-input")?.value?.replace("#", "") ?? "FFFFFF");
+
+  // Check if parameters have changed
+  const newParams = paramsChanged(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(renderMath(includeX, normQY, closeCoeff));
+  }
+
+  // Only modified if current color is valid
+  let totalScorer = info => info;
+
+  // 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);
+    // TODO render q vectors somewhere
+
+    // 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,
+      }
+    };
+
+    // Rescore Pokemon and update lists if config has changed
+    if (newParams) {
+      const scored = database.map(info => totalScorer(info));
   
-  // determine metrics from configuration
-  const targetInSpace = useRGB ? targetRGB : rgb2jab(targetRGB);
-  const xSelector = includeX ? (useRGB ? ({ xRGB }) => xRGB : ({ xJAB }) => xJAB) : () => 0;
-  const ySelector = useRGB ? ({ yRGB }) => yRGB : ({ yJAB }) => yJAB;
-  const yScorer = (normQY ? getNormedScorer : getUnnormedScorer)(closeCoeff, targetInSpace);
-  const totalScorer = info => xSelector(info) - yScorer(ySelector(info));
-
-  const newParams = paramsChanged(includeX, normQY, closeCoeff, useRGB, numPoke, targetColor);
-
-  if (targetColor.length === 7 && newParams) {
-    // calculate luminance to determine if text should be dark or light
-    const textColor = vectorDot(targetRGB, [0.3, 0.6, 0.1]) >= 128 ? "#222" : "#ddd";
-    document.querySelector("body").setAttribute("style", `background: ${targetColor}; color: ${textColor}`);
-
-    const bestList = document.getElementById("best-list");
-    bestList.innerHTML = ''; // do the lazy thing
+      const bestListRGB = document.getElementById("best-list-rgb");
+      bestListRGB.innerHTML = '';
+      scored
+        .sort((a, b) => a.scoreRGB - b.scoreRGB)
+        .slice(0, numPoke)
+        .forEach(info => {
+          const li = document.createElement("li");
+          li.appendChild(renderPokemon(info, { jabClass: "hide" }))
+          bestListRGB.appendChild(li);
+        });
   
-    // actually score pokemon
-    database
-      .map(info => ({ ...info, score: totalScorer(info) }))
-      .sort((a, b) => a.score - b.score)
-      .slice(0, numPoke)
-      .forEach(info => {
-        const li = document.createElement("li");
-        li.appendChild(createPokemon(info))
-        bestList.appendChild(li);
-      });
+      const bestListJAB = document.getElementById("best-list-jab");
+      bestListJAB.innerHTML = '';
+      scored
+        .sort((a, b) => a.scoreJAB - b.scoreJAB)
+        .slice(0, numPoke)
+        .forEach(info => {
+          const li = document.createElement("li");
+          li.appendChild(renderPokemon(info, { rgbClass: "hide" }))
+          bestListJAB.appendChild(li);
+        });
+    }
   }
 
-  if (pokemonName.length > 0 && (lastPkmnSearch !== pokemonName || newParams)) {
+  // Lookup by name
+  if (pokemonName.length === 0) {
+    const searchList = document.getElementById("search-list");
+    searchList.innerHTML = '';
+  } else if (lastPkmnSearch !== pokemonName || newParams) {
+    // Update last search
     lastPkmnSearch = pokemonName;
-    // lookup by pokemon too
+
     const searchList = document.getElementById("search-list");
     searchList.innerHTML = '';
     pokemonLookup
-      .search(pokemonName, { limit: 15 })
-      .map(({ item }) => ({ ...item, score: totalScorer(item) }))
+      .search(pokemonName, { limit: 10 })
+      // If scoring is impossible, totalScorer will just be identity
+      .map(({ item }) => totalScorer(item))
       .forEach(item => {
         const li = document.createElement("li");
-        li.appendChild(createPokemon(item))
+        li.appendChild(renderPokemon(item))
         searchList.appendChild(li);
       });
   }
@@ -161,11 +244,3 @@ const onRandomColor = () => {
   document.getElementById("color-input").value = rgb2hex([Math.random(), Math.random(), Math.random()].map(c => c * 255));
   onUpdate();
 };
-
-const onToggleSpace = () => {
-  const element = document.getElementById("color-space");
-  const current = element?.textContent;
-  element.textContent = current === "RGB" ? "CAM02-UCS" : "RGB";
-  document.getElementById("space-toggle").textContent = `Swap to ${current}`
-  onUpdate();
-};