const selectors = { get sortControl() { return document.forms.sortControl.elements; }, get clusterControl() { return document.forms.clusterControl.elements; }, get colorSelect(){ return document.forms.colorSelect.elements; }, get resultsToDisplay() { return selectors.sortControl.resultsToDisplay.value; }, get colorSpace() { return selectors.sortControl.colorSpace.value; }, get sortMetric() { return selectors.sortControl.sortMetric.value; }, get sortOrder() { return selectors.sortControl.sortOrder.checked ? "max" : "min"; }, get scaleFactor() { return selectors.sortControl.rescaleFactor.value; }, get useClusters() { return selectors.sortControl.useClusters.value; }, get clusterSortMetric() { return selectors.clusterControl.sortMetric.value; }, get clusterSortOrder() { return selectors.clusterControl.sortOrder.checked ? "max" : "min"; }, get clusterScaleFactor() { return selectors.clusterControl.rescaleFactor.value; }, get prevColors() { return document.getElementById("prev-colors"); }, get metricFormTemplate() { return document.getElementById("metric-form-template").content; }, get scaleFormTemplate() { return document.getElementById("scale-form-template").content; }, get sizeFactorFormTemplate() { return document.getElementById("size-factor-template").content; }, get pokemonTemplate() { return document.getElementById("pkmn-template").content; }, get colorSearchResults() { return document.getElementById("color-results"); }, get nameSearchResults() { return document.getElementById("name-results"); }, set background(hex) { document.querySelector(":root").style.setProperty("--background", hex); }, set highlight(hex) { document.querySelector(":root").style.setProperty("--highlight", hex); }, set colorText(hex) { selectors.colorSelect.colorText.value = hex; }, set colorPicker(hex) { selectors.colorSelect.colorPicker.value = hex; }, }; const onMetricChange = (elements, skipUpdates = false) => { elements.sortOrderLabel.value = elements.sortOrder.checked ? "Maximizing" : "Minimizing"; const kind = elements.metricKind.value; elements.whole.disabled = kind !== "whole"; elements.mean.disabled = kind !== "mean"; elements.statistic.disabled = kind !== "statistic"; elements.sortMetric.value = elements[kind].value; if (!skipUpdates) { updateSort(); } }; const onColorChange = (inputValue, skipUpdates = false) => { const colorInput = "#" + (inputValue?.replace("#", "") ?? "FFFFFF"); if (colorInput.length !== 7) { return; } const rgb = d3.color(colorInput); if (!rgb) { return; } const hex = rgb.formatHex(); selectors.colorText = hex; selectors.colorPicker = hex; const contrast = getContrastingTextColor(hex); const newColor = document.createElement("div"); newColor.innerHTML = hex; newColor.style = ` color: ${contrast}; background-color: ${hex}; `; selectors.prevColors.prepend(newColor); selectors.background = hex; selectors.highlight = contrast; if (!skipUpdates) { updateScores(rgb); updateSort(); } }; const randomColor = () => d3.hsl(Math.random() * 360, Math.random(), Math.random()).formatHex(); const sortOrders = { max: (a, b) => b - a, min: (a, b) => a - b, }; const scaleOptions = { none: () => [1, 1, 1], direct: (scores) => scores.clusters.map((c) => c.proportion), inverse: (scores) => scores.clusters.map((c) => c.inverseProportion), size: (scores) => [scores.total.size, scores.total.size, scores.total.size], inverseSize: (scores) => [ scores.total.inverseSize, scores.total.inverseSize, scores.total.inverseSize, ], }; const cardinalityTerms = { clusterSize: (scores, index) => scores.clusters[index].size, invClusterSize: (scores, index) => scores.clusters[index].inverseSize, totalSize: (scores) => scores.total.size, invTotalSize: (scores) => scores.total.inverseSize, }; const currentScores = {}; const currentBestClusterIndices = {}; const currentSortValues = {}; let sortedData = []; const updateScores = (rgb) => { const { J, a, b } = d3.jab(rgb); const targetJab = buildVectorData([J, a, b], jab2hue, jab2lit, jab2chroma, jab2hex); const targetRgb = buildVectorData( [rgb.r, rgb.g, rgb.b], rgb2hue, rgb2lit, rgb2chroma, rgb2hex ); pokemonData.forEach(({ name, jab, rgb }) => { currentScores[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)), }, }; }); }; const updateSort = () => { // update cluster rankings const clusterSortOrder = sortOrders[selectors.clusterSortOrder]; const clusterScaleOption = scaleOptions[selectors.clusterScaleFactor]; pokemonData.forEach(({ name }) => { const { jab, rgb } = currentScores[name]; // multiply scale with the intended metric, and find the index of the best value const forSpace = (clusters, scales) => clusters .map((c, i) => [c[selectors.clusterSortMetric] * scales[i], i]) .reduce((a, b) => (clusterSortOrder(a[0], b[0]) > 0 ? b : a))[1]; currentBestClusterIndices[name] = { jab: forSpace(jab.clusters, clusterScaleOption(jab)), rgb: forSpace(rgb.clusters, clusterScaleOption(rgb)), }; }); // set up for actual sort const scaleOption = scaleOptions[selectors.scaleFactor]; switch (selectors.useClusters) { case "off": pokemonData.forEach(({ name }) => { currentSortValues[name] = currentScores[name][selectors.colorSpace].total[selectors.sortMetric]; }); break; case "on": pokemonData.forEach(({ name }) => { const index = currentBestClusterIndices[name][selectors.colorSpace]; currentSortValues[name] = scaleOption(currentScores[name][selectors.colorSpace])[index] * currentScores[name][selectors.colorSpace].clusters[index][selectors.sortMetric]; }); break; case "mult": pokemonData.forEach(({ name }) => { const index = currentBestClusterIndices[name][selectors.colorSpace]; currentSortValues[name] = currentScores[name][selectors.colorSpace].total[selectors.sortMetric] * scaleOption(currentScores[name][selectors.colorSpace])[index] * currentScores[name][selectors.colorSpace].clusters[index][selectors.sortMetric]; }); break; } // update actual sorted data const sortOrder = sortOrders[selectors.sortOrder]; sortedData = pokemonData .map(({ name }) => name) .sort((a, b) => sortOrder(currentSortValues[a], currentSortValues[b])); // and desplay results showResults(); }; const getSprite = (() => { const stripForm = [ "flabebe", "floette", "florges", "vivillon", "basculin", "furfrou", "magearna", "alcremie", ]; return (pokemon) => { pokemon = pokemon .replace("-alola", "-alolan") .replace("-galar", "-galarian") .replace("-phony", "") // sinistea and polteageist .replace("darmanitan-galarian", "darmanitan-galarian-standard"); if (stripForm.find((s) => pokemon.includes(s))) { pokemon = pokemon.replace(/-.*$/, ""); } return `https://img.pokemondb.net/sprites/sword-shield/icon/${pokemon}.png`; }; })(); const makePokemonTile = (name) => { const clone = selectors.pokemonTemplate.cloneNode(true); const img = clone.querySelector("img"); img.src = getSprite(name); img.alt = name; clone.querySelector(".pkmn-name").innerText = name .split("-") .map((part) => part.charAt(0).toUpperCase() + part.substr(1)) .join(" "); clone.querySelector(".pkmn-score").innerText = currentSortValues[name].toFixed(3); const { total, clusters } = currentScores[name][selectors.colorSpace]; const mu = clone.querySelector(".pkmn-total"); mu.innerText = total.muHex; mu.style = ` color: ${getContrastingTextColor(total.muHex)}; background-color: ${total.muHex}; `; clusters.forEach((cls, i) => { const clsDiv = clone.querySelector(`.pkmn-cls${i + 1}`); clsDiv.firstChild.innerText = (cls.proportion * 100).toFixed(2) + "%"; clsDiv.lastChild.innerText = cls.muHex; clsDiv.style = ` color: ${getContrastingTextColor(cls.muHex)}; background-color: ${cls.muHex}; `; }); if (selectors.useClusters === "off") { clone.querySelector(".pkmn").classList.add("pkmn-total-selected"); } else { clone .querySelector(".pkmn") .classList.add( `pkmn-cls${currentBestClusterIndices[name][selectors.colorSpace] + 1}-selected` ); } return clone; }; const pokemonLookup = new Fuse(pokemonData, { keys: ["name"] }); let currentNameSearchResults = []; const searchByName = (newSearch) => { currentNameSearchResults = pokemonLookup .search(newSearch, { limit: 10 }) .map(({ item: { name } }) => name); showResults(); }; const randomPokemon = () => { currentNameSearchResults = Array.from( { length: 10 }, () => pokemonData[Math.floor(Math.random() * pokemonData.length)].name ); showResults(); }; const showResults = () => { selectors.colorSearchResults.innerHTML = ""; sortedData.slice(0, selectors.resultsToDisplay).forEach((name) => { selectors.colorSearchResults.appendChild(makePokemonTile(name)); }); selectors.nameSearchResults.innerHTML = ""; currentNameSearchResults.forEach((name) => { selectors.nameSearchResults.appendChild(makePokemonTile(name)); }); }; window.addEventListener("load", () => { const metricTemplate = selectors.metricFormTemplate; selectors.sortControl.metric.prepend(metricTemplate.cloneNode(true)); selectors.sortControl.metricKind.value = "whole"; selectors.sortControl.whole.value = "alpha"; onMetricChange(selectors.sortControl, true); selectors.clusterControl.metric.appendChild(metricTemplate.cloneNode(true)); selectors.clusterControl.sortOrder.checked = true; selectors.clusterControl.metricKind.value = "statistic"; selectors.clusterControl.statistic.value = "importance"; onMetricChange(selectors.clusterControl, true); const scaleTemplate = selectors.scaleFormTemplate; selectors.sortControl.rescaleSection.appendChild(scaleTemplate.cloneNode(true)); selectors.sortControl.rescaleFactor.value = "inverse"; selectors.clusterControl.rescaleSection.appendChild(scaleTemplate.cloneNode(true)); selectors.clusterControl.rescaleFactor.value = "none"; document.body.addEventListener("click", ({ target: { innerText }, detail }) => { if (detail === 2 && innerText?.includes("#")) { const clickedHex = innerText?.match(/.*(#[0-9a-fA-F]{6}).*/)?.[1] ?? ""; if (clickedHex) { onColorChange(clickedHex); } } }); onColorChange(randomColor()); });