// ---- 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 = (mn, v, mx) => Math.min(Math.max(v, mn), mx); const productLift = (...factors) => (...args) => factors .filter(fn => !!fn) .map(fn => fn(...args)) .reduce((x, y) => x * y, 1); // Contrast + Shadow + Hover Colors const getContrastingTextColor = hex => { const { r, g, b } = d3.color(hex); return vectorDot([r, g, b], [0.3, 0.6, 0.1]) >= 128 ? "var(--color-dark)" : "var(--color-light)"; }; // "Visual Importance" const calcImportance = (chroma, lightness, proportion) => chroma + Math.tanh(100 * (chroma - 0.25)) + // penalty for being <25% Math.tanh(100 * (chroma - 0.4)) + // penalty for being <40% lightness + Math.tanh(100 * (lightness - 0.5)) + // penalty for being <50% proportion + Math.tanh(100 * (proportion - 0.05)) + // penalty for being <5% Math.tanh(100 * (proportion - 0.1)) + // penalty for being <15% Math.tanh(100 * (proportion - 0.15)) + // penalty for being <15% Math.tanh(100 * (proportion - 0.25)) + // penalty for being <25% Math.tanh(100 * (proportion - 0.8)); // penalty for being <50% // Conversions const jab2hex = jab => d3.jab(...jab).formatHex(); const rgb2hex = rgb => d3.rgb(...rgb).formatHex(); const jab2hue = jab => d3.jch(d3.jab(...jab)).h || 0; const rgb2hue = rgb => d3.hsl(d3.rgb(...rgb)).h || 0; const jab2lit = ([j]) => j / 100; const rgb2lit = rgb => d3.hsl(d3.rgb(...rgb)).l || 0; const jab2chroma = jab => d3.jch(d3.jab(...jab)).C / 100; const rgb2chroma = rgb => d3.jch(d3.rgb(...rgb)).C / 100; // Pre-computation const buildVectorData = (vector, toHue, toLightness, toChroma, toHex) => { 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 = vector => buildVectorData(vector, jab2hue, jab2lit, jab2chroma, jab2hex); const buildVectorDataRgb = vector => buildVectorData(vector, rgb2hue, rgb2lit, rgb2chroma, rgb2hex); 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(vectorDot(mu.unit, nu) / vectorMag(nu)); const proportion = size / totalSize; const importance = calcImportance(mu.chroma, mu.lightness, proportion); return { size, inverseSize: 1 / size, inertia, mu, nu, muNuAngle, proportion, inverseProportion: 1 / proportion, importance, }; }; const buildPokemonData = ([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 pokemonData = databaseV3.map(row => buildPokemonData(row)); 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(vectorDot(data.mu.unit, target.unit)), 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, }; // ---- Styling ---- const rootStyle = document.querySelector(":root").style; const setColorStyles = (style, hex) => { const highlight = getContrastingTextColor(hex); style.setProperty("--highlight", highlight); style.setProperty("--background", hex); style.setProperty("--shadow-component", highlight.includes("light") ? "255" : "0"); }; // ---- Pokemon Display ---- // pulled out bc the render uses them const metricScores = {}; const bestClusterIndices = {}; const objectiveValues = {}; const pokemonTileTemplate = document.getElementById("pkmn-tile-template").content; const pokemonDataTemplate = document.getElementById("pkmn-data-template").content; const loadTemplateWithBinds = content => { 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]; }) ); return [fragment, binds]; }; 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") .replace("chienpao", "chien-pao") .replace("tinglu", "ting-lu") .replace("wochien", "wo-chien") .replace("chiyu", "chi-yu"); if (stripForm.find(s => pokemon.includes(s))) { pokemon = pokemon.replace(/-.*$/, ""); } return pokemon; }; })(); const formatName = name => name .split("-") .map(part => part.charAt(0).toUpperCase() + part.substr(1)) .join(" "); const renderPokemon = (list, target) => { target.innerText = ""; const { sortUseWholeImage, sortUseBestCluster, sortUseClusterSize, sortUseInvClusterSize, sortUseTotalSize, sortUseInvTotalSize, } = Object.fromEntries(new FormData(document.forms.colorCalculateForm).entries()); const enableTotalFlags = !!( sortUseWholeImage || sortUseTotalSize || sortUseInvTotalSize ); const enableClusterFlags = !!( sortUseBestCluster || sortUseClusterSize || sortUseInvClusterSize ); list.forEach(pkmnName => { const [tile, { image, name, score, ...binds }] = loadTemplateWithBinds(pokemonTileTemplate); const spriteName = getSpriteName(pkmnName); const imageErrHandler = () => { image.removeEventListener("error", imageErrHandler); image.src = `https://img.pokemondb.net/sprites/scarlet-violet/icon/${spriteName}.png`; }; image.addEventListener("error", imageErrHandler); image.src = `https://img.pokemondb.net/sprites/sword-shield/icon/${spriteName}.png`; name.innerText = name.title = image.alt = formatName(pkmnName); const colorSpace = document.forms.colorSortForm.elements.colorSpace.value; score.innerText = objectiveValues[pkmnName][colorSpace].toFixed(2); const { total, clusters } = metricScores[pkmnName][colorSpace]; [ [clusters[0], binds.cls1Btn, binds.cls1Data], [clusters[1], binds.cls2Btn, binds.cls2Data], [clusters[2], binds.cls3Btn, binds.cls3Data], [clusters[3], binds.cls4Btn, binds.cls4Data], [total, binds.totalBtn, binds.totalData], ] .filter(([data]) => !!data) .forEach(([data, button, dataTile], index) => { button.hidden = false; button.innerText = data.muHex; button.dataset.included = enableClusterFlags && index === bestClusterIndices[pkmnName][colorSpace]; setColorStyles(button.style, data.muHex); button.addEventListener("click", () => { model.setTargetColor(data.muHex); }); const [tooltip, tooltipBinds] = loadTemplateWithBinds(pokemonDataTemplate); Object.entries(tooltipBinds).forEach(([metricName, target]) => { target.innerText = data[metricName].toFixed(2).replace(".00", ""); }); dataTile.append(tooltip); }); binds.totalBtn.dataset.included = enableTotalFlags; target.append(tile); }); }; const colorSearchResultsTarget = document.getElementById("color-results"); const nameSearchResultsTarget = document.getElementById("name-results"); // ---- Name Lookup ---- const lookupLimit = 24; const pokemonLookup = new Fuse(pokemonData, { keys: ["name"] }); const nameSearchResults = []; const renderNameSearchResults = () => { renderPokemon(nameSearchResults, nameSearchResultsTarget); }; document.forms.nameSearchForm.elements.input.addEventListener( "input", ({ target: { value } }) => { nameSearchResults.splice( 0, Infinity, ...pokemonLookup .search(value, { limit: lookupLimit }) .map(({ item: { name } }) => name) ); renderNameSearchResults(); } ); document.forms.nameSearchForm.elements.clear.addEventListener("click", () => { nameSearchResults.splice(0); document.forms.nameSearchForm.elements.input.value = ""; renderNameSearchResults(); }); document.forms.nameSearchForm.elements.random.addEventListener("click", () => { nameSearchResults.splice( 0, Infinity, ...Array.from( { length: lookupLimit }, () => pokemonData[Math.floor(Math.random() * pokemonData.length)].name ) ); renderNameSearchResults(); }); // ---- Calculation Logic ---- const model = new (class { #targetColor = ""; ranked = []; setTargetColor(newColor) { const hex = `#${newColor?.replace("#", "")}`; if (hex.length !== 7) { return; } setColorStyles(rootStyle, hex); const oldColor = this.#targetColor; this.#targetColor = hex; document.forms.targetColorForm.elements.colorText.value = hex; document.forms.targetColorForm.elements.colorText.dataset.lastValid = hex; document.forms.targetColorForm.elements.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)); document.getElementById("prevColors").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(document.forms.colorCalculateForm).entries()); const clsMetric = document.forms.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 = document.forms.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), }; }); renderNameSearchResults(); this.rank(); } rank() { const { colorSpace, sortOrder } = Object.fromEntries( new FormData(document.forms.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(); } renderColorSearchResults() { renderPokemon( this.ranked.slice( 0, parseInt(document.forms.colorDisplayForm.elements.resultsToDisplay.value) ), colorSearchResultsTarget ); } })(); // ---- Form Controls ---- document.forms.targetColorForm.elements.colorText.addEventListener( "input", ({ target }) => { if (target.willValidate && !target.validity.valid) { target.value = target.dataset.lastValid || ""; } else { model.setTargetColor(target.value); } } ); document.forms.targetColorForm.elements.colorPicker.addEventListener( "change", ({ target }) => model.setTargetColor(target.value) ); const randomizeTargetColor = () => model.setTargetColor( d3.hsl(Math.random() * 360, Math.random(), Math.random()).formatHex() ); document.forms.targetColorForm.elements.randomColor.addEventListener( "click", randomizeTargetColor ); document.forms.colorDisplayForm.elements.resultsToDisplay.addEventListener( "input", ({ target: { value } }) => { document.forms.colorDisplayForm.elements.output.value = value; } ); document.forms.colorDisplayForm.elements.resultsToDisplay.addEventListener("change", () => model.renderColorSearchResults() ); Array.from(document.forms.colorSortForm.elements).forEach(el => el.addEventListener("change", () => model.rank()) ); const clusterRankingTitle = document.getElementById("cls-title"); const clusterMetricSection = document.getElementById("cls-metric-mount"); const clusterFunctionSection = document.getElementById("cls-fn"); Array.from(document.forms.colorCalculateForm.elements).forEach(el => el.addEventListener("change", () => { const { sortUseBestCluster, sortUseClusterSize, sortUseInvClusterSize } = Object.fromEntries(new FormData(document.forms.colorCalculateForm).entries()); clusterRankingTitle.dataset.faded = clusterMetricSection.dataset.faded = clusterFunctionSection.dataset.faded = !(sortUseBestCluster || sortUseClusterSize || sortUseInvClusterSize); model.calculateObjective(); }) ); // ---- Add Metric Selections ---- const metricSelectTemplate = document.getElementById("metric-select-template").content; const sortMetricForm = metricSelectTemplate.cloneNode(true).firstElementChild; sortMetricForm.id = "sortMetricForm"; const clusterMetricForm = metricSelectTemplate.cloneNode(true).firstElementChild; clusterMetricForm.id = "clusterMetricForm"; document.getElementById("sort-metric-mount").append(sortMetricForm); document.getElementById("cls-metric-mount").append(clusterMetricForm); document.forms.sortMetricForm.elements.metricKind.value = "whole"; document.forms.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; }; const getMetricSymbol = metricName => // terrible hack document.querySelector(`option[value=${metricName}]`).textContent.at(-2); const onMetricChange = () => { updateMetricSelects(document.forms.sortMetricForm); updateMetricSelects(document.forms.clusterMetricForm); document.forms.colorCalculateForm.elements.sortMetricSymbolP.value = document.forms.colorCalculateForm.elements.sortMetricSymbolB.value = getMetricSymbol( document.forms.sortMetricForm.elements[ document.forms.sortMetricForm.elements.metricKind.value ].value ); document.forms.colorCalculateForm.elements.clusterMetricSymbol.value = getMetricSymbol( document.forms.clusterMetricForm.elements[ document.forms.clusterMetricForm.elements.metricKind.value ].value ); }; onMetricChange(); document.forms.sortMetricForm.addEventListener("change", () => { onMetricChange(); model.calculateObjective(); }); document.forms.clusterMetricForm.addEventListener("change", () => { onMetricChange(); model.calculateObjective(); }); // ---- Pick Starting Color ---- randomizeTargetColor();