// ---- Math and Utilities ---- // Vector Math const vectorDot = (u, v) => u.map((x, i) => x * v[i]).reduce((x, y) => x + y); const unitVector = v => { const mag = Math.hypot(...v); return v.map(c => c / mag); }; // Angle Math const rad2deg = 180 / Math.PI; const twoPi = 2 * Math.PI; // Color Conversion const hex2rgb = hex => { hex = hex.replace("#", ""); const red = hex.substr(0, 2); const grn = hex.substr(2, 2); const blu = hex.substr(4, 2); return [red, grn, blu].map(c => parseInt(c, 16)); }; // calculated from analyze.py RGB_TO_LMS = [ [0.4121965, 0.53627432, 0.05143268], [0.2119195, 0.68071831, 0.10738379], [0.08834911, 0.28185414, 0.63018663], ]; LMS_TO_OKLAB = [ [0.2104542553, 0.793617785, -0.0040720468], [1.9779984951, -2.428592205, 0.4505937099], [0.0259040371, 0.7827717662, -0.808675766], ]; const hex2oklab = hex => { const lrgb = hex2rgb(hex).map(c => { const v = c / 255; return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); const lms = RGB_TO_LMS.map(row => Math.cbrt(vectorDot(row, lrgb))); return LMS_TO_OKLAB.map(row => vectorDot(row, lms)); }; // Misc const clamp = (min, value, max) => Math.min(Math.max(value, min), max); const productLift = (...factors) => (...args) => factors .filter(fn => !!fn) .map(fn => fn(...args)) .reduce((x, y) => x * y, 1); // Pre-computation const getColorData = hex => { const lab = hex2oklab(hex); return { hex, vector: lab, unit: unitVector(lab), chroma: Math.hypot(lab[1], lab[2]), hue: (Math.atan2(lab[2], lab[1]) + twoPi) % twoPi, }; }; const pokemonData = database.map(({ total, clusters, ...rest }) => ({ total: { ...total, unitCentroid: unitVector(total.centroid), lightness: total.centroid[0], abar: total.centroid[1], bbar: total.centroid[2], proportion: 1, beta: 1, inverseSize: 1 / total.size, }, clusters: clusters.map(c => ({ ...c, unitCentroid: unitVector(c.centroid), lightness: c.centroid[0], abar: c.centroid[1], bbar: c.centroid[2], proportion: c.size / total.size, beta: Math.sqrt((c.chroma * c.size) / total.size), inverseSize: 1 / c.size, })), ...rest, })); const pokemonLookup = new Fuse(pokemonData, { keys: ["name"] }); const calcScores = (data, target) => { const { centroid, unitCentroid, tilt, variance, chroma, hue } = data; const delta = Math.hypot(...centroid.map((c, i) => c - target.vector[i])); const psi = Math.sqrt(variance + delta * delta); const omega = 1 - vectorDot(tilt, target.unit); return { ...data, hue: data.hue * rad2deg, delta, psi, theta: Math.acos(vectorDot(unitCentroid, target.unit)) * rad2deg, omega, alpha: 100 * Math.sqrt(psi * omega), deltaL: Math.abs(centroid[0] - target.vector[0]), deltaC: Math.abs(chroma - target.chroma), deltaH: (Math.abs(hue - target.hue) % Math.PI) * rad2deg, }; }; const sortOrders = { max: (a, b) => b - a, min: (a, b) => a - b, }; // 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 filterElements = document.forms.filterControl.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 = "compare"; document.getElementById("cls-metric-mount").append(clusterMetricForm); clusterMetricForm.elements.metricKind.value = "stat"; const updateMetricSelects = form => { const kind = form.elements.metricKind.value; form.elements.compare.disabled = kind !== "compare"; form.elements.stat.disabled = kind !== "stat"; form.elements.metric.value = form.elements[kind].value; }; // 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.split("(")[1].split(")")[0], ]) ); 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 rgb = hex2rgb(hex); const lum = vectorDot(rgb, [0.3, 0.6, 0.1]); const highlight = lum >= 128 ? "var(--color-dark)" : "var(--color-light)"; return { "--highlight": highlight, "--background": hex, "--shadow-component": lum >= 128 ? "0" : "255", }; }; const setColorStyles = (style, hex) => Object.entries(getColorStyles(hex)).forEach(([prop, value]) => style.setProperty(prop, value) ); // ---- Pokemon Display ---- // pulled out bc the render uses them const metricScores = {}; const bestClusterIndices = {}; const objectiveValues = {}; const createPokemonTooltip = makeTemplate("pkmn-data-template", data => Object.fromEntries( Object.entries(data).map(([metric, value]) => [ metric, { innerText: Array.isArray(value) ? value.map(v => v.toFixed(2)).join(", ") : value.toFixed?.(3)?.replace(".000", ""), }, ]) ) ); const createPokemonTile = makeTemplate( "pkmn-tile-template", ({ name, species }, enableTotalFlags, enableClusterFlags) => { const formattedName = name .split("-") .map(part => part.charAt(0).toUpperCase() + part.substr(1)) .join(" "); let spriteName = name .toLowerCase() .replace("'", "") // farfetchd line .replace("-gmax", "-gigantamax") .replace("-alola", "-alolan") .replace("-galar", "-galarian") .replace("-hisui", "-hisuian") .replace("-paldea", "-paldean") .replace("-paldean-combat", "-paldean") // tauros .replace("-paldean-blaze", "-paldean-fire") // tauros .replace("-paldean-aqua", "-paldean-water") // tauros .replace("-phony", "") // sinistea and polteageist .replace(". ", "-") // mr mime + rime .replace("darmanitan-galarian", "darmanitan-galarian-standard") .replace("chienpao", "chien-pao") .replace("tinglu", "ting-lu") .replace("wochien", "wo-chien") .replace("chiyu", "chi-yu"); if ( [ "flabebe", "floette", "florges", "vivillon", "basculin", "furfrou", "magearna", "alcremie", ].find(s => spriteName.includes(s)) ) { spriteName = spriteName.replace(/-.*$/, ""); } const imageErrorHandler2 = ({ target }) => { target.removeEventListener("error", imageErrorHandler2); target.src = `https://img.pokemondb.net/sprites/scarlet-violet/icon/${spriteName}.png`; }; const imageErrorHandler1 = ({ target }) => { target.removeEventListener("error", imageErrorHandler1); target.addEventListener("error", imageErrorHandler2); target.src = `https://img.pokemondb.net/sprites/sword-shield/icon/${spriteName}.png`; }; const image = { alt: formattedName, src: `https://img.pokemondb.net/sprites/sword-shield/normal/${spriteName}.png`, "@error": imageErrorHandler1, }; const link = { href: `https://pokemondb.net/pokedex/${species.replace("'", "")}`, }; const score = { innerText: objectiveValues[name].toFixed(2), }; const { total, clusters } = metricScores[name]; 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[name], }, hidden: false, innerText: data.hex, "@click"() { model.setTargetColor(data.hex); }, ...getColorStyles(data.hex), }, [tooltip]: { append: createPokemonTooltip(data)[0], }, }; }) .reduce((a, b) => ({ ...a, ...b }), {}); buttonBinds.totalBtn.dataset.included = enableTotalFlags; return { name: { innerText: formattedName, title: formattedName, }, image, link, score, ...buttonBinds, }; } ); const renderPokemon = (list, target) => { target.innerText = ""; const { sortUseWholeImage, sortUseBestCluster, sortUseClusterSize, sortUseInvClusterSize, sortUseTotalSize, sortUseInvTotalSize, } = Object.fromEntries(new FormData(colorCalculateForm).entries()); const enableTotalFlags = !!( sortUseWholeImage || sortUseTotalSize || sortUseInvTotalSize ); const enableClusterFlags = !!( sortUseBestCluster || sortUseClusterSize || sortUseInvClusterSize ); target.append( ...list.map(name => createPokemonTile(name, enableTotalFlags, enableClusterFlags)[0]) ); }; // ---- Calculation Logic ---- const model = { setTargetColor(newColor) { const hex = `#${newColor?.replace("#", "")}`; if (hex.length !== 7) { return; } setColorStyles(rootStyle, hex); const oldColor = this.targetColor; this.targetColor = hex; targetColorElements.colorText.value = hex; targetColorElements.colorText.dataset.lastValid = hex; targetColorElements.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.setTargetColor(oldColor)); prevColorsSidebar.prepend(prevButton); } const targetData = getColorData(hex); pokemonData.forEach(({ name, total, clusters }) => { metricScores[name] = { total: calcScores(total, targetData), clusters: clusters.map(c => calcScores(c, targetData)), }; }); this.calculateObjective(); }, calculateObjective() { const { clusterUseClusterSize, clusterUseInvClusterSize, clusterUseTotalSize, clusterUseInvTotalSize, clusterSortOrder, sortUseWholeImage, sortUseBestCluster, sortUseClusterSize, sortUseInvClusterSize, sortUseTotalSize, sortUseInvTotalSize, } = Object.fromEntries(new FormData(colorCalculateForm).entries()); const clsMetric = 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, scores]) => { bestClusterIndices[name] = getBestClusterIndex(scores); }); const metric = 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, scores]) => { objectiveValues[name] = getSortScore(scores, bestClusterIndices[name]); }); this.renderNameSearchResults(); this.rank(); }, rank() { const { sortOrder } = Object.fromEntries(new FormData(colorSortForm).entries()); const compare = sortOrders[sortOrder]; const sortFn = (a, b) => compare(objectiveValues[a], objectiveValues[b]); this.ranked = pokemonData .slice(0) .sort((a, b) => sortFn(a.name, b.name) || a.name.localeCompare(b.name)); this.renderColorSearchResults(); }, setNameSearchResults(newNameResults) { this.nameSearchResults = newNameResults; this.renderNameSearchResults(); }, renderNameSearchResults() { renderPokemon(this.nameSearchResults ?? [], nameSearchResultsTarget); }, renderColorSearchResults() { renderPokemon( this.ranked .filter(({ traits }) => traits.every(t => !filterElements[t]?.checked)) .slice(0, parseInt(colorDisplayElements.resultsToDisplay.value)), colorSearchResultsTarget ); }, }; // ---- Form Controls ---- nameSearchFormElements.input.addEventListener("input", ({ target: { value } }) => { model.setNameSearchResults( pokemonLookup.search(value, { limit: 24 }).map(({ item }) => item) ); }); 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)] ) ); }); targetColorElements.colorText.addEventListener("input", ({ target }) => { if (target.willValidate && !target.validity.valid) { target.value = target.dataset.lastValid || ""; } else { model.setTargetColor(target.value); } }); targetColorElements.colorPicker.addEventListener("change", ({ target }) => model.setTargetColor(target.value) ); const randomizeTargetColor = () => model.setTargetColor( [Math.random(), Math.random(), Math.random()] .map(component => Math.floor(component * 256) .toString(16) .padStart(2, "0") ) .reduce((x, y) => x + y) ); targetColorElements.randomColor.addEventListener("click", randomizeTargetColor); colorDisplayElements.resultsToDisplay.addEventListener( "input", ({ target: { value } }) => { colorDisplayElements.output.value = value; } ); colorDisplayElements.resultsToDisplay.addEventListener("change", () => model.renderColorSearchResults() ); Array.from(filterElements).forEach(el => el.addEventListener("change", () => model.renderColorSearchResults()) ); Array.from(colorSortForm.elements).forEach(el => el.addEventListener("change", () => model.rank()) ); Array.from(colorCalculateForm.elements).forEach(el => el.addEventListener("change", () => { const { sortUseBestCluster, sortUseClusterSize, sortUseInvClusterSize } = Object.fromEntries(new FormData(colorCalculateForm).entries()); clusterRankingTitle.dataset.faded = clusterMetricSection.dataset.faded = clusterFunctionSection.dataset.faded = !(sortUseBestCluster || sortUseClusterSize || sortUseInvClusterSize); model.calculateObjective(); }) ); sortMetricForm.addEventListener("change", () => { updateMetricDisplays(); model.calculateObjective(); }); clusterMetricForm.addEventListener("change", () => { updateMetricDisplays(); model.calculateObjective(); }); // ---- Initial Setup ---- updateMetricDisplays(); randomizeTargetColor();