Kirk Trombley 2 سال پیش
والد
کامیت
8bf3307b75
1فایلهای تغییر یافته به همراه268 افزوده شده و 238 حذف شده
  1. 268 238
      main.js

+ 268 - 238
main.js

@@ -102,6 +102,8 @@ const pokemonData = databaseV3.map(([name, size, ...values]) => ({
   },
 }));
 
+const pokemonLookup = new Fuse(pokemonData, { keys: ["name"] });
+
 const calcScores = (data, target) => {
   const sigma = Math.sqrt(
     data.inertia - 2 * vectorDot(data.mu.vector, target.vector) + target.sqMag
@@ -143,21 +145,124 @@ const sortOrders = {
   min: (a, b) => a - b,
 };
 
-// ---- Styling ----
+// who needs a framework?
+const makeTemplate = (id, definition = () => ({})) => {
+  const content = document.getElementById(id).content;
+  return (...args) => {
+    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];
+      })
+    );
+    Object.entries(definition(...args))
+      .map(([name, settings]) => [binds[name], settings])
+      .filter(([bind]) => !!bind)
+      .forEach(([bind, settings]) =>
+        Object.entries(settings).forEach(([setting, value]) => {
+          if (setting.startsWith("@")) {
+            bind.addEventListener(setting.slice(1), value);
+          } else if (setting.startsWith("--")) {
+            bind.style.setProperty(setting, value);
+          } else if (setting === "dataset") {
+            Object.entries(value).forEach(([key, data]) => (bind.dataset[key] = data));
+          } else if (setting === "append") {
+            if (Array.isArray(value)) {
+              bind.append(...value);
+            } else {
+              bind.append(value);
+            }
+          } else {
+            bind[setting] = value;
+          }
+        })
+      );
+    return [fragment, binds];
+  };
+};
+
+// ---- Selectors ----
 
 const rootStyle = document.querySelector(":root").style;
