소스 검색

Reimpl done, now to clean up

Kirk Trombley 2 년 전
부모
커밋
3208f2e008
3개의 변경된 파일470개의 추가작업 그리고 201개의 파일을 삭제
  1. 48 46
      index.html
  2. 416 150
      main.js
  3. 6 5
      styles.css

+ 48 - 46
index.html

@@ -14,51 +14,53 @@
     <noscript>Requires javascript</noscript>
 
     <template id="metric-select-template">
-      <form action="javascript:void(0)" autocomplete="off">
+      <!-- Form is named by script, contains all fields to use event bubbling -->
+      <form
+        action="javascript:void(0)"
+        autocomplete="off"
+        class="metric-fields | flex col small-gap"
+      >
         <output hidden name="metric"></output>
-        <fieldset class="metric-fields | flex col small-gap">
-          <legend>Metric</legend>
-          <label>
-            <input type="radio" name="metricKind" value="whole" />
-            <div class="toggle-label | center pill-shape highlight-border">
-              Set Comparison
-            </div>
-          </label>
-          <label>
-            <input type="radio" name="metricKind" value="mean" />
-            <div class="toggle-label | center pill-shape highlight-border">
-              Mean Comparison
-            </div>
-          </label>
-          <label>
-            <input type="radio" name="metricKind" value="stat" />
-            <div class="toggle-label | center pill-shape highlight-border">
-              Set Statistics
-            </div>
-          </label>
-          <select class="pill-shape highlight-border" name="whole" disabled>
-            <option value="alpha">Geometric Difference (α)</option>
-            <option value="sigma">RMS Deviation (σ)</option>
-            <option value="bigTheta">Cosine Difference (Θ)</option>
-          </select>
-          <select class="pill-shape highlight-border" name="mean" disabled>
-            <option value="theta">Angular Difference (θ)</option>
-            <option value="phi">Hue Azimuth (ϕ)</option>
-            <option value="delta">Euclidean (δ)</option>
-            <option value="manhattan">Manhattan (M)</option>
-            <option value="ch">Chebyshev (Ч)</option>
-            <option value="lightnessDiff">Lightness (ℓ)</option>
-          </select>
-          <select class="pill-shape highlight-border" name="stat" disabled>
-            <option value="importance">Visual Importance (β)</option>
-            <option value="inertia">Inertia (I)</option>
-            <option value="variance">Variance (V)</option>
-            <option value="muNuAngle">Mu-Nu Angle (Φ)</option>
-            <option value="size">Size (N)</option>
-            <option value="lightness">Mean Lightness (L)</option>
-            <option value="chroma">Mean Chroma (C)</option>
-          </select>
-        </fieldset>
+        <label>
+          <input type="radio" name="metricKind" value="whole" />
+          <div class="toggle-label | center pill-shape highlight-border">
+            Set Comparison
+          </div>
+        </label>
+        <label>
+          <input type="radio" name="metricKind" value="mean" />
+          <div class="toggle-label | center pill-shape highlight-border">
+            Mean Comparison
+          </div>
+        </label>
+        <label>
+          <input type="radio" name="metricKind" value="stat" />
+          <div class="toggle-label | center pill-shape highlight-border">
+            Set Statistics
+          </div>
+        </label>
+        <select class="pill-shape highlight-border" name="whole" disabled>
+          <option value="alpha">Geometric Difference (α)</option>
+          <option value="sigma">RMS Deviation (σ)</option>
+          <option value="bigTheta">Cosine Difference (Θ)</option>
+        </select>
+        <select class="pill-shape highlight-border" name="mean" disabled>
+          <option value="theta">Angular Difference (θ)</option>
+          <option value="phi">Hue Azimuth (ϕ)</option>
+          <option value="delta">Euclidean (δ)</option>
+          <option value="manhattan">Manhattan (M)</option>
+          <option value="ch">Chebyshev (Ч)</option>
+          <option value="lightnessDiff">Lightness (ℓ)</option>
+        </select>
+        <select class="pill-shape highlight-border" name="stat" disabled>
+          <option value="importance">Visual Importance (β)</option>
+          <option value="inertia">Inertia (I)</option>
+          <option value="variance">Variance (V)</option>
+          <option value="muNuAngle">Mu-Nu Angle (Φ)</option>
+          <option value="size">Size (N)</option>
+          <option value="lightness">Mean Lightness (L)</option>
+          <option value="chroma">Mean Chroma (C)</option>
+        </select>
       </form>
     </template>
 
