const selectors = { get colorSelect() { return document.forms.colorSelect.elements; }, set colorText(hex) { selectors.colorSelect.colorText.value = hex; }, set colorPicker(hex) { selectors.colorSelect.colorPicker.value = hex; }, get sortControl() { return document.forms.sortControl; }, get resultsToDisplay() { return selectors.sortControl.elements.resultsToDisplay.value; }, get colorSpace() { return selectors.sortControl.elements.colorSpace.value; }, get prevColors() { return document.getElementById("prev-colors"); }, get pokemonTemplate() { return document.getElementById("pkmn-template").content; }, get pokemonDataTemplate() { return document.getElementById("pkmn-data-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); }, get metricSelectTemplate() { return document.getElementById("metric-select-template").content; }, get sortFunction() { return document.forms.sortFunction; }, get sortMetric() { return document.forms.sortMetric.elements.sortMetric.value; }, get sortOrder() { return selectors.sortFunction.elements.sortOrder.checked ? "max" : "min"; }, get sortUseBestCluster() { return selectors.sortFunction.elements.useBestCluster.checked; }, get sortUseWholeImage() { return selectors.sortFunction.elements.useWholeImage.checked; }, get sortClusterSize() { return selectors.sortFunction.elements.clusterSize.checked; }, get sortInverseClusterSize() { return selectors.sortFunction.elements.invClusterSize.checked; }, get sortTotalSize() { return selectors.sortFunction.elements.totalSize.checked; }, get sortInverseTotalSize() { return selectors.sortFunction.elements.invTotalSize.checked; }, set sortMetricSymbol(sym) { selectors.sortFunction.elements.metricSymbolP.value = sym; selectors.sortFunction.elements.metricSymbolB.value = sym; }, get sortIsUsingCluster() { return ( selectors.sortUseBestCluster || selectors.sortClusterSize || selectors.sortInverseClusterSize ); }, get clusterFunction() { return document.forms.clusterFunction; }, get clusterSortMetric() { return document.forms.clusterMetric.elements.sortMetric.value; }, get clusterSortOrder() { return selectors.clusterFunction.elements.sortOrder.checked ? "max" : "min"; }, get clusterSortClusterSize() { return selectors.clusterFunction.elements.clusterSize.checked; }, get clusterSortInverseClusterSize() { return selectors.clusterFunction.elements.invClusterSize.checked; }, get clusterSortTotalSize() { return selectors.clusterFunction.elements.totalSize.checked; }, get clusterSortInverseTotalSize() { return selectors.clusterFunction.elements.invTotalSize.checked; }, set clusterMetricSymbol(sym) { selectors.clusterFunction.elements.metricSymbol.value = sym; }, }; const onMetricChange = (elements, skipUpdates = false) => { 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) { // terrible hack selectors.sortMetricSymbol = document .querySelector(`option[value=${selectors.sortMetric}]`) .textContent.at(-2); selectors.clusterMetricSymbol = document .querySelector(`option[value=${selectors.clusterSortMetric}]`) .textContent.at(-2); 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 getCardinalityFactorExtractor = ( clusterSize, invClusterSize, totalSize, invTotalSize ) => { const extractors = []; if (clusterSize) { extractors.push((scores) => scores.clusters.map(({ size }) => size)); } if (invClusterSize) { extractors.push((scores) => scores.clusters.map(({ inverseSize }) => inverseSize)); } if (totalSize) { extractors.push((scores) => scores.clusters.map(() => scores.total.size)); } if (invTotalSize) { extractors.push((scores) => scores.clusters.map(() => scores.total.inverseSize)); } return (scores) => extractors .map((ext) => ext(scores)) .reduce( (acc, xs) => acc.map((a, i) => a * xs[i]), scores.clusters.map(() => 1) ); }; 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 = () => { // fade out cluster stuff if not in use selectors.clusterFunction.classList.toggle("fade", !selectors.sortIsUsingCluster); document.forms.clusterMetric.classList.toggle("fade", !selectors.sortIsUsingCluster); // update cluster rankings const clusterSortOrder = sortOrders[selectors.clusterSortOrder]; const getClusterCardinalityFactors = getCardinalityFactorExtractor( selectors.clusterSortClusterSize, selectors.clusterSortInverseClusterSize, selectors.clusterSortTotalSize, selectors.clusterSortInverseTotalSize ); pokemonData.forEach(({ name }) => { const { jab, rgb } = currentScores[name]; // multiply scales with the intended metric, and find the index of the best value const forSpace = (clusters, factors) => clusters .map((c, i) => [c[selectors.clusterSortMetric] * factors[i], i]) .reduce((a, b) => (clusterSortOrder(a[0], b[0]) > 0 ? b : a))[1]; currentBestClusterIndices[name] = { jab: forSpace(jab.clusters, getClusterCardinalityFactors(jab)), rgb: forSpace(rgb.clusters, getClusterCardinalityFactors(rgb)), }; }); // set up for actual sort const getCardinalityFactors = getCardinalityFactorExtractor( selectors.sortClusterSize, selectors.sortInverseClusterSize, selectors.sortTotalSize, selectors.sortInverseTotalSize ); const factors = [ (name) => getCardinalityFactors(currentScores[name][selectors.colorSpace])[ currentBestClusterIndices[name][selectors.colorSpace] ], ]; if (selectors.sortUseWholeImage) { factors.push( (name) => currentScores[name][selectors.colorSpace].total[selectors.sortMetric] ); } if (selectors.sortUseBestCluster) { factors.push( (name) => currentScores[name][selectors.colorSpace].clusters[ currentBestClusterIndices[name][selectors.colorSpace] ][selectors.sortMetric] ); } pokemonData.forEach(({ name }) => { currentSortValues[name] = factors.map((fn) => fn(name)).reduce((x, y) => x * y); }); // 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 populateDataDialog = (data, dialog) => Object.entries(data).forEach(([metric, value]) => { const target = dialog.querySelector(`.pkmn-data-value--${metric}`); console.log(target); if (target) { target.innerText = value % 1 === 0 ? value : value.toFixed(3); } }); const makePokemonTile = (name) => { const clone = selectors.pokemonTemplate.cloneNode(true); const img = clone.querySelector("img"); img.src = getSprite(name); img.alt = name; const formattedName = name .split("-") .map((part) => part.charAt(0).toUpperCase() + part.substr(1)) .join(" "); clone.querySelector(".pkmn-name").innerText = formattedName; clone.querySelector(".pkmn-name").title = formattedName; clone.querySelector(".pkmn-score").innerText = currentSortValues[name].toFixed(3); const { total, clusters } = currentScores[name][selectors.colorSpace]; const mu = clone.querySelector(".pkmn-total"); mu.appendChild(document.createTextNode(total.muHex)); mu.style = ` color: ${getContrastingTextColor(total.muHex)}; background-color: ${total.muHex}; `; const totalDataDialog = selectors.pokemonDataTemplate.cloneNode(true); populateDataDialog(total, totalDataDialog); clone.querySelector("dialog").appendChild(totalDataDialog); clusters.forEach((cls, i) => { const clsDiv = clone.querySelector(`.pkmn-cls${i + 1}`); clsDiv.querySelector("span:first-child").innerText = (cls.proportion * 100).toFixed(2) + "%"; clsDiv.querySelector("span:nth-child(2)").innerText = cls.muHex; clsDiv.style = ` color: ${getContrastingTextColor(cls.muHex)}; background-color: ${cls.muHex}; `; const dialog = clsDiv.querySelector("dialog"); dialog.removeAttribute("hidden"); const clsDataDialog = selectors.pokemonDataTemplate.cloneNode(true); populateDataDialog(cls, clsDataDialog); dialog.appendChild(clsDataDialog); }); if (selectors.sortUseWholeImage) { clone.querySelector(".pkmn").classList.add("pkmn-total-selected"); } if (selectors.sortIsUsingCluster) { 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: 100 }) .map(({ item: { name } }) => name); showResults(); }; const randomPokemon = () => { currentNameSearchResults = Array.from( { length: 100 }, () => 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.slice(0, selectors.resultsToDisplay).forEach((name) => { selectors.nameSearchResults.appendChild(makePokemonTile(name)); }); }; window.addEventListener("load", () => { const metricSelect = selectors.metricSelectTemplate; document.forms.sortMetric.appendChild(metricSelect.cloneNode(true)); document.forms.sortMetric.querySelector("legend").textContent = "Sort Metric"; document.forms.sortMetric.elements.metricKind.value = "whole"; document.forms.sortMetric.elements.whole.value = "alpha"; onMetricChange(document.forms.sortMetric.elements, true); document.forms.clusterMetric.appendChild(metricSelect.cloneNode(true)); document.forms.clusterMetric.querySelector("legend").textContent = "Cluster Metric"; document.forms.clusterMetric.elements.metricKind.value = "statistic"; document.forms.clusterMetric.elements.statistic.value = "importance"; onMetricChange(document.forms.clusterMetric.elements, true); 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()); });