+const colorSearchResultsTarget = document.getElementById("color-results");
+const nameSearchResultsTarget = document.getElementById("name-results");
+const prevColorsSidebar = document.getElementById("prevColors");
+const clusterRankingTitle = document.getElementById("cls-title");
+const clusterMetricSection = document.getElementById("cls-metric-mount");
+const clusterFunctionSection = document.getElementById("cls-fn");
+
+const colorCalculateForm = document.forms.colorCalculateForm;
+const colorSortForm = document.forms.colorSortForm;
+const targetColorElements = document.forms.targetColorForm.elements;
+const colorDisplayElements = document.forms.colorDisplayForm.elements;
+const nameSearchFormElements = document.forms.nameSearchForm.elements;
+
+// ---- Add Metric Selects ----
+
+const createMetricSelect = makeTemplate("metric-select-template");
+const [{ firstElementChild: sortMetricForm }] = createMetricSelect();
+const [{ firstElementChild: clusterMetricForm }] = createMetricSelect();
+
+document.getElementById("sort-metric-mount").append(sortMetricForm);
+sortMetricForm.elements.metricKind.value = "whole";
+
+document.getElementById("cls-metric-mount").append(clusterMetricForm);
+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 setColorStyles = (style, hex) => {
+// bit of a hack, but lets us control this all from the template
+const metricSymbols = Object.fromEntries(
+  Array.from(document.querySelectorAll("option")).map(el => [
+    el.value,
+    el.textContent.at(-2),
+  ])
+);
+
+const updateMetricDisplays = () => {
+  updateMetricSelects(sortMetricForm);
+  updateMetricSelects(clusterMetricForm);
+
+  colorCalculateForm.elements.sortMetricSymbolP.value =
+    colorCalculateForm.elements.sortMetricSymbolB.value =
+      metricSymbols[
+        sortMetricForm.elements[sortMetricForm.elements.metricKind.value].value
+      ];
+
+  colorCalculateForm.elements.clusterMetricSymbol.value =
+    metricSymbols[
+      clusterMetricForm.elements[clusterMetricForm.elements.metricKind.value].value
+    ];
+};
+
+// ---- Styling ----
+
+const getColorStyles = hex => {
   const { r, g, b } = d3.color(hex);
   const highlight =
     vectorDot([r, g, b], [0.3, 0.6, 0.1]) >= 128
       ? "var(--color-dark)"
       : "var(--color-light)";
-  style.setProperty("--highlight", highlight);
-  style.setProperty("--background", hex);
-  style.setProperty("--shadow-component", highlight.includes("light") ? "255" : "0");
+  return {
+    "--highlight": highlight,
+    "--background": hex,
+    "--shadow-component": highlight.includes("light") ? "255" : "0",
+  };
 };
 
+const setColorStyles = (style, hex) =>
+  Object.entries(getColorStyles(hex)).forEach(([prop, value]) =>
+    style.setProperty(prop, value)
+  );
+
 // ---- Pokemon Display ----
 
 // pulled out bc the render uses them
@@ -165,34 +270,28 @@ 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 createPokemonTooltip = makeTemplate("pkmn-data-template", data =>
+  Object.fromEntries(
+    Object.entries(data).map(([metric, value]) => [
+      metric,
+      { innerText: value.toFixed?.(2)?.replace(".00", "") },
+    ])
+  )
+);
+
+const createPokemonTile = makeTemplate(
+  "pkmn-tile-template",
+  (pkmnName, colorSpace, enableTotalFlags, enableClusterFlags) => {
+    const formattedName = pkmnName
+      .split("-")
+      .map(part => part.charAt(0).toUpperCase() + part.substr(1))
+      .join(" ");
+    const name = {
+      innerText: formattedName,
+      title: formattedName,
+    };
 
-const getSpriteName = (() => {
-  const stripForm = [
-    "flabebe",
-    "floette",
-    "florges",
-    "vivillon",
-    "basculin",
-    "furfrou",
-    "magearna",
-    "alcremie",
-  ];
-  return pokemon => {
-    pokemon = pokemon
+    let spriteName = pkmnName
       .replace("-alola", "-alolan")
       .replace("-galar", "-galarian")
       .replace("-hisui", "-hisuian")
@@ -205,16 +304,74 @@ const getSpriteName = (() => {
       .replace("tinglu", "ting-lu")
       .replace("wochien", "wo-chien")
       .replace("chiyu", "chi-yu");
-    if (stripForm.find(s => pokemon.includes(s))) {
-      pokemon = pokemon.replace(/-.*$/, "");
+    if (
+      [
+        "flabebe",
+        "floette",
+        "florges",
+        "vivillon",
+        "basculin",
+        "furfrou",
+        "magearna",
+        "alcremie",
+      ].find(s => spriteName.includes(s))
+    ) {
+      spriteName = spriteName.replace(/-.*$/, "");
     }
-    return pokemon;
-  };
-})();
+    const imageErrorHandler = ({ target }) => {
+      target.removeEventListener("error", imageErrorHandler);
+      target.src = `https://img.pokemondb.net/sprites/scarlet-violet/icon/${spriteName}.png`;
+    };
+    const image = {
+      alt: formattedName,
+      src: `https://img.pokemondb.net/sprites/sword-shield/icon/${spriteName}.png`,
+      "@error": imageErrorHandler,
+    };
+
+    const score = {
+      innerText: objectiveValues[pkmnName][colorSpace].toFixed(2),
+    };
+
+    const { total, clusters } = metricScores[pkmnName][colorSpace];
+    const buttonBinds = [
+      [clusters[0], "cls1Btn", "cls1Data"],
+      [clusters[1], "cls2Btn", "cls2Data"],
+      [clusters[2], "cls3Btn", "cls3Data"],
+      [clusters[3], "cls4Btn", "cls4Data"],
+      [total, "totalBtn", "totalData"],
+    ]
+      .filter(([data]) => !!data)
+      .map(([data, button, tooltip], index) => {
+        return {
+          [button]: {
+            dataset: {
+              included:
+                enableClusterFlags && index === bestClusterIndices[pkmnName][colorSpace],
+            },
+            hidden: false,
+            innerText: data.muHex,
+            "@click"() {
+              model.setTargetColor(data.muHex);
+            },
+            ...getColorStyles(data.muHex),
+          },
+          [tooltip]: {
+            append: createPokemonTooltip(data)[0],
+          },
+        };
+      })
+      .reduce((a, b) => ({ ...a, ...b }), {});
+    buttonBinds.totalBtn.dataset.included = enableTotalFlags;
+
+    return { name, image, score, ...buttonBinds };
+  }
+);
 
 const renderPokemon = (list, target) => {
   target.innerText = "";
 
+  const colorSpace = colorSortForm.elements.colorSpace.value;
+
   const {
     sortUseWholeImage,
     sortUseBestCluster,
@@ -222,128 +379,30 @@ const renderPokemon = (list, target) => {
     sortUseInvClusterSize,
     sortUseTotalSize,
     sortUseInvTotalSize,
-  } = Object.fromEntries(new FormData(document.forms.colorCalculateForm).entries());
+  } = Object.fromEntries(new FormData(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/scarlet-violet/icon/${spriteName}.png`;
-    };
-    image.addEventListener("error", imageErrHandler);
-    image.src = `https://img.pokemondb.net/sprites/sword-shield/icon/${spriteName}.png`;
-
-    name.innerText =
-      name.title =
-      image.alt =
-        pkmnName
-          .split("-")
-          .map(part => part.charAt(0).toUpperCase() + part.substr(1))
-          .join(" ");
-
-    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);
-  });
-};
-
-const colorSearchResultsTarget = document.getElementById("color-results");
-const nameSearchResultsTarget = document.getElementById("name-results");
-
-// ---- 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
+  target.append(
+    ...list.map(
+      name => createPokemonTile(name, colorSpace, enableTotalFlags, enableClusterFlags)[0]
     )
   );
-  renderNameSearchResults();
-});
+};
 
 // ---- Calculation Logic ----
 