@@ -81,7 +83,7 @@
       <div>C&nbsp;=&nbsp;<span bind-to="chroma"></span></div>
     </template>
 
-    <template id="pkmn-template">
+    <template id="pkmn-tile-template">
       <div class="pkmn-tile | flex col no-gap">
         <div class="ellipsis emphasis" bind-to="name"></div>
         <div class="pkmn-info | highlight-border flex even">
@@ -278,7 +280,7 @@
       <div id="cls-title" class="emphasis center">Cluster Ranking</div>
       <div id="cls-metric-mount"></div>
       <!-- Cluster Sort Function -->
-      <div class="fn-control | flex col no-gap">
+      <div id="cls-fn" class="fn-control | flex col no-gap">
         <div class="fn-minmax | center flex small-gap">
           <label>
             <input

+ 416 - 150
main.js

@@ -1,7 +1,7 @@
 // ---- Math and Utilities ----
 // 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 vectorMag = v => Math.sqrt(vectorDot(v, v));
 
 // Angle Math
 const rad2deg = 180 / Math.PI;
@@ -12,14 +12,12 @@ const productLift =
   (...factors) =>
   (...args) =>
     factors
-      .filter((fn) => !!fn)
-      .map((fn) => fn(...args))
+      .filter(fn => !!fn)
+      .map(fn => fn(...args))
       .reduce((x, y) => x * y, 1);
-const mapValues = (obj, fn) =>
-  Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, fn(value, key)]));
 
 // Contrast + Shadow + Hover Colors
