// ---- Math and Utilities ---- // Vector Math const vectorDot = (u, v) => u.map((x, i) => x * v[i]).reduce((x, y) => x + y); const vectorMag = v => Math.sqrt(vectorDot(v, v)); // Angle Math const rad2deg = 180 / Math.PI; // 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 getVectorDataBuilder = (toHue, toLightness, toChroma, toHex) => vector => { const sqMag = vectorDot(vector, vector); const mag = Math.sqrt(sqMag); const unit = vector.map(c => c / mag); const hue = toHue(vector); const lightness = toLightness(vector); const chroma = toChroma(vector); const hex = toHex(vector); return { vector, sqMag, mag, unit, hue, lightness, chroma, hex }; }; const buildVectorDataJab = getVectorDataBuilder( jab => d3.jch(d3.jab(...jab)).h || 0, // Jab -> hue ([j]) => j / 100, // Jab -> lightness jab => d3.jch(d3.jab(...jab)).C / 100, // Jab -> chroma jab => d3.jab(...jab).formatHex() // Jab -> hex ); const buildVectorDataRgb = getVectorDataBuilder( rgb => d3.hsl(d3.rgb(...rgb)).h || 0, // RGB -> hue rgb => d3.hsl(d3.rgb(...rgb)).l || 0, // RGB -> lightness rgb => d3.jch(d3.rgb(...rgb)).C / 100, // RGB -> chroma rgb => d3.rgb(...rgb).formatHex() // RGB -> hex ); const buildClusterData = ( size, inertia, mu1, mu2, mu3, nu1, nu2, nu3, totalSize, buildVectorDataForSpace ) => { const mu = buildVectorDataForSpace([mu1, mu2, mu3]); const nu = [nu1, nu2, nu3]; const muNuAngle = rad2deg * Math.acos(clamp(-1, vectorDot(mu.unit, nu) / vectorMag(nu), 1)); const proportion = size / totalSize; // "Visual Importance" - effectively a comparison where size is the // biggest factor, then lightness, then chroma const importance = 100 * proportion + 10 * mu.lightness + mu.chroma; return { size, inverseSize: 1 / size, inertia, mu, nu, muNuAngle, proportion, inverseProportion: 1 / proportion, importance, }; }; const pokemonData = databaseV3.map(([name, size, ...values]) => ({ name, jab: { total: buildClusterData(size, ...values.slice(0, 7), size, buildVectorDataJab), clusters: [ buildClusterData(...values.slice(7, 15), size, buildVectorDataJab), buildClusterData(...values.slice(15, 23), size, buildVectorDataJab), buildClusterData(...values.slice(23, 31), size, buildVectorDataJab), buildClusterData(...values.slice(31, 39), size, buildVectorDataJab), ].filter(c => c.size !== 0), }, rgb: { total: buildClusterData(size, ...values.slice(39, 46), size, buildVectorDataRgb), clusters: [ buildClusterData(...values.slice(46, 54), size, buildVectorDataRgb), buildClusterData(...values.slice(54, 62), size, buildVectorDataRgb), buildClusterData(...values.slice(62, 70), size, buildVectorDataRgb), buildClusterData(...values.slice(70, 78), size, buildVectorDataRgb), ].filter(c => c.size !== 0), }, })); 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 ); const bigTheta = 1 - vectorDot(data.nu, target.unit); const rawPhi = Math.abs(data.mu.hue - target.hue); return { sigma, bigTheta, alpha: sigma * Math.pow(bigTheta, target.chroma + target.lightness), theta: rad2deg * Math.acos(clamp(-1, vectorDot(data.mu.unit, target.unit), 1)), phi: Math.min(rawPhi, 360 - rawPhi), delta: vectorMag(data.mu.vector.map((x, i) => x - target.vector[i])), manhattan: data.mu.vector .map((x, i) => Math.abs(x - target.vector[i])) .reduce((x, y) => x + y), ch: Math.max(...data.mu.vector.map((x, i) => Math.abs(x - target.vector[i]))), lightnessDiff: Math.abs(data.mu.lightness - target.lightness), inertia: data.inertia, variance: data.inertia - data.mu.sqMag, muNuAngle: data.muNuAngle, size: data.size, lightness: data.mu.lightness, chroma: data.mu.chroma, importance: data.importance, inverseSize: data.inverseSize, proportion: data.proportion, inverseProportion: data.inverseProportion, muHex: data.mu.hex, }; }; 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 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; }; // 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)"; 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 const metricScores = {}; const bestClusterIndices = {}; const objectiveValues = {}; 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, }; let spriteName = pkmnName .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") .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 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, 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, colorSpace, 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 rgb = d3.rgb(hex); const { J, a, b } = d3.jab(rgb); const targetJab = buildVectorDataJab([J, a, b]); const targetRgb = buildVectorDataRgb([rgb.r, rgb.g, rgb.b]); pokemonData.forEach(({ name, jab, rgb }) => { metricScores[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)), }, }; }); 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, { jab, rgb }]) => { bestClusterIndices[name] = { jab: getBestClusterIndex(jab), rgb: getBestClusterIndex(rgb), }; }); 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, { jab, rgb }]) => { objectiveValues[name] = { jab: getSortScore(jab, bestClusterIndices[name].jab), rgb: getSortScore(rgb, bestClusterIndices[name].rgb), }; }); this.renderNameSearchResults(); this.rank(); }, rank() { const { colorSpace, sortOrder } = Object.fromEntries( new FormData(colorSortForm).entries() ); const compare = sortOrders[sortOrder]; const sortFn = (a, b) => compare(objectiveValues[a][colorSpace], objectiveValues[b][colorSpace]); this.ranked = pokemonData .map(({ name }) => name) .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(colorDisplayElements.resultsToDisplay.value)), colorSearchResultsTarget ); }, }; // ---- Form Controls ---- 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); } }); targetColorElements.colorPicker.addEventListener("change", ({ target }) => model.setTargetColor(target.value) ); const randomizeTargetColor = () => model.setTargetColor( d3.hsl(Math.random() * 360, Math.random(), Math.random()).formatHex() ); targetColorElements.randomColor.addEventListener("click", randomizeTargetColor); colorDisplayElements.resultsToDisplay.addEventListener( "input", ({ target: { value } }) => { colorDisplayElements.output.value = value; } ); colorDisplayElements.resultsToDisplay.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();