123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618 |
- // ---- 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 link = {
- href: `https://pokemondb.net/pokedex/${spriteName}`,
- };
- 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, link, 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();
|