// ---- Observables ---- const targetColor = U.obs(); const resultsToDisplay = U.obs(6); const colorSpace = U.obs("jab"); const sortOrder = U.obs("min"); const sortUseWholeImage = U.obs(true); const sortUseBestCluster = U.obs(true); const sortUseClusterSize = U.obs(false); const sortUseInverseClusterSize = U.obs(true); const sortUseTotalSize = U.obs(true); const sortUseInverseTotalSize = U.obs(false); const sortMetric = addMetricForm( "sortMetric", "Sort Metric", "whole", "alpha", "sort-metric-mount" ); const clusterOrder = U.obs("max"); const clusterUseClusterSize = U.obs(false); const clusterUseInverseClusterSize = U.obs(false); const clusterUseTotalSize = U.obs(false); const clusterUseInverseTotalSize = U.obs(false); const clusterMetric = addMetricForm( "clsMetric", "Cluster Metric", "stat", "importance", "cls-metric-mount" ); const sortIgnoresCluster = U.obs(() => [ // ensure dep on all three sortUseBestCluster.value, sortUseClusterSize.value, sortUseInverseClusterSize.value, ].every((v) => !v) ); // ---- Color Controls ---- const randomizeTargetColor = () => { targetColor.value = d3 .hsl(Math.random() * 360, Math.random(), Math.random()) .formatHex(); }; U.element("targetSelect", ({ randomButton }, form) => { U.field(form.elements.colorText, { obs: targetColor }); U.field(form.elements.colorPicker, { obs: targetColor }); U.field(form.elements.resultsToDisplay, { obs: resultsToDisplay }); U.reactive(() => { form.elements.resultsToDisplayOutput.value = resultsToDisplay.value; }); // non-reactive since the form onchange updates it form.elements.colorSpace.value = colorSpace.value; U.form(form, { onChange: { colorSpace(value) { colorSpace.value = value; }, }, }); randomButton.addEventListener("click", randomizeTargetColor); }); const getColorButtonStyle = (hex) => { const { h, s, l } = d3.hsl(hex); return ` color: ${getContrastingTextColor(hex)}; --button-bg: ${hex}; --button-bg-hover: ${d3.hsl(h, s, clamp(0.25, l * 1.25, 0.9)).formatHex()}; `; }; targetColor.subscribe((hex, { previous }) => { const style = document.querySelector(":root").style; style.setProperty("--background", hex); const highlight = getContrastingTextColor(hex); style.setProperty("--highlight", highlight); style.setProperty("--shadow-component", highlight.includes("light") ? "255" : "0"); if (previous) { const prevButton = document.createElement("button"); prevButton.innerText = previous; prevButton.classList = "color-select"; prevButton.style = getColorButtonStyle(previous); prevButton.addEventListener("click", () => (targetColor.value = previous)); document.getElementById("prevColors").prepend(prevButton); } }); // ---- Metric Controls ---- function addMetricForm(formName, legendText, initialKind, initialMetric, mountPoint) { const metricKind = U.obs(initialKind); let metric; U.template("metric-select-template", ({ form, legend }) => { form.name = formName; legend.innerText = legendText; form.elements[initialKind].disabled = false; form.elements[initialKind].value = initialMetric; form.elements.metricKind.value = initialKind; U.reactive(() => { form.elements.whole.disabled = metricKind.value !== "whole"; form.elements.mean.disabled = metricKind.value !== "mean"; form.elements.stat.disabled = metricKind.value !== "stat"; }); const metrics = U.obs([ U.field(form.elements.whole), U.field(form.elements.mean), U.field(form.elements.stat), ]); U.form(form, { onChange: { metricKind(kind) { metricKind.value = kind; }, }, }); metric = U.obs(() => { const [whole, mean, stat] = metrics.value; return { whole, mean, stat }[metricKind.value]; }); return mountPoint; }); return metric; } // ---- Sorting Function Controls ---- const getMetricSymbol = (metric) => // terrible hack document.querySelector(`option[value=${metric}]`).textContent.at(-2); U.template( "function-template", ({ form, metricSymbol, metricSymbolCls, clusterName, clusterNameInv }) => { form.name = "sortFunction"; U.field(form.elements.useWholeImage, { obs: sortUseWholeImage }); U.field(form.elements.useBestCluster, { obs: sortUseBestCluster }); U.field(form.elements.clusterSize, { obs: sortUseClusterSize }); U.field(form.elements.invClusterSize, { obs: sortUseInverseClusterSize }); U.field(form.elements.totalSize, { obs: sortUseTotalSize }); U.field(form.elements.invTotalSize, { obs: sortUseInverseTotalSize }); U.reactive(() => { const symbol = getMetricSymbol(sortMetric.value); metricSymbol.innerText = symbol; metricSymbolCls.innerText = `${symbol}(B)`; }); clusterName.innerText = clusterNameInv.innerText = "B"; form.elements.sortOrder.value = sortOrder.value; U.form(form, { onChange: { sortOrder(value) { sortOrder.value = value; }, }, }); return "sort-fn-mount"; } ); U.template( "function-template", ({ form, useWholeImageLabel, metricSymbolCls, clusterName, clusterNameInv }) => { form.name = "clsFunction"; form.elements.sortOrder.checked = true; U.field(form.elements.clusterSize, { obs: clusterUseClusterSize }); U.field(form.elements.invClusterSize, { obs: clusterUseInverseClusterSize }); U.field(form.elements.totalSize, { obs: clusterUseTotalSize }); U.field(form.elements.invTotalSize, { obs: clusterUseInverseTotalSize }); // not needed for cluster function useWholeImageLabel.nextSibling.remove(); useWholeImageLabel.remove(); // and this can't be disabled so just replace the field with the span metricSymbolCls.parentElement.replaceWith(metricSymbolCls); U.reactive(() => { metricSymbolCls.innerText = `${getMetricSymbol(clusterMetric.value)}(K)`; }); clusterName.innerText = clusterNameInv.innerText = "K"; form.elements.sortOrder.value = clusterOrder.value; U.form(form, { onChange: { sortOrder(value) { clusterOrder.value = value; }, }, }); return "cls-fn-mount"; } ); U.reactive(() => { document.getElementById("cls-title").dataset.faded = document.forms.clsMetric.dataset.faded = document.forms.clsFunction.dataset.faded = sortIgnoresCluster.value; }); // ---- Score Calculation ---- const currentScores = U.obs(() => { const rgb = d3.rgb(targetColor.value); 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 ); const scores = {}; pokemonData.forEach(({ name, jab, rgb }) => { scores[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)), }, }; }); return scores; }); const sortOrders = { max: (a, b) => b - a, min: (a, b) => a - b, }; const currentResults = U.obs(() => { const clsMetric = clusterMetric.value; const getClusterScore = productLift( (cluster) => cluster[clsMetric], clusterUseClusterSize.value && ((cluster) => cluster.size), clusterUseInverseClusterSize.value && ((cluster) => cluster.inverseSize), clusterUseTotalSize.value && ((_, total) => total.size), clusterUseInverseTotalSize.value && ((_, total) => total.inverseSize) ); const clsSort = sortOrders[clusterOrder.value]; const getBestClusterIndex = ({ total, clusters }) => clusters .map((c, i) => [getClusterScore(c, total), i]) .reduce((a, b) => (clsSort(a[0], b[0]) > 0 ? b : a))[1]; const bestClusterIndices = mapValues(currentScores.value, ({ jab, rgb }) => ({ jab: getBestClusterIndex(jab), rgb: getBestClusterIndex(rgb), })); const metric = sortMetric.value; const getSortScore = productLift( sortUseWholeImage.value && (({ total }) => total[metric]), sortUseBestCluster.value && (({ clusters }, i) => clusters[i][metric]), sortUseClusterSize.value && (({ clusters }, i) => clusters[i].size), sortUseInverseClusterSize.value && (({ clusters }, i) => clusters[i].inverseSize), sortUseTotalSize.value && (({ total }) => total.size), sortUseInverseTotalSize.value && (({ total }) => total.inverseSize) ); const rankingValues = mapValues(currentScores.value, ({ jab, rgb }, name) => ({ jab: getSortScore(jab, bestClusterIndices[name].jab), rgb: getSortScore(rgb, bestClusterIndices[name].rgb), })); return { bestClusterIndices, rankingValues }; }); const sortedResults = U.obs(() => { const { rankingValues } = currentResults.value; const sort = sortOrders[sortOrder.value]; const space = colorSpace.value; return pokemonData .map(({ name }) => name) .sort( (a, b) => sort(rankingValues[a][space], rankingValues[b][space]) || a.localeCompare(b) ); }); const colorSearchResults = U.obs(() => sortedResults.value.slice(0, resultsToDisplay.value) ); // ---- Pokemon Display ---- 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"); if (stripForm.find((s) => pokemon.includes(s))) { pokemon = pokemon.replace(/-.*$/, ""); } return pokemon; }; })(); const pokemonListManager = (target) => U.list( "pkmn-template", ( { image, name, score, totalBtn, totalData, cls1Btn, cls2Btn, cls3Btn, cls4Btn, cls1Data, cls2Data, cls3Data, cls4Data, }, tileApi, pkmnName ) => { const spriteName = getSpriteName(pkmnName); const formattedName = pkmnName .split("-") .map((part) => part.charAt(0).toUpperCase() + part.substr(1)) .join(" "); 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 = formattedName; tileApi.reactive(() => { const { rankingValues } = currentResults.value; score.innerText = rankingValues[pkmnName][colorSpace.value].toFixed(2); }); [ [({ total }) => total, totalBtn, totalData], [({ clusters }) => clusters[0], cls1Btn, cls1Data], [({ clusters }) => clusters[1], cls2Btn, cls2Data], [({ clusters }) => clusters[2], cls3Btn, cls3Data], [({ clusters }) => clusters[3], cls4Btn, cls4Data], ].forEach(([getData, button, dataTile]) => { tileApi.reactive(() => { const data = getData(currentScores.value[pkmnName][colorSpace.value]); if (!data) { button.hidden = true; return; } button.hidden = false; const { muHex } = data; button.innerText = muHex; button.style = getColorButtonStyle(muHex); }); button.addEventListener("click", () => { targetColor.value = getData( currentScores.value[pkmnName][colorSpace.value] )?.muHex; }); U.template("pkmn-data-template", (binds) => { Object.entries(binds).forEach(([metricName, target]) => { tileApi.reactive(() => { target.innerText = getData(currentScores.value[pkmnName][colorSpace.value]) ?.[metricName]?.toFixed(2) ?.replace(".00", ""); }); }); return dataTile; }); }); tileApi.reactive(() => { totalBtn.dataset.best = [ sortUseWholeImage.value, sortUseTotalSize.value, sortUseInverseTotalSize.value, ].reduce((x, y) => x || y); }); tileApi.reactive(() => { const { bestClusterIndices } = currentResults.value; const bestCluster = bestClusterIndices[pkmnName][colorSpace.value]; [cls1Btn, cls2Btn, cls3Btn, cls4Btn].forEach((button, index) => { button.dataset.best = !sortIgnoresCluster.value && index === bestCluster; }); }); return target; } ); // ---- Display Logic ---- colorSearchResults.subscribe(pokemonListManager("color-results")); // ---- Name Search ---- U.element("nameSearch", ({ input, randomBtn, clearBtn }) => { const nameSearchInput = U.field(input); const lookupLimit = 24; const pokemonLookup = new Fuse(pokemonData, { keys: ["name"] }); const nameSearchResults = U.obs(() => pokemonLookup .search(nameSearchInput.value, { limit: lookupLimit }) .map(({ item: { name } }) => name) ); randomBtn.addEventListener("click", () => { nameSearchResults.value = Array.from( { length: lookupLimit }, () => pokemonData[Math.floor(Math.random() * pokemonData.length)].name ); }); clearBtn.addEventListener("click", () => { nameSearchResults.value = []; nameSearchInput.value = ""; }); nameSearchResults.subscribe(pokemonListManager("name-results")); }); // ---- Initial Target ---- randomizeTargetColor();