-const getContrastingTextColor = (hex) => {
+const getContrastingTextColor = hex => {
   const { r, g, b } = d3.color(hex);
   return vectorDot([r, g, b], [0.3, 0.6, 0.1]) >= 128
     ? "var(--color-dark)"
@@ -41,26 +39,30 @@ const calcImportance = (chroma, lightness, proportion) =>
   Math.tanh(100 * (proportion - 0.8)); // penalty for being <50%
 
 // Conversions
-const jab2hex = (jab) => d3.jab(...jab).formatHex();
-const rgb2hex = (rgb) => d3.rgb(...rgb).formatHex();
-const jab2hue = (jab) => d3.jch(d3.jab(...jab)).h || 0;
-const rgb2hue = (rgb) => d3.hsl(d3.rgb(...rgb)).h || 0;
+const jab2hex = jab => d3.jab(...jab).formatHex();
+const rgb2hex = rgb => d3.rgb(...rgb).formatHex();
+const jab2hue = jab => d3.jch(d3.jab(...jab)).h || 0;
+const rgb2hue = rgb => d3.hsl(d3.rgb(...rgb)).h || 0;
 const jab2lit = ([j]) => j / 100;
-const rgb2lit = (rgb) => d3.hsl(d3.rgb(...rgb)).l || 0;
-const jab2chroma = (jab) => d3.jch(d3.jab(...jab)).C / 100;
-const rgb2chroma = (rgb) => d3.jch(d3.rgb(...rgb)).C / 100;
+const rgb2lit = rgb => d3.hsl(d3.rgb(...rgb)).l || 0;
+const jab2chroma = jab => d3.jch(d3.jab(...jab)).C / 100;
+const rgb2chroma = rgb => d3.jch(d3.rgb(...rgb)).C / 100;
 
 // Pre-computation
 const buildVectorData = (vector, toHue, toLightness, toChroma, toHex) => {
   const sqMag = vectorDot(vector, vector);
   const mag = Math.sqrt(sqMag);
-  const unit = vector.map((c) => c / mag);
+  const unit = vector.map(c => c / mag);
   const hue = toHue(vector);
   const lightness = toLightness(vector);
   const chroma = toChroma(vector);
   const hex = toHex(vector);
   return { vector, sqMag, mag, unit, hue, lightness, chroma, hex };
 };
+const buildVectorDataJab = vector =>
+  buildVectorData(vector, jab2hue, jab2lit, jab2chroma, jab2hex);
+const buildVectorDataRgb = vector =>
+  buildVectorData(vector, rgb2hue, rgb2lit, rgb2chroma, rgb2hex);
 
 const buildClusterData = (
   size,
@@ -72,12 +74,9 @@ const buildClusterData = (
   nu2,
   nu3,
   totalSize,
-  toHue,
-  toLightness,
-  toChroma,
-  toHex
+  buildVectorDataForSpace
 ) => {
-  const mu = buildVectorData([mu1, mu2, mu3], toHue, toLightness, toChroma, toHex);
+  const mu = buildVectorDataForSpace([mu1, mu2, mu3]);
   const nu = [nu1, nu2, nu3];
   const muNuAngle = rad2deg * Math.acos(vectorDot(mu.unit, nu) / vectorMag(nu));
   const proportion = size / totalSize;
@@ -98,98 +97,26 @@ const buildClusterData = (
 const buildPokemonData = ([name, size, ...values]) => ({
   name,
   jab: {
-    total: buildClusterData(
-      size,
-      ...values.slice(0, 7),
-      size,
-      jab2hue,
-      jab2lit,
-      jab2chroma,
-      jab2hex
-    ),
+    total: buildClusterData(size, ...values.slice(0, 7), size, buildVectorDataJab),
     clusters: [
-      buildClusterData(
-        ...values.slice(7, 15),
-        size,
-        jab2hue,
-        jab2lit,
-        jab2chroma,
-        jab2hex
-      ),
-      buildClusterData(
-        ...values.slice(15, 23),
-        size,
-        jab2hue,
-        jab2lit,
-        jab2chroma,
-        jab2hex
-      ),
-      buildClusterData(
-        ...values.slice(23, 31),
-        size,
-        jab2hue,
-        jab2lit,
-        jab2chroma,
-        jab2hex
-      ),
-      buildClusterData(
-        ...values.slice(31, 39),
-        size,
-        jab2hue,
-        jab2lit,
-        jab2chroma,
-        jab2hex
-      ),
-    ].filter((c) => c.size !== 0),
+      buildClusterData(...values.slice(7, 15), size, buildVectorDataJab),
+      buildClusterData(...values.slice(15, 23), size, buildVectorDataJab),
+      buildClusterData(...values.slice(23, 31), size, buildVectorDataJab),
+      buildClusterData(...values.slice(31, 39), size, buildVectorDataJab),
+    ].filter(c => c.size !== 0),
   },
   rgb: {
-    total: buildClusterData(
-      size,
-      ...values.slice(39, 46),
-      size,
-      rgb2hue,
-      rgb2lit,
-      rgb2chroma,
-      rgb2hex
-    ),
+    total: buildClusterData(size, ...values.slice(39, 46), size, buildVectorDataRgb),
     clusters: [
-      buildClusterData(
-        ...values.slice(46, 54),
-        size,
-        rgb2hue,
-        rgb2lit,
-        rgb2chroma,
-        rgb2hex
-      ),
-      buildClusterData(
-        ...values.slice(54, 62),
-        size,
-        rgb2hue,
-        rgb2lit,
-        rgb2chroma,
-        rgb2hex
-      ),
-      buildClusterData(
-        ...values.slice(62, 70),
-        size,
-        rgb2hue,
-        rgb2lit,
-        rgb2chroma,
-        rgb2hex
-      ),
-      buildClusterData(
-        ...values.slice(70, 78),
-        size,
-        rgb2hue,
-        rgb2lit,
-        rgb2chroma,
-        rgb2hex
-      ),
-    ].filter((c) => c.size !== 0),
+      buildClusterData(...values.slice(46, 54), size, buildVectorDataRgb),
+      buildClusterData(...values.slice(54, 62), size, buildVectorDataRgb),
+      buildClusterData(...values.slice(62, 70), size, buildVectorDataRgb),
+      buildClusterData(...values.slice(70, 78), size, buildVectorDataRgb),
+    ].filter(c => c.size !== 0),
   },
 });
 
-const pokemonData = databaseV3.map((row) => buildPokemonData(row));
+const pokemonData = databaseV3.map(row => buildPokemonData(row));
 
 const calcScores = (data, target) => {
   const sigma = Math.sqrt(
@@ -227,6 +154,11 @@ const calcScores = (data, target) => {
   };
 };
 
+const sortOrders = {
+  max: (a, b) => b - a,
+  min: (a, b) => a - b,
+};
+
 // ---- Styling ----
 
 const rootStyle = document.querySelector(":root").style;
@@ -238,20 +170,193 @@ const setColorStyles = (style, hex) => {
   style.setProperty("--shadow-component", highlight.includes("light") ? "255" : "0");
 };
 
-// ---- List Render ----
+// ---- Pokemon Display ----
+
+// pulled out bc the render uses them
+const metricScores = {};
+const bestClusterIndices = {};
+const objectiveValues = {};
+
+const pokemonTileTemplate = document.getElementById("pkmn-tile-template").content;
+const pokemonDataTemplate = document.getElementById("pkmn-data-template").content;
+
+const loadTemplateWithBinds = content => {
+  const fragment = content.cloneNode(true);
+  const binds = Object.fromEntries(
+    Array.from(fragment.querySelectorAll("[bind-to]")).map(element => {
+      const name = element.getAttribute("bind-to");
+      element.removeAttribute("bind-to");
+      return [name, element];
+    })
+  );
+  return [fragment, binds];
+};
+
+const getSpriteName = (() => {
+  const stripForm = [
+    "flabebe",
+    "floette",
+    "florges",
+    "vivillon",
+    "basculin",
+    "furfrou",
+    "magearna",
+    "alcremie",
+  ];
+  return pokemon => {
+    pokemon = pokemon
+      .replace("-alola", "-alolan")
+      .replace("-galar", "-galarian")
+      .replace("-hisui", "-hisuian")
+      .replace("-paldea", "-paldean")
+      .replace("-paldeanfire", "-paldean-fire") // tauros
+      .replace("-paldeanwater", "-paldean-water") // tauros
+      .replace("-phony", "") // sinistea and polteageist
+      .replace("darmanitan-galarian", "darmanitan-galarian-standard")
+      .replace("chienpao", "chien-pao")
+      .replace("tinglu", "ting-lu")
+      .replace("wochien", "wo-chien")
+      .replace("chiyu", "chi-yu");
+    if (stripForm.find(s => pokemon.includes(s))) {
+      pokemon = pokemon.replace(/-.*$/, "");
+    }
+    return pokemon;
+  };
+})();
+
+const formatName = name =>
+  name
+    .split("-")
+    .map(part => part.charAt(0).toUpperCase() + part.substr(1))
+    .join(" ");
 
 const renderPokemon = (list, target) => {
-  target.innerHTML = "";
-  // TODO
+  target.innerText = "";
+
+  const {
+    sortUseWholeImage,
+    sortUseBestCluster,
+    sortUseClusterSize,
+    sortUseInvClusterSize,
+    sortUseTotalSize,
+    sortUseInvTotalSize,
+  } = Object.fromEntries(new FormData(document.forms.colorCalculateForm).entries());
+
+  const enableTotalFlags = !!(
+    sortUseWholeImage ||
+    sortUseTotalSize ||
+    sortUseInvTotalSize
+  );
+  const enableClusterFlags = !!(
+    sortUseBestCluster ||
+    sortUseClusterSize ||
+    sortUseInvClusterSize
+  );
+
+  list.forEach(pkmnName => {
+    const [tile, { image, name, score, ...binds }] =
+      loadTemplateWithBinds(pokemonTileTemplate);
+
+    const spriteName = getSpriteName(pkmnName);
+    const imageErrHandler = () => {
+      image.removeEventListener("error", imageErrHandler);
+      image.src = `https://img.pokemondb.net/sprites/sword-shield/icon/${spriteName}.png`;
+    };
+    image.addEventListener("error", imageErrHandler);
+    image.src = `https://img.pokemondb.net/sprites/scarlet-violet/icon/${spriteName}.png`;
+
+    name.innerText = name.title = image.alt = formatName(pkmnName);
+
+    const colorSpace = document.forms.colorSortForm.elements.colorSpace.value;
+
+    score.innerText = objectiveValues[pkmnName][colorSpace].toFixed(2);
+
+    const { total, clusters } = metricScores[pkmnName][colorSpace];
+    [
+      [clusters[0], binds.cls1Btn, binds.cls1Data],
+      [clusters[1], binds.cls2Btn, binds.cls2Data],
+      [clusters[2], binds.cls3Btn, binds.cls3Data],
+      [clusters[3], binds.cls4Btn, binds.cls4Data],
+      [total, binds.totalBtn, binds.totalData],
+    ]
+      .filter(([data]) => !!data)
+      .forEach(([data, button, dataTile], index) => {
+        button.hidden = false;
+        button.innerText = data.muHex;
+        button.dataset.included =
+          enableClusterFlags && index === bestClusterIndices[pkmnName][colorSpace];
+        setColorStyles(button.style, data.muHex);
+        button.addEventListener("click", () => {
+          model.setTargetColor(data.muHex);
+        });
+
+        const [tooltip, tooltipBinds] = loadTemplateWithBinds(pokemonDataTemplate);
+        Object.entries(tooltipBinds).forEach(([metricName, target]) => {
+          target.innerText = data[metricName].toFixed(2).replace(".00", "");
+        });
+
+        dataTile.append(tooltip);
+      });
+
+    binds.totalBtn.dataset.included = enableTotalFlags;
+
+    target.append(tile);
+  });
 };
 
-// ---- Shared State ----
+const colorSearchResultsTarget = document.getElementById("color-results");
+const nameSearchResultsTarget = document.getElementById("name-results");
 
-const state = {
-  get targetColor() {
-    return this._targetColor || "";
-  },
-  set targetColor(newColor) {
+// ---- Name Lookup ----
+
+const lookupLimit = 24;
+const pokemonLookup = new Fuse(pokemonData, { keys: ["name"] });
+const nameSearchResults = [];
+
+const renderNameSearchResults = () => {
+  renderPokemon(nameSearchResults, nameSearchResultsTarget);
+};
+
+document.forms.nameSearchForm.elements.input.addEventListener(
+  "input",
+  ({ target: { value } }) => {
+    nameSearchResults.splice(
+      0,
+      Infinity,
+      ...pokemonLookup
+        .search(value, { limit: lookupLimit })
+        .map(({ item: { name } }) => name)
+    );
+    renderNameSearchResults();
+  }
+);
+
+document.forms.nameSearchForm.elements.clear.addEventListener("click", () => {
+  nameSearchResults.splice(0);
+  document.forms.nameSearchForm.elements.input.value = "";
+  renderNameSearchResults();
+});
+
+document.forms.nameSearchForm.elements.random.addEventListener("click", () => {
+  nameSearchResults.splice(
+    0,
+    Infinity,
+    ...Array.from(
+      { length: lookupLimit },
+      () => pokemonData[Math.floor(Math.random() * pokemonData.length)].name
+    )
+  );
+  renderNameSearchResults();
+});
+
+// ---- Calculation Logic ----
+
+const model = new (class {
+  #targetColor = "";
+
+  ranked = [];
+
+  setTargetColor(newColor) {
     const hex = `#${newColor?.replace("#", "")}`;
     if (hex.length !== 7) {
       return;
@@ -259,51 +364,126 @@ const state = {
 
     setColorStyles(rootStyle, hex);
 
-    const oldColor = this._targetColor;
-    this._targetColor = hex;
+    const oldColor = this.#targetColor;
+    this.#targetColor = hex;
+
+    document.forms.targetColorForm.elements.colorText.value = hex;
+    document.forms.targetColorForm.elements.colorText.dataset.lastValid = hex;
+    document.forms.targetColorForm.elements.colorPicker.value = hex;
 
     if (oldColor) {
       const prevButton = document.createElement("button");
       prevButton.innerText = oldColor;
       prevButton.classList = "color-select";
       setColorStyles(prevButton.style, oldColor);
-      prevButton.addEventListener("click", () => (this.targetColor = oldColor));
+      prevButton.addEventListener("click", () => this.setTargetColor(oldColor));
       document.getElementById("prevColors").prepend(prevButton);
     }
 
-    document.forms.targetColorForm.elements.colorText.value = hex;
-    document.forms.targetColorForm.elements.colorPicker.value = hex;
+    const rgb = d3.rgb(hex);
+    const { J, a, b } = d3.jab(rgb);
+    const targetJab = buildVectorDataJab([J, a, b]);
+    const targetRgb = buildVectorDataRgb([rgb.r, rgb.g, rgb.b]);
+
+    pokemonData.forEach(({ name, jab, rgb }) => {
+      metricScores[name] = {
+        jab: {
+          total: calcScores(jab.total, targetJab),
+          clusters: jab.clusters.map(c => calcScores(c, targetJab)),
+        },
+        rgb: {
+          total: calcScores(rgb.total, targetRgb),
+          clusters: rgb.clusters.map(c => calcScores(c, targetRgb)),
+        },
+      };
+    });
+
+    this.calculateObjective();
+  }
 
-    // TODO trigger recalc
-  },
+  calculateObjective() {
+    const {
+      clusterUseClusterSize,
+      clusterUseInvClusterSize,
+      clusterUseTotalSize,
+      clusterUseInvTotalSize,
+      clusterSortOrder,
+      sortUseWholeImage,
+      sortUseBestCluster,
+      sortUseClusterSize,
+      sortUseInvClusterSize,
+      sortUseTotalSize,
+      sortUseInvTotalSize,
+    } = Object.fromEntries(new FormData(document.forms.colorCalculateForm).entries());
+
+    const clsMetric = document.forms.clusterMetricForm.elements.metric.value;
+    const getClusterScore = productLift(
+      cluster => cluster[clsMetric],
+      clusterUseClusterSize && (cluster => cluster.size),
+      clusterUseInvClusterSize && (cluster => cluster.inverseSize),
+      clusterUseTotalSize && ((_, total) => total.size),
+      clusterUseInvTotalSize && ((_, total) => total.inverseSize)
+    );
+
+    const clsSort = sortOrders[clusterSortOrder];
+    const getBestClusterIndex = ({ total, clusters }) =>
+      clusters
+        .map((c, i) => [getClusterScore(c, total), i])
+        .reduce((a, b) => (clsSort(a[0], b[0]) > 0 ? b : a))[1];
+
+    Object.entries(metricScores).forEach(([name, { jab, rgb }]) => {
+      bestClusterIndices[name] = {
+        jab: getBestClusterIndex(jab),
+        rgb: getBestClusterIndex(rgb),
+      };
+    });
+
+    const metric = document.forms.sortMetricForm.elements.metric.value;
+    const getSortScore = productLift(
+      sortUseWholeImage && (({ total }) => total[metric]),
+      sortUseBestCluster && (({ clusters }, i) => clusters[i][metric]),
+      sortUseClusterSize && (({ clusters }, i) => clusters[i].size),
+      sortUseInvClusterSize && (({ clusters }, i) => clusters[i].inverseSize),
+      sortUseTotalSize && (({ total }) => total.size),
+      sortUseInvTotalSize && (({ total }) => total.inverseSize)
+    );
+
+    Object.entries(metricScores).forEach(([name, { jab, rgb }]) => {
+      objectiveValues[name] = {
+        jab: getSortScore(jab, bestClusterIndices[name].jab),
+        rgb: getSortScore(rgb, bestClusterIndices[name].rgb),
+      };
+    });
 
-  get colorSearchResults() {
-    return this._colorSearchResults || [];
-  },
-  set colorSearchResults(results) {
-    this._colorSearchResults = results;
-    renderColorSearchResults();
-  },
-  
-  get nameSearchResults() {
-    return this._nameSearchResults || [];
-  },
-  set nameSearchResults(results) {
-    this._nameSearchResults = results;
     renderNameSearchResults();
-  },
-};
+    this.rank();
+  }
 
-const colorSearchResultsTarget = document.getElementById("color-results");
-const nameSearchResultsTarget = document.getElementById("name-results");
+  rank() {
+    const { colorSpace, sortOrder } = Object.fromEntries(
+      new FormData(document.forms.colorSortForm).entries()
+    );
+    const compare = sortOrders[sortOrder];
+    const sortFn = (a, b) =>
+      compare(objectiveValues[a][colorSpace], objectiveValues[b][colorSpace]);
 
-function renderColorSearchResults() {
-  renderPokemon(state.colorSearchResults, colorSearchResultsTarget);
-}
+    this.ranked = pokemonData
+      .map(({ name }) => name)
+      .sort((a, b) => sortFn(a, b) || a.localeCompare(b));
+
+    this.renderColorSearchResults();
+  }
 
-function renderNameSearchResults() {
-  renderPokemon(state.nameSearchResults, nameSearchResultsTarget);
-}
+  renderColorSearchResults() {
+    renderPokemon(
+      this.ranked.slice(
+        0,
+        parseInt(document.forms.colorDisplayForm.elements.resultsToDisplay.value)
+      ),
+      colorSearchResultsTarget
+    );
+  }
+})();
 
 // ---- Form Controls ----
 
@@ -313,26 +493,112 @@ document.forms.targetColorForm.elements.colorText.addEventListener(
     if (target.willValidate && !target.validity.valid) {
       target.value = target.dataset.lastValid || "";
     } else {
-      state.targetColor = target.dataset.lastValid = target.value;
+      model.setTargetColor(target.value);
     }
   }
 );
 
 document.forms.targetColorForm.elements.colorPicker.addEventListener(
   "change",
-  ({ target }) => {
-    state.targetColor = target.value;
-  }
+  ({ target }) => model.setTargetColor(target.value)
 );
 
 const randomizeTargetColor = () =>
-  (state.targetColor = d3
-    .hsl(Math.random() * 360, Math.random(), Math.random())
-    .formatHex());
+  model.setTargetColor(
+    d3.hsl(Math.random() * 360, Math.random(), Math.random()).formatHex()
+  );
 
 document.forms.targetColorForm.elements.randomColor.addEventListener(
   "click",
   randomizeTargetColor
 );
 
+document.forms.colorDisplayForm.elements.resultsToDisplay.addEventListener(
+  "input",
+  ({ target: { value } }) => {
+    document.forms.colorDisplayForm.elements.output.value = value;
+  }
+);
+
+document.forms.colorDisplayForm.elements.resultsToDisplay.addEventListener("change", () =>
+  model.renderColorSearchResults()
+);
+
+Array.from(document.forms.colorSortForm.elements).forEach(el =>
+  el.addEventListener("change", () => model.rank())
+);
+
+const clusterRankingTitle = document.getElementById("cls-title");
+const clusterMetricSection = document.getElementById("cls-metric-mount");
+const clusterFunctionSection = document.getElementById("cls-fn");
+
+Array.from(document.forms.colorCalculateForm.elements).forEach(el =>
+  el.addEventListener("change", () => {
+    const { sortUseBestCluster, sortUseClusterSize, sortUseInvClusterSize } =
+      Object.fromEntries(new FormData(document.forms.colorCalculateForm).entries());
+    clusterRankingTitle.dataset.faded =
+      clusterMetricSection.dataset.faded =
+      clusterFunctionSection.dataset.faded =
+        !(sortUseBestCluster || sortUseClusterSize || sortUseInvClusterSize);
+    model.calculateObjective();
+  })
+);
+
+// ---- Add Metric Selections ----
+
+const metricSelectTemplate = document.getElementById("metric-select-template").content;
+const sortMetricForm = metricSelectTemplate.cloneNode(true).firstElementChild;
+sortMetricForm.id = "sortMetricForm";
+const clusterMetricForm = metricSelectTemplate.cloneNode(true).firstElementChild;
+clusterMetricForm.id = "clusterMetricForm";
+document.getElementById("sort-metric-mount").append(sortMetricForm);
+document.getElementById("cls-metric-mount").append(clusterMetricForm);
+
+document.forms.sortMetricForm.elements.metricKind.value = "whole";
+document.forms.clusterMetricForm.elements.metricKind.value = "stat";
+
+const updateMetricSelects = form => {
+  const kind = form.elements.metricKind.value;
+  form.elements.whole.disabled = kind !== "whole";
+  form.elements.mean.disabled = kind !== "mean";
+  form.elements.stat.disabled = kind !== "stat";
+  form.elements.metric.value = form.elements[kind].value;
+};
+
+const getMetricSymbol = metricName =>
+  // terrible hack
+  document.querySelector(`option[value=${metricName}]`).textContent.at(-2);
+
+const onMetricChange = () => {
+  updateMetricSelects(document.forms.sortMetricForm);
+  updateMetricSelects(document.forms.clusterMetricForm);
+
+  document.forms.colorCalculateForm.elements.sortMetricSymbolP.value =
+    document.forms.colorCalculateForm.elements.sortMetricSymbolB.value = getMetricSymbol(
+      document.forms.sortMetricForm.elements[
+        document.forms.sortMetricForm.elements.metricKind.value
+      ].value
+    );
+
+  document.forms.colorCalculateForm.elements.clusterMetricSymbol.value = getMetricSymbol(
+    document.forms.clusterMetricForm.elements[
+      document.forms.clusterMetricForm.elements.metricKind.value
+    ].value
+  );
+};
+
+onMetricChange();
+
+document.forms.sortMetricForm.addEventListener("change", () => {
+  onMetricChange();
+  model.calculateObjective();
+});
+
+document.forms.clusterMetricForm.addEventListener("change", () => {
+  onMetricChange();
+  model.calculateObjective();
+});
+
+// ---- Pick Starting Color ----
+
 randomizeTargetColor();

+ 6 - 5
styles.css

@@ -220,7 +220,8 @@ button.color-select:hover {
 }
 
 #cls-title,
-:is(#cls-fn-mount, #cls-metric-mount) form {
+#cls-fn,
+#cls-metric-mount {
   transition: opacity 200ms;
 }
 
@@ -297,11 +298,11 @@ button.color-select:hover {
   min-width: 12ch;
 }
 
-.pkmn-tile button[data-best="true"] {
+.pkmn-tile button[data-included="true"] {
   position: relative;
 }
 
-.pkmn-tile button[data-best="true"]::before {
+.pkmn-tile button[data-included="true"]::before {
   position: absolute;
   content: "▸";
   inset: 50% auto auto 1ch;
@@ -388,8 +389,8 @@ button.color-select:hover {
 }
 
 .fn-control .toggle-label:first-child {
-  /* TODO fix this */
   /* only affects the fake label on the cluster function */
+  opacity: inherit;
   color: inherit;
   background-color: inherit;
   border: none;
@@ -409,7 +410,7 @@ button.color-select:hover {
   border: none;
 }
 
-.metric-fields :is(legend, select:disabled, input[type="radio"]) {
+.metric-fields :is(select:disabled, input[type="radio"]) {
   display: none;
 }