-const model = new (class {
-  #targetColor = "";
-
-  ranked = [];
-
+const model = {
   setTargetColor(newColor) {
     const hex = `#${newColor?.replace("#", "")}`;
     if (hex.length !== 7) {
@@ -352,12 +411,12 @@ const model = new (class {
 
     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;
+    targetColorElements.colorText.value = hex;
+    targetColorElements.colorText.dataset.lastValid = hex;
+    targetColorElements.colorPicker.value = hex;
 
     if (oldColor) {
       const prevButton = document.createElement("button");
@@ -365,7 +424,7 @@ const model = new (class {
       prevButton.classList = "color-select";
       setColorStyles(prevButton.style, oldColor);
       prevButton.addEventListener("click", () => this.setTargetColor(oldColor));
-      document.getElementById("prevColors").prepend(prevButton);
+      prevColorsSidebar.prepend(prevButton);
     }
 
     const rgb = d3.rgb(hex);
@@ -387,7 +446,7 @@ const model = new (class {
     });
 
     this.calculateObjective();
-  }
+  },
 
   calculateObjective() {
     const {
@@ -402,9 +461,9 @@ const model = new (class {
       sortUseInvClusterSize,
       sortUseTotalSize,
       sortUseInvTotalSize,
-    } = Object.fromEntries(new FormData(document.forms.colorCalculateForm).entries());
+    } = Object.fromEntries(new FormData(colorCalculateForm).entries());
 
-    const clsMetric = document.forms.clusterMetricForm.elements.metric.value;
+    const clsMetric = clusterMetricForm.elements.metric.value;
     const getClusterScore = productLift(
       cluster => cluster[clsMetric],
       clusterUseClusterSize && (cluster => cluster.size),
@@ -426,7 +485,7 @@ const model = new (class {
       };
     });
 
-    const metric = document.forms.sortMetricForm.elements.metric.value;
+    const metric = sortMetricForm.elements.metric.value;
     const getSortScore = productLift(
       sortUseWholeImage && (({ total }) => total[metric]),
       sortUseBestCluster && (({ clusters }, i) => clusters[i][metric]),
@@ -443,13 +502,13 @@ const model = new (class {
       };
     });
 
-    renderNameSearchResults();
+    this.renderNameSearchResults();
     this.rank();
-  }
+  },
 
   rank() {
     const { colorSpace, sortOrder } = Object.fromEntries(
-      new FormData(document.forms.colorSortForm).entries()
+      new FormData(colorSortForm).entries()
     );
     const compare = sortOrders[sortOrder];
     const sortFn = (a, b) =>
@@ -460,35 +519,57 @@ const model = new (class {
       .sort((a, b) => sortFn(a, b) || a.localeCompare(b));
 
     this.renderColorSearchResults();
-  }
+  },
+
+  setNameSearchResults(newNameResults) {
+    this.nameSearchResults = newNameResults;
+    this.renderNameSearchResults();
+  },
+
+  renderNameSearchResults() {
+    renderPokemon(this.nameSearchResults ?? [], nameSearchResultsTarget);
+  },
 
   renderColorSearchResults() {
     renderPokemon(
-      this.ranked.slice(
-        0,
-        parseInt(document.forms.colorDisplayForm.elements.resultsToDisplay.value)
-      ),
+      this.ranked.slice(0, parseInt(colorDisplayElements.resultsToDisplay.value)),
       colorSearchResultsTarget
     );
-  }
-})();
+  },
+};
 
 // ---- Form Controls ----
 
-document.forms.targetColorForm.elements.colorText.addEventListener(
-  "input",
-  ({ target }) => {
-    if (target.willValidate && !target.validity.valid) {
-      target.value = target.dataset.lastValid || "";
-    } else {
-      model.setTargetColor(target.value);
-    }
+nameSearchFormElements.input.addEventListener("input", ({ target: { value } }) => {
+  model.setNameSearchResults(
+    pokemonLookup.search(value, { limit: 24 }).map(({ item: { name } }) => name)
+  );
+});
+
+nameSearchFormElements.clear.addEventListener("click", () => {
+  nameSearchFormElements.input.value = "";
+  model.setNameSearchResults([]);
+});
+
+nameSearchFormElements.random.addEventListener("click", () => {
+  model.setNameSearchResults(
+    Array.from(
+      { length: 24 },
+      () => pokemonData[Math.floor(Math.random() * pokemonData.length)].name
+    )
+  );
+});
+
+targetColorElements.colorText.addEventListener("input", ({ target }) => {
+  if (target.willValidate && !target.validity.valid) {
+    target.value = target.dataset.lastValid || "";
+  } else {
+    model.setTargetColor(target.value);
   }
-);
+});
 
-document.forms.targetColorForm.elements.colorPicker.addEventListener(
-  "change",
-  ({ target }) => model.setTargetColor(target.value)
+targetColorElements.colorPicker.addEventListener("change", ({ target }) =>
+  model.setTargetColor(target.value)
 );
 
 const randomizeTargetColor = () =>
@@ -496,34 +577,27 @@ const randomizeTargetColor = () =>
     d3.hsl(Math.random() * 360, Math.random(), Math.random()).formatHex()
   );
 
-document.forms.targetColorForm.elements.randomColor.addEventListener(
-  "click",
-  randomizeTargetColor
-);
+targetColorElements.randomColor.addEventListener("click", randomizeTargetColor);
 
-document.forms.colorDisplayForm.elements.resultsToDisplay.addEventListener(
+colorDisplayElements.resultsToDisplay.addEventListener(
   "input",
   ({ target: { value } }) => {
-    document.forms.colorDisplayForm.elements.output.value = value;
+    colorDisplayElements.output.value = value;
   }
 );
 
-document.forms.colorDisplayForm.elements.resultsToDisplay.addEventListener("change", () =>
+colorDisplayElements.resultsToDisplay.addEventListener("change", () =>
   model.renderColorSearchResults()
 );
 
-Array.from(document.forms.colorSortForm.elements).forEach(el =>
+Array.from(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 =>
+Array.from(colorCalculateForm.elements).forEach(el =>
   el.addEventListener("change", () => {
     const { sortUseBestCluster, sortUseClusterSize, sortUseInvClusterSize } =
-      Object.fromEntries(new FormData(document.forms.colorCalculateForm).entries());
+      Object.fromEntries(new FormData(colorCalculateForm).entries());
     clusterRankingTitle.dataset.faded =
       clusterMetricSection.dataset.faded =
       clusterFunctionSection.dataset.faded =
@@ -532,61 +606,17 @@ Array.from(document.forms.colorCalculateForm.elements).forEach(el =>
   })
 );
 
-// ---- 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();
+sortMetricForm.addEventListener("change", () => {
+  updateMetricDisplays();
   model.calculateObjective();
 });
 
-document.forms.clusterMetricForm.addEventListener("change", () => {
-  onMetricChange();
+clusterMetricForm.addEventListener("change", () => {
+  updateMetricDisplays();
   model.calculateObjective();
 });
 
-// ---- Pick Starting Color ----
+// ---- Initial Setup ----
 
+updateMetricDisplays();
 randomizeTargetColor();