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

Update web implementation with color space logic

Kirk Trombley 3 жил өмнө
parent
commit
f05c2ef933
2 өөрчлөгдсөн 170 нэмэгдсэн , 71 устгасан
  1. 111 20
      nearest.html
  2. 59 51
      nearest.js

+ 111 - 20
nearest.html

@@ -3,31 +3,122 @@
     <head>
         <meta charset="utf-8" />
         <title>Pokemon By Color</title>
+        <script src="https://www.unpkg.com/jquery@3.6.0/dist/jquery.min.js"></script>
+        <script src="https://unpkg.com/colorspaces@0.1.5/colorspaces.js"></script>
         <script src="database.js"></script>
         <script src="nearest.js"></script>
+        <script lang="javascript">window.onload = () => { onUpdate(); }</script>
+        <style>
+            body {
+                width: 100vw;
+            }
+
+            .container {
+                width: 100%;
+                display: flex;
+                flex-flow: row nowrap;
+                justify-content: space-between;
+                align-items: flex-start;
+            }
+
+            .panel {
+                width: 100%;
+                flex: 1;
+                display: flex;
+                flex-flow: column nowrap;
+                justify-content: flex-start;
+                align-items: flex-start;
+            }
+
+            .padded {
+                padding-left: 16px;
+                padding-right: 16px;
+            }
+
+            .margined {
+                margin-left: 8px;
+                margin-right: 8px;
+            }
+
+            #left-panel {
+                min-width: 400px;
+                max-width: 500px;
+            }
+
+            .bycolor {
+                flex: 2;
+                border-left: 4px solid #222;
+            }
+
+            .bycolor_l1 {
+                justify-content: flex-start;
+                align-items: flex-end;
+            }
+
+            .bycolor_l2 {
+                padding-top: 16px;
+                justify-content: flex-start;
+            }
+
+            .control {
+                height: 32px;
+                align-items: flex-end;
+            }
+        </style>
     </head>
     <body>
         <noscript>Requires javascript</noscript>
-        <div id="root">
-            <div>Click random, or enter six digit hex code (no #) and press enter</div>
-            <button onclick="onRandomColor()">Random color</button>
-            <input id="color-input" onchange="onColorChange()" />
-            <img src="https://img.pokemondb.net/sprites/sword-shield/icon/bulbasaur.png" />
-            <br/>
-            <span>Include X:</span>
-            <input type="checkbox" checked oninput="onColorChange()" id="include-x">
-            <br/>
-            <span>Normalize q and Y:</span>
-            <input type="checkbox" oninput="onColorChange()" id="norm-q-y">
-            <br/>
-            <span>Number to find:</span>
-            <input type="range" min="1" max="100" value="20" oninput="onColorChange()" id="num-poke">
-            <span id="num-poke-display">20</span>
-            <br/>
-            <span>Closeness coefficient:</span>
-            <input type="range" min="0" max="10" value="2" step="0.1" oninput="onColorChange()" id="close-coeff">
-            <span id="close-coeff-display">2</span>
-            <ul id="best-list"></ul>
+        <div class="container">
+            <div id="left-panel" class="padded panel">
+                <div>
+                    Minimizing:
+                    <span id="x-term">X(P)</span>
+                    <span> -</span>
+                    <span id="c-value">2</span>
+                    <span id="q-vec">q</span>
+                    <span> · </span>
+                    <span id="y-vec">Y(P)</span>
+                </div>
+                <form class="panel" onsubmit="onUpdate()" action="#">
+                    <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">CIELUV</span></div>
+                        <button id="space-toggle" onclick="onToggleSpace()">Swap to RGB</button>
+                    </div>
+                </form>
+            </div>
+            <div class="padded panel bycolor">
+                <div>
+                    Search By Color - click random, or enter six digit hex code (optional #) and press enter
+                </div>
+                <form class="panel" onsubmit="onUpdate()" action="#">
+                    <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" onchange="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"></ul>
+            </div>
         </div>
     </body>
 </html>

+ 59 - 51
nearest.js

@@ -1,51 +1,71 @@
 const getSprite = pokemon => `https://img.pokemondb.net/sprites/sword-shield/icon/${pokemon}.png`
 
-const vectorMag = v => Math.sqrt(v.map(x => x * x).reduce((x, y) => x + y));
+const vectorDot = (u, v) => u.map((x, i) => x * v[i]).reduce((x, y) => x + y);
 
-const onColorChange = () => {
-  const numPoke = document.getElementById("num-poke")?.value;
-  document.getElementById("num-poke-display").innerHTML = numPoke;
+const vectorMag = v => Math.sqrt(vectorDot(v, v));
 
-  const closeCoeff = document.getElementById("close-coeff")?.value;
-  document.getElementById("close-coeff-display").innerHTML = closeCoeff;
+// hex codes already include leading # in these functions
+// rgb values are [0, 1] in these functions
+const hex2luv = $.colorspaces.converter("hex", "CIELUV");
+const luv2hex = $.colorspaces.converter("CIELUV", "hex");
+const rgb2luv = $.colorspaces.converter("sRGB", "CIELUV");
+const luv2rgb = $.colorspaces.converter("CIELUV", "sRGB");
+const rgb2hex = $.colorspaces.converter("sRGB", "hex");
+const hex2rgb = $.colorspaces.converter("hex", "sRGB");
+
+// scoring functions
+const getNormedScorer = (c, q) => {
+  const factor = c / vectorMag(q);
+  return yVec => factor * vectorDot(q, yVec) / vectorMag(yVec);
+};
+const getUnnormedScorer = (c, q) => yVec => c * vectorDot(q, yVec);
 
+const onUpdate = () => {
+  // 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 useRGB = document.getElementById("color-space")?.textContent === "RGB";
+  const numPoke = document.getElementById("num-poke")?.value ?? 20;
+  const pokemonName = document.getElementById("pokemon-name")?.value ?? "";
+  const targetColor = "#" + (document.getElementById("color-input")?.value?.replace("#", "") ?? "FFFFFF");
+  const targetRGB = hex2rgb(targetColor).map(x => x * 255);
+
+  // Update display values
+  document.getElementById("x-term").textContent = includeX ? "X(P)" : "";
+  document.getElementById("c-value").textContent = closeCoeff;
+  document.getElementById("q-vec").textContent = normQY ? "q̂" : "q";
+  document.getElementById("y-vec").textContent = normQY ? "Ŷ(P)" : "Y(P)";
+  document.getElementById("close-coeff-display").innerHTML = closeCoeff;
+  document.getElementById("num-poke-display").textContent = numPoke;
+
+  // 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 color = document.getElementById("color-input")?.value;
-  if (color.length !== 6) {
-    return;
-  }
-  document.querySelector("body").setAttribute("style", `background: #${color}`);
-  const colorValues = [
-    Number.parseInt(color.substring(0, 2), 16),
-    Number.parseInt(color.substring(2, 4), 16),
-    Number.parseInt(color.substring(4, 6), 16),
-  ];
-  const colorMag = vectorMag(colorValues);
+  // determine metrics from configuration
+  const targetInSpace = useRGB ? targetRGB : rgb2luv(targetRGB.map(x => x / 255));
+  const xSelector = includeX ? (useRGB ? ({ xRGB }) => xRGB : ({ xLUV }) => xLUV) : () => 0;
+  const ySelector = useRGB ? ({ yRGB }) => yRGB : ({ yLUV }) => yLUV;
+  const yScorer = (normQY ? getNormedScorer : getUnnormedScorer)(closeCoeff, targetInSpace);
 
-  database.map(([name, x, ...y]) => {
-    const xComp = includeX ? x : 0;
-    const norm = normQY ? vectorMag(y) * colorMag : 1;
-    return {
-      name,
-      score: xComp - closeCoeff * y.map((yc, i) => yc * colorValues[i] / norm).reduce((x, y) => x + y),
-      avgColor: y,
-    };
-  }).sort((a, b) => a.score - b.score)
+  // actually score pokemon
+  database
+    .map(info => ({ ...info, score: xSelector(info) - yScorer(ySelector(info)) }))
+    .sort((a, b) => a.score - b.score)
     .slice(0, numPoke)
-    .forEach(({ name, avgColor}) => {
+    .forEach(({ name, score, yRGB }) => {
       const li = document.createElement("li");
       const img = document.createElement("img");
       const tile = document.createElement("div");
-      const hexColor = avgColor.map(c => Math.round(c).toString(16).padStart(2, "0")).reduce((x, y) => x + y);
-      tile.setAttribute("style", `width: 25px; height: 25px; background-color: #${hexColor}`)
+      const hexColor = rgb2hex(yRGB.map(x => x / 255));
+      tile.setAttribute("style", `width: 25px; height: 25px; background-color: ${hexColor}`)
       img.setAttribute("src", getSprite(name));
       li.appendChild(img)
-      li.appendChild(document.createTextNode(name));
+      li.appendChild(document.createTextNode(`${name}: ${score.toFixed(3)}`));
       li.appendChild(tile)
       li.setAttribute("style", "display: flex; flex-flow: row nowrap; justify-content: space-between; width: 320px")
       bestList.appendChild(li);
@@ -53,27 +73,15 @@ const onColorChange = () => {
 };
 
 const onRandomColor = () => {
-  const colorInput = document.getElementById("color-input");
-  colorInput.value = hsvToRGB(Math.random(), 0.9, 0.9);
-  onColorChange();
+  document.getElementById("color-input").value = rgb2hex([Math.random(), Math.random(), Math.random()]);
+  onUpdate();
 };
 
-// mostly stolen from https://stackoverflow.com/a/17243070
-// adapted slightly
-const hsvToRGB = (h, s, v) => {
-  var r, g, b, i, f, p, q, t;
-  i = Math.floor(h * 6);
-  f = h * 6 - i;
-  p = v * (1 - s);
-  q = v * (1 - f * s);
-  t = v * (1 - (1 - f) * s);
-  switch (i % 6) {
-      case 0: r = v, g = t, b = p; break;
-      case 1: r = q, g = v, b = p; break;
-      case 2: r = p, g = v, b = t; break;
-      case 3: r = p, g = q, b = v; break;
-      case 4: r = t, g = p, b = v; break;
-      case 5: r = v, g = p, b = q; break;
-  }
-  return [r, g, b].map(c => Math.round(c * 255).toString(16).padStart(2, "0")).reduce((x, y) => x + y);
+const onToggleSpace = () => {
+  const element = document.getElementById("color-space");
+  const current = element?.textContent;
+  element.textContent = current === "RGB" ? "CIELUV" : "RGB";
+  document.getElementById("space-toggle").textContent = `Swap to ${current}`
+  onUpdate();
 }
+