|
@@ -1,631 +0,0 @@
|
|
-// Selectors + DOM Manipulation
|
|
|
|
-const getColorInputNode = () => document.getElementById("color-input");
|
|
|
|
-const getMetricDropdownNode = () => document.getElementById("metric");
|
|
|
|
-const getClusterChoiceDropdownNode = () => document.getElementById("image-summary");
|
|
|
|
-const getClusterScaleToggleNode = () => document.getElementById("scale-by-cluster-size");
|
|
|
|
-const getClusterMeanWarning = () => document.getElementById("cluster-mean-warning");
|
|
|
|
-const getIncludeXToggleNode = () => document.getElementById("include-x");
|
|
|
|
-const getNormQYToggleNode = () => document.getElementById("norm-q-y");
|
|
|
|
-const getCloseCoeffSliderNode = () => document.getElementById("close-coeff");
|
|
|
|
-const getCloseCoeffDisplayNode = () => document.getElementById("close-coeff-display");
|
|
|
|
-const getLimitSliderNode = () => document.getElementById("num-poke");
|
|
|
|
-const getLimitDisplayNode = () => document.getElementById("num-poke-display");
|
|
|
|
-const getNameInputNode = () => document.getElementById("pokemon-name");
|
|
|
|
-const getScoreListJABNode = () => document.getElementById("best-list-jab");
|
|
|
|
-const getScoreListRGBNode = () => document.getElementById("best-list-rgb");
|
|
|
|
-const getSearchSpaceDisplayNode = () => document.getElementById("search-space-display");
|
|
|
|
-const getSearchListNode = () => document.getElementById("search-list");
|
|
|
|
-const getHideableControlNodes = () => document.querySelectorAll(".hideable_control");
|
|
|
|
-const getQJABDisplay = () => document.getElementById("q-vec-jab");
|
|
|
|
-const getQRGBDisplay = () => document.getElementById("q-vec-rgb");
|
|
|
|
-const getObjFnDisplay = () => document.getElementById("obj-fn");
|
|
|
|
-
|
|
|
|
-const clearNodeContents = node => { node.innerHTML = ""; };
|
|
|
|
-const hideCustomControls = () => getHideableControlNodes()
|
|
|
|
- .forEach(n => n.setAttribute("class", "hideable_control hideable_control--hidden"));
|
|
|
|
-const showCustomControls = () => getHideableControlNodes()
|
|
|
|
- .forEach(n => n.setAttribute("class", "hideable_control"));
|
|
|
|
-
|
|
|
|
-// Vector Math
|
|
|
|
-const vectorDot = (u, v) => u.map((x, i) => x * v[i]).reduce((x, y) => x + y);
|
|
|
|
-const vectorSqMag = v => vectorDot(v, v);
|
|
|
|
-const vectorMag = v => Math.sqrt(vectorSqMag(v));
|
|
|
|
-const vectorSqDist = (u, v) => vectorSqMag(u.map((x, i) => x - v[i]));
|
|
|
|
-const vectorDist = (u, v) => Math.sqrt(vectorSqDist(u, v));
|
|
|
|
-const vectorNorm = v => { const n = vectorMag(v); return [ n, v.map(c => c / n) ]; };
|
|
|
|
-
|
|
|
|
-// Angle Math
|
|
|
|
-const angleDiff = (a, b) => { const raw = Math.abs(a - b); return raw < 180 ? raw : (360 - raw); };
|
|
|
|
-const rad2deg = 180 / Math.PI;
|
|
|
|
-
|
|
|
|
-// Conversions
|
|
|
|
-const jab2hex = jab => d3.jab(...jab).formatHex();
|
|
|
|
-const rgb2hex = rgb => d3.rgb(...rgb).formatHex();
|
|
|
|
-const jab2hue = ([, a, b]) => rad2deg * Math.atan2(b, a);
|
|
|
|
-const rgb2hue = rgb => d3.hsl(d3.rgb(...rgb)).h || 0;
|
|
|
|
-const hex2rgb = hex => {
|
|
|
|
- const { r, g, b } = d3.color(hex);
|
|
|
|
- return [r, g, b];
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-// Arg Compare
|
|
|
|
-const argComp = comp => ra => ra.map((x, i) => [x, i]).reduce((a, b) => comp(a[0], b[0]) > 0 ? b : a)[1];
|
|
|
|
-const argMin = argComp((a, b) => a - b);
|
|
|
|
-const argMax = argComp((a, b) => b - a);
|
|
|
|
-
|
|
|
|
-// Pre-Compute Data
|
|
|
|
-const computeVectorData = (vector, toHex, toHue) => {
|
|
|
|
- const [ magnitude, unit ] = vectorNorm(vector);
|
|
|
|
- return {
|
|
|
|
- vector,
|
|
|
|
- magnitude,
|
|
|
|
- magSq: magnitude * magnitude,
|
|
|
|
- unit,
|
|
|
|
- hex: toHex(vector),
|
|
|
|
- hue: toHue(vector),
|
|
|
|
- };
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-const computeStats = (inertia, trueMeanVec, kMeanStruct, toHex, toHue) => ({
|
|
|
|
- inertia,
|
|
|
|
- trueMean: computeVectorData(trueMeanVec, toHex, toHue),
|
|
|
|
- kMeans: kMeanStruct.slice(0, 3).map(z => computeVectorData(z, toHex, toHue)),
|
|
|
|
- kWeights: kMeanStruct[3],
|
|
|
|
- largestCluster: argMax(kMeanStruct[3]),
|
|
|
|
- smallestCluster: argMin(kMeanStruct[3]),
|
|
|
|
-});
|
|
|
|
-
|
|
|
|
-const pokemonColorData = database.map(({
|
|
|
|
- name, xJAB, xRGB, yJAB, yRGB, zJAB, zRGB,
|
|
|
|
-}) => ({
|
|
|
|
- name,
|
|
|
|
- jabStats: computeStats(xJAB, yJAB, zJAB, jab2hex, jab2hue),
|
|
|
|
- rgbStats: computeStats(xRGB, yRGB, zRGB, rgb2hex, rgb2hue),
|
|
|
|
-}));
|
|
|
|
-
|
|
|
|
-const pokemonLookup = new Fuse(pokemonColorData, { keys: [ "name" ] });
|
|
|
|
-
|
|
|
|
-// Color Calculations
|
|
|
|
-const getContrastingTextColor = rgb => vectorDot(rgb, [0.3, 0.6, 0.1]) >= 128 ? "#222" : "#ddd";
|
|
|
|
-
|
|
|
|
-const readColorInput = () => {
|
|
|
|
- const colorInput = "#" + (getColorInputNode()?.value?.replace("#", "") ?? "FFFFFF");
|
|
|
|
-
|
|
|
|
- if (colorInput.length !== 7) {
|
|
|
|
- return;
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- const rgb = d3.color(colorInput);
|
|
|
|
- const { J, a, b } = d3.jab(rgb);
|
|
|
|
-
|
|
|
|
- return {
|
|
|
|
- jabData: computeVectorData([ J, a, b ], jab2hex, jab2hue),
|
|
|
|
- rgbData: computeVectorData([ rgb.r, rgb.g, rgb.b ], rgb2hex, rgb2hue),
|
|
|
|
- };
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-// State
|
|
|
|
-const state = {
|
|
|
|
- metric: null,
|
|
|
|
- clusterChoice: null,
|
|
|
|
- includeScale: null,
|
|
|
|
- includeX: null,
|
|
|
|
- normQY: null,
|
|
|
|
- closeCoeff: null,
|
|
|
|
- numPoke: null,
|
|
|
|
- searchTerm: null,
|
|
|
|
- searchSpace: null,
|
|
|
|
- targetColor: null,
|
|
|
|
- searchResults: null,
|
|
|
|
- clusterToggles: {},
|
|
|
|
- currentScores: {},
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-// Metrics
|
|
|
|
-const getBestKMean = (stats, q) => argMin(stats.kMeans.map(z => vectorDist(z.vector, q.vector)));
|
|
|
|
-const getWorstKMean = (stats, q) => argMax(stats.kMeans.map(z => vectorDist(z.vector, q.vector)));
|
|
|
|
-
|
|
|
|
-const getScale = weight => state.includeScale ? (1 / weight) : 1;
|
|
|
|
-
|
|
|
|
-const summarySelectors = [
|
|
|
|
- // true mean
|
|
|
|
- stats => [stats.trueMean, 1],
|
|
|
|
- // largest cluster
|
|
|
|
- stats => [stats.kMeans[stats.largestCluster], getScale(stats.kWeights[stats.largestCluster])],
|
|
|
|
- // smallest cluster
|
|
|
|
- stats => [stats.kMeans[stats.smallestCluster], getScale(stats.kWeights[stats.smallestCluster])],
|
|
|
|
- // best fit cluster
|
|
|
|
- (stats, q) => {
|
|
|
|
- const best = getBestKMean(stats, q);
|
|
|
|
- return [stats.kMeans[best], getScale(stats.kWeights[best])];
|
|
|
|
- },
|
|
|
|
- // worst fit cluster
|
|
|
|
- (stats, q) => {
|
|
|
|
- const worst = getWorstKMean(stats, q);
|
|
|
|
- return [stats.kMeans[worst], getScale(stats.kWeights[worst])];
|
|
|
|
- },
|
|
|
|
-];
|
|
|
|
-
|
|
|
|
-const selectedSummary = (stats, q) => summarySelectors[state.clusterChoice](stats, q);
|
|
|
|
-
|
|
|
|
-// TODO unfortunately the addition of the scaling factor means we have to compute *real*
|
|
|
|
-// metrics instead of cheaper approximations. would be nice to fix this
|
|
|
|
-const metrics = [
|
|
|
|
- // RMS
|
|
|
|
- (stats, q) => {
|
|
|
|
- const [ mean, scale ] = selectedSummary(stats, q);
|
|
|
|
- return (stats.inertia - 2 * vectorDot(mean.vector, q.vector)) * scale;
|
|
|
|
- },
|
|
|
|
- // mean angle
|
|
|
|
- (stats, q) => {
|
|
|
|
- const [ mean, scale ] = selectedSummary(stats, q);
|
|
|
|
- return rad2deg * Math.acos(vectorDot(mean.unit, q.unit)) * scale;
|
|
|
|
- },
|
|
|
|
- // mean dist
|
|
|
|
- (stats, q) => {
|
|
|
|
- const [ mean, scale ] = selectedSummary(stats, q);
|
|
|
|
- // TODO I know there's some way to avoid recalculation here but I'm just too lazy right now
|
|
|
|
- return vectorDist(mean.vector, q.vector) * scale;
|
|
|
|
- },
|
|
|
|
- // hue angle
|
|
|
|
- (stats, q) => {
|
|
|
|
- const [ mean, scale ] = selectedSummary(stats, q);
|
|
|
|
- return angleDiff(mean.hue, q.hue) * scale;
|
|
|
|
- },
|
|
|
|
- // max inertia
|
|
|
|
- (stats, q) => {
|
|
|
|
- const [ , scale ] = selectedSummary(stats, q);
|
|
|
|
- return -stats.inertia * scale;
|
|
|
|
- },
|
|
|
|
- // chebyshev
|
|
|
|
- (stats, q) => {
|
|
|
|
- const [ mean, scale ] = selectedSummary(stats, q);
|
|
|
|
- return Math.max(...mean.vector.map((x, i) => Math.abs(x - q.vector[i]))) * scale;
|
|
|
|
- },
|
|
|
|
- // custom
|
|
|
|
- (stats, q) => {
|
|
|
|
- const [ mean, scale ] = selectedSummary(stats, q);
|
|
|
|
- return (
|
|
|
|
- (state.includeX ? stats.inertia : 0)
|
|
|
|
- -
|
|
|
|
- state.closeCoeff * vectorDot(
|
|
|
|
- mean[state.normQY ? "unit" : "vector"],
|
|
|
|
- state.normQY ? q.unit : q.vector,
|
|
|
|
- )
|
|
|
|
- ) * scale;
|
|
|
|
- },
|
|
|
|
-];
|
|
|
|
-
|
|
|
|
-const scorePokemon = pkmn => ({
|
|
|
|
- jab: metrics[state.metric](pkmn.jabStats, state.targetColor.jabData),
|
|
|
|
- rgb: metrics[state.metric](pkmn.rgbStats, state.targetColor.rgbData),
|
|
|
|
-});
|
|
|
|
-
|
|
|
|
-const calcDisplayMetrics = (meanData, q) => ({
|
|
|
|
- theta: rad2deg * Math.acos(vectorDot(q.unit, meanData.unit)),
|
|
|
|
- delta: vectorDist(q.vector, meanData.vector),
|
|
|
|
- phi: angleDiff(q.hue, meanData.hue),
|
|
|
|
-});
|
|
|
|
-
|
|
|
|
-// Math Rendering
|
|
|
|
-const renderQVec = (q, node, sub) => {
|
|
|
|
- node.innerHTML = TeXZilla.toMathMLString(String.raw`\vec{q}_{\text{${sub}}} = \left(\text{${q.join(", ")}}\right)`);
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-const mathArgBest = (mxn, arg) => `\\underset{${arg}}{\\arg\\${mxn}}`;
|
|
|
|
-
|
|
|
|
-const mathDefinitions = {
|
|
|
|
- "main-definition": String.raw`
|
|
|
|
- \begin{aligned}
|
|
|
|
- \vec{\mu}\left(P\right) &= \frac{1}{\left|P\right|}\sum_{p\in P}{\vec{p}} \\
|
|
|
|
- I\left(P\right) &= \frac{1}{\left|P\right|}\sum_{p\in P}{\left|\left|\vec{p}\right|\right|^2} \\
|
|
|
|
- \delta\left(P\right) &= \left|\left| \vec{q} - \vec{\mu}\left(P\right) \right|\right| \\
|
|
|
|
- \theta\left(P\right) &= \angle \left(\vec{q}, \vec{\mu}\left(P\right)\right) \\
|
|
|
|
- \vec{x}_{\perp} &= \text{oproj}_{\left\{\vec{J}, \vec{L}\right\}}{\vec{x}} \\
|
|
|
|
- \phi\left(P\right) &= \angle \left(\vec{q}_{\perp}, \vec{\mu}\left(P\right)_{\perp} \right) \\
|
|
|
|
- \sigma\left(P\right) &= \sqrt{E\left[\left(\vec{q} - P\right)^2\right]} \\
|
|
|
|
- &= \sqrt{\frac{1}{|P|}\sum_{p \in P}{\left|\left|\vec{p} - \vec{q}\right|\right|^2}}
|
|
|
|
- \end{aligned}
|
|
|
|
- `,
|
|
|
|
- "cluster-definition": String.raw`
|
|
|
|
- \begin{aligned}
|
|
|
|
- \left\{P_1, P_2, P_3\right\} &= ${mathArgBest("max", String.raw`\left\{P_1, P_2, P_3\right\}`)} \sum_{i=1}^3 \sum_{p\inP_i} \left|\left| \vec{p} - \vec{\mu}\left(P_i\right) \right|\right|^2 \\
|
|
|
|
- \pi_i &= \frac{\left|P_i\right|}{\left|P\right|} \\
|
|
|
|
- M\left(P\right) &= ${mathArgBest("max", "P_i")} \left( \left|P_i\right| \right) \\
|
|
|
|
- m\left(P\right) &= ${mathArgBest("min", "P_i")} \left( \left|P_i\right| \right) \\
|
|
|
|
- \alpha\left(P\right) &= ${mathArgBest("min", "P_i")} \left( \left|\left| \vec{q} - \vec{\mu}\left(P_i\right) \right|\right| \right) \\
|
|
|
|
- \omega\left(P\right) &= ${mathArgBest("max", "P_i")} \left( \left|\left| \vec{q} - \vec{\mu}\left(P_i\right) \right|\right| \right)
|
|
|
|
- \end{aligned}
|
|
|
|
- `,
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-const includeScaleFactor = muArg => state.clusterChoice > 0 && state.includeScale ? String.raw`\frac{\left|P\right|}{\left|${muArg}\right|}` : ""
|
|
|
|
-
|
|
|
|
-const metricText = [
|
|
|
|
- muArg => String.raw`
|
|
|
|
- ${mathArgBest("min", "P")}\left[
|
|
|
|
- ${includeScaleFactor(muArg)}I\left(P\right)
|
|
|
|
- - 2\vec{q}\cdot ${includeScaleFactor(muArg)}\vec{\mu}\left(${muArg}\right)
|
|
|
|
- \right]`,
|
|
|
|
- muArg => String.raw`${mathArgBest("min", "P")}\left[${includeScaleFactor(muArg)}\angle \left(\vec{q}, \vec{\mu}\left(${muArg}\right)\right)\right]`,
|
|
|
|
- muArg => String.raw`${mathArgBest("min", "P")}\left[${includeScaleFactor(muArg)}\left|\left|\vec{q} - \vec{\mu}\left(${muArg}\right) \right|\right|\right]`,
|
|
|
|
- muArg => String.raw`${mathArgBest("min", "P")}\left[${includeScaleFactor(muArg)}\angle \left(\vec{q}_{\perp}, \vec{\mu}\left(${muArg}\right)_{\perp}\right)\right]`,
|
|
|
|
- muArg => String.raw`${mathArgBest("min", "P")}\left[-${includeScaleFactor(muArg)}I\left(P\right)\right]`,
|
|
|
|
- muArg => String.raw`${mathArgBest("min", "P")}\left[${includeScaleFactor(muArg)} \max_{i} \left|\vec{\mu}\left(${muArg}\right)_i - \vec{q}_i \right|\right]`,
|
|
|
|
-].map(s => muArg => TeXZilla.toMathML(s(muArg)));
|
|
|
|
-
|
|
|
|
-const muArgs = [
|
|
|
|
- "P",
|
|
|
|
- String.raw`M\left(P\right)`,
|
|
|
|
- String.raw`m\left(P\right)`,
|
|
|
|
- String.raw`\alpha\left(P\right)`,
|
|
|
|
- String.raw`\omega\left(P\right)`,
|
|
|
|
-];
|
|
|
|
-
|
|
|
|
-const renderVec = math => String.raw`\vec{${math.charAt(0)}}${math.substr(1)}`;
|
|
|
|
-const renderNorm = vec => String.raw`\frac{${vec}}{\left|\left|${vec}\right|\right|}`;
|
|
|
|
-const updateObjective = () => {
|
|
|
|
- const muArg = muArgs[state.clusterChoice];
|
|
|
|
- let tex = metricText?.[state.metric]?.(muArg);
|
|
|
|
- if (!tex) {
|
|
|
|
- const { includeX, normQY, closeCoeff } = state;
|
|
|
|
- if (!includeX && closeCoeff === 0) {
|
|
|
|
- tex = TeXZilla.toMathML(String.raw`\text{Malamar-ness}`);
|
|
|
|
- } else {
|
|
|
|
- const qyMod = normQY ? renderNorm : c => c;
|
|
|
|
- tex = TeXZilla.toMathML(String.raw`
|
|
|
|
- ${mathArgBest("min", "P")}
|
|
|
|
- \left[
|
|
|
|
- ${includeX ? String.raw`${includeScaleFactor(muArg)}I\left(P\right)` : ""}
|
|
|
|
- ${closeCoeff === 0 ? "" : String.raw`
|
|
|
|
- -
|
|
|
|
- ${closeCoeff}
|
|
|
|
- ${qyMod("\\vec{q}")}
|
|
|
|
- \cdot
|
|
|
|
- ${includeScaleFactor(muArg)}${qyMod(String.raw`\vec{\mu}\left(${muArg}\right)`)}
|
|
|
|
- `}
|
|
|
|
- \right]
|
|
|
|
- `);
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
- const objFnNode = getObjFnDisplay();
|
|
|
|
- clearNodeContents(objFnNode);
|
|
|
|
- objFnNode.appendChild(tex);
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-// Pokemon Rendering
|
|
|
|
-const stripForm = ["flabebe", "floette", "florges", "vivillon", "basculin", "furfrou", "magearna"];
|
|
|
|
-
|
|
|
|
-const getSprite = pokemon => {
|
|
|
|
- pokemon = pokemon
|
|
|
|
- .replace("-alola", "-alolan")
|
|
|
|
- .replace("-galar", "-galarian")
|
|
|
|
- .replace("darmanitan-galarian", "darmanitan-galarian-standard");
|
|
|
|
- if (stripForm.find(s => pokemon.includes(s))) {
|
|
|
|
- pokemon = pokemon.replace(/-.*$/, "");
|
|
|
|
- }
|
|
|
|
- return `https://img.pokemondb.net/sprites/sword-shield/icon/${pokemon}.png`;
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-const renderCluster = ({
|
|
|
|
- index, big, small, best, worst, pi, theta, delta, phi, hex, vector,
|
|
|
|
-}) => `
|
|
|
|
- <div
|
|
|
|
- class="pkmn_tile-cluster"
|
|
|
|
- style="grid-area: k${index + 1}; color: ${getContrastingTextColor(hex2rgb(hex))}; background-color: ${hex};"
|
|
|
|
- >
|
|
|
|
- <div class="pkmn_tile-cluster-top_label" style="grid-area: lbl;">
|
|
|
|
- ${index === big ? "<span>M</span>" : ""}
|
|
|
|
- ${index === small ? "<span>m</span>" : ""}
|
|
|
|
- ${index === best ? "<span>α</span>" : ""}
|
|
|
|
- ${index === worst ? "<span>ω</span>" : ""}
|
|
|
|
- </div>
|
|
|
|
- <div class="pkmn_tile-cluster-stat_label" style="grid-area: mu;">μ =</div>
|
|
|
|
- <div class="pkmn_tile-cluster-stat_label" style="grid-area: pi;">π =</div>
|
|
|
|
- <div class="pkmn_tile-cluster-stat_label" style="grid-area: th;">θ =</div>
|
|
|
|
- <div class="pkmn_tile-cluster-stat_label" style="grid-area: dl;">δ =</div>
|
|
|
|
- <div class="pkmn_tile-cluster-stat_label" style="grid-area: ph;">ϕ =</div>
|
|
|
|
- <div style="grid-area: mux">${hex}</div>
|
|
|
|
- <div style="grid-area: muv; justify-self: center;">(${vector.map(c => c.toFixed(2)).join(", ")})</div>
|
|
|
|
- <div style="grid-area: piv">${(pi * 100).toFixed(1)}%</div>
|
|
|
|
- <div style="grid-area: thv">${theta.toFixed(2)}°</div>
|
|
|
|
- <div style="grid-area: dlv">${delta.toFixed(2)}</div>
|
|
|
|
- <div style="grid-area: phv">${phi.toFixed(2)}°</div>
|
|
|
|
- </div>
|
|
|
|
-`;
|
|
|
|
-
|
|
|
|
-const getPokemonRenderer = targetList => (name, stats, q, score, idPostfix) => {
|
|
|
|
- let sigma, metrics, kMeanInfo, kMeanResults;
|
|
|
|
- if (q) {
|
|
|
|
- sigma = Math.sqrt(stats.inertia - 2 * vectorDot(stats.trueMean.vector, q.vector) + q.magSq)
|
|
|
|
- metrics = calcDisplayMetrics(stats.trueMean, q)
|
|
|
|
- kMeanInfo = {
|
|
|
|
- big: stats.largestCluster,
|
|
|
|
- small: stats.smallestCluster,
|
|
|
|
- best: getBestKMean(stats, q),
|
|
|
|
- worst: getWorstKMean(stats, q), // TODO yeah yeah this is a recalc whatever
|
|
|
|
- };
|
|
|
|
- kMeanResults = stats.kMeans.map(k => calcDisplayMetrics(k, q));
|
|
|
|
- } else {
|
|
|
|
- // no target color, just do all zeros
|
|
|
|
- sigma = 0;
|
|
|
|
- metrics = { theta: 0, delta: 0, phi: 0 };
|
|
|
|
- kMeanInfo = { big: 0, small: 0, best: 0, worst: 0 };
|
|
|
|
- kMeanResults = [ metrics, metrics, metrics ];
|
|
|
|
- }
|
|
|
|
- const clusterToggleId = `reveal_clusters-${name}-${idPostfix}`;
|
|
|
|
-
|
|
|
|
- const li = document.createElement("li");
|
|
|
|
- li.innerHTML = `
|
|
|
|
- <div class="pkmn_tile">
|
|
|
|
- <img class="pkmn_tile-img" src="${getSprite(name)}" />
|
|
|
|
- <span class="pkmn_tile-name">
|
|
|
|
- ${name.split("-").map(part => part.charAt(0).toUpperCase() + part.substr(1)).join(" ")}
|
|
|
|
- </span>
|
|
|
|
- <div class="pkmn_tile-fn">
|
|
|
|
- ${score?.toFixed(3) ?? ""}
|
|
|
|
- </div>
|
|
|
|
- <input
|
|
|
|
- type="checkbox"
|
|
|
|
- ${state.clusterToggles?.[clusterToggleId] ? "checked" : ""}
|
|
|
|
- id="${clusterToggleId}"
|
|
|
|
- onchange="state.clusterToggles['${clusterToggleId}'] = event.target.checked"
|
|
|
|
- class="pkmn_tile-reveal_clusters"
|
|
|
|
- role="button"
|
|
|
|
- >
|
|
|
|
- <label class="pkmn_tile-reveal_clusters_label" for="${clusterToggleId}">
|
|
|
|
- <div class="pkmn_tile-reveal_clusters_label--closed">►</div>
|
|
|
|
- <div class="pkmn_tile-reveal_clusters_label--open">▼</div>
|
|
|
|
- </label>
|
|
|
|
- <div
|
|
|
|
- class="pkmn_tile-true_mean"
|
|
|
|
- style="color: ${getContrastingTextColor(hex2rgb(stats.trueMean.hex))}; background-color: ${stats.trueMean.hex};"
|
|
|
|
- >
|
|
|
|
- <div class="pkmn_tile-true_mean-value">
|
|
|
|
- <div class="pkmn_tile-true_mean-mu_label">μ =</div>
|
|
|
|
- <div class="pkmn_tile-true_mean-mu_hex">${stats.trueMean.hex}</div>
|
|
|
|
- <div class="pkmn_tile-true_mean-mu_vec">
|
|
|
|
- (${stats.trueMean.vector.map(c => c.toFixed(2)).join(", ")})
|
|
|
|
- </div>
|
|
|
|
- </div>
|
|
|
|
- <div class="pkmn_tile-true_mean-stat pkmn_tile-true_mean-inertia">
|
|
|
|
- 𝖨 = ${stats.inertia.toFixed(2)}
|
|
|
|
- </div>
|
|
|
|
- <div class="pkmn_tile-true_mean-stat pkmn_tile-true_mean-stat-sigma">
|
|
|
|
- σ = ${sigma.toFixed(2)}
|
|
|
|
- </div>
|
|
|
|
- <div class="pkmn_tile-true_mean-stat pkmn_tile-true_mean-stat-theta">
|
|
|
|
- θ = ${metrics.theta.toFixed(2)}°
|
|
|
|
- </div>
|
|
|
|
- <div class="pkmn_tile-true_mean-stat pkmn_tile-true_mean-stat-delta">
|
|
|
|
- δ = ${metrics.delta.toFixed(2)}
|
|
|
|
- </div>
|
|
|
|
- <div class="pkmn_tile-true_mean-stat pkmn_tile-true_mean-stat-phi">
|
|
|
|
- ϕ = ${metrics.phi.toFixed(2)}°
|
|
|
|
- </div>
|
|
|
|
- </div>
|
|
|
|
- ${stats.kMeans.map((data, index) => renderCluster({
|
|
|
|
- index,
|
|
|
|
- ...kMeanInfo,
|
|
|
|
- pi: stats.kWeights[index],
|
|
|
|
- ...kMeanResults[index],
|
|
|
|
- hex: data.hex,
|
|
|
|
- vector: data.vector,
|
|
|
|
- })).join("\n")}
|
|
|
|
- </div>
|
|
|
|
- `;
|
|
|
|
- targetList.appendChild(li);
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-// Update Search Results
|
|
|
|
-const renderSearch = () => {
|
|
|
|
- const resultsNode = getSearchListNode();
|
|
|
|
- const append = getPokemonRenderer(resultsNode);
|
|
|
|
- clearNodeContents(resultsNode);
|
|
|
|
- const argMapper = state.searchSpace === "RGB"
|
|
|
|
- ? pkmn => [pkmn.rgbStats, state.targetColor?.rgbData, state.currentScores?.[pkmn.name]?.rgb ?? null]
|
|
|
|
- : pkmn => [pkmn.jabStats, state.targetColor?.jabData, state.currentScores?.[pkmn.name]?.jab ?? null]
|
|
|
|
- state.searchResults?.forEach(pkmn => append(
|
|
|
|
- pkmn.name, ...argMapper(pkmn), "search"
|
|
|
|
- ));
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-// Scoring
|
|
|
|
-const renderScored = () => {
|
|
|
|
- const jabList = getScoreListJABNode();
|
|
|
|
- const appendJAB = getPokemonRenderer(jabList);
|
|
|
|
- const rgbList = getScoreListRGBNode();
|
|
|
|
- const appendRGB = getPokemonRenderer(rgbList);
|
|
|
|
-
|
|
|
|
- const clonedData = pokemonColorData.slice();
|
|
|
|
-
|
|
|
|
- // extract best CIECAM02 results
|
|
|
|
- const bestJAB = clonedData
|
|
|
|
- .sort((a, b) => state.currentScores[a.name].jab - state.currentScores[b.name].jab)
|
|
|
|
- .slice(0, state.numPoke);
|
|
|
|
- clearNodeContents(jabList);
|
|
|
|
- bestJAB.forEach(data => appendJAB(
|
|
|
|
- data.name, data.jabStats, state.targetColor.jabData, state.currentScores[data.name].jab, "jab"
|
|
|
|
- ));
|
|
|
|
-
|
|
|
|
- // extract best RGB results
|
|
|
|
- const bestRGB = clonedData
|
|
|
|
- .sort((a, b) => state.currentScores[a.name].rgb - state.currentScores[b.name].rgb)
|
|
|
|
- .slice(0, state.numPoke);
|
|
|
|
- clearNodeContents(rgbList);
|
|
|
|
- bestRGB.forEach(data => appendRGB(
|
|
|
|
- data.name, data.rgbStats, state.targetColor.rgbData, state.currentScores[data.name].rgb, "rgb"
|
|
|
|
- ));
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-const rescore = () => {
|
|
|
|
- if (!state.targetColor) {
|
|
|
|
- return;
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- state.currentScores = {};
|
|
|
|
- pokemonColorData.forEach(data => {
|
|
|
|
- state.currentScores[data.name] = scorePokemon(data);
|
|
|
|
- });
|
|
|
|
-
|
|
|
|
- // update displays
|
|
|
|
- renderScored();
|
|
|
|
-
|
|
|
|
- // update the rendered search results as well
|
|
|
|
- renderSearch();
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-// Listeners
|
|
|
|
-const onColorChanged = skipScore => {
|
|
|
|
- const readColor = readColorInput();
|
|
|
|
- if (readColor) {
|
|
|
|
- state.targetColor = readColor;
|
|
|
|
-
|
|
|
|
- renderQVec(state.targetColor.jabData.vector.map(c => c.toFixed(3)), getQJABDisplay(), "Jab");
|
|
|
|
- renderQVec(state.targetColor.rgbData.vector.map(c => c.toFixed()), getQRGBDisplay(), "RGB");
|
|
|
|
-
|
|
|
|
- const rootElem = document.querySelector(":root");
|
|
|
|
- rootElem.style.setProperty("--background", state.targetColor.rgbData.hex);
|
|
|
|
- rootElem.style.setProperty("--highlight", getContrastingTextColor(state.targetColor.rgbData.vector));
|
|
|
|
-
|
|
|
|
- if (!skipScore) {
|
|
|
|
- rescore();
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-const onRandomColor = () => {
|
|
|
|
- const color = [Math.random(), Math.random(), Math.random()].map(c => c * 255);
|
|
|
|
- getColorInputNode().value = d3.rgb(...color).formatHex();
|
|
|
|
- onColorChanged(); // triggers rescore
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-const onCustomControlsChanged = skipScore => {
|
|
|
|
- state.includeX = getIncludeXToggleNode()?.checked ?? false;
|
|
|
|
- state.normQY = getNormQYToggleNode()?.checked ?? false;
|
|
|
|
- state.closeCoeff = parseFloat(getCloseCoeffSliderNode()?.value ?? 2);
|
|
|
|
-
|
|
|
|
- getCloseCoeffDisplayNode().innerHTML = state.closeCoeff;
|
|
|
|
- updateObjective();
|
|
|
|
-
|
|
|
|
- if (!skipScore) {
|
|
|
|
- rescore();
|
|
|
|
- }
|
|
|
|
-}
|
|
|
|
-
|
|
|
|
-const checkClusterMeanWarning = () => {
|
|
|
|
- const warning = getClusterMeanWarning();
|
|
|
|
- const unhidden = warning.getAttribute("class").replaceAll("hide", "");
|
|
|
|
- if (state.clusterChoice !== 0 && (state.metric === 0 || state.metric === 4)) {
|
|
|
|
- warning.setAttribute("class", unhidden);
|
|
|
|
- } else {
|
|
|
|
- warning.setAttribute("class", unhidden + " hide");
|
|
|
|
- }
|
|
|
|
-}
|
|
|
|
-
|
|
|
|
-const checkScaleByClusterToggle = () => {
|
|
|
|
- const toggle = getClusterScaleToggleNode()?.parentNode;
|
|
|
|
- const unhidden = toggle.getAttribute("class").replaceAll("hide", "");
|
|
|
|
- if (state.clusterChoice !== 0) {
|
|
|
|
- toggle.setAttribute("class", unhidden);
|
|
|
|
- } else {
|
|
|
|
- toggle.setAttribute("class", unhidden + " hide");
|
|
|
|
- }
|
|
|
|
-}
|
|
|
|
-
|
|
|
|
-const onScaleByClusterChanged = skipScore => {
|
|
|
|
- state.includeScale = getClusterScaleToggleNode()?.checked ?? true;
|
|
|
|
-
|
|
|
|
- updateObjective();
|
|
|
|
-
|
|
|
|
- if (!skipScore) {
|
|
|
|
- rescore();
|
|
|
|
- }
|
|
|
|
-}
|
|
|
|
-
|
|
|
|
-const onClusterChoiceChanged = skipScore => {
|
|
|
|
- const clusterChoice = getClusterChoiceDropdownNode()?.selectedIndex ?? 0;
|
|
|
|
- if (clusterChoice === state.clusterChoice) {
|
|
|
|
- return;
|
|
|
|
- }
|
|
|
|
- state.clusterChoice = clusterChoice;
|
|
|
|
- checkClusterMeanWarning();
|
|
|
|
- checkScaleByClusterToggle();
|
|
|
|
- updateObjective();
|
|
|
|
- if (!skipScore) {
|
|
|
|
- rescore();
|
|
|
|
- }
|
|
|
|
-}
|
|
|
|
-
|
|
|
|
-const onMetricChanged = skipScore => {
|
|
|
|
- const metric = getMetricDropdownNode()?.selectedIndex ?? 0;
|
|
|
|
- if (metric === state.metric) {
|
|
|
|
- return;
|
|
|
|
- }
|
|
|
|
- state.metric = metric;
|
|
|
|
- checkClusterMeanWarning();
|
|
|
|
- checkScaleByClusterToggle();
|
|
|
|
- if (state.metric === 6) { // Custom
|
|
|
|
- showCustomControls();
|
|
|
|
- onCustomControlsChanged(skipScore); // triggers rescore
|
|
|
|
- } else {
|
|
|
|
- hideCustomControls();
|
|
|
|
- updateObjective();
|
|
|
|
- if (!skipScore) {
|
|
|
|
- rescore();
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-const onLimitChanged = skipRenderScore => {
|
|
|
|
- state.numPoke = parseInt(getLimitSliderNode()?.value ?? 10);
|
|
|
|
-
|
|
|
|
- getLimitDisplayNode().textContent = state.numPoke;
|
|
|
|
-
|
|
|
|
- if (!skipRenderScore) {
|
|
|
|
- renderScored();
|
|
|
|
- }
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-const onSearchChanged = () => {
|
|
|
|
- state.searchTerm = getNameInputNode()?.value?.toLowerCase() ?? "";
|
|
|
|
- if (state.searchTerm.length === 0) {
|
|
|
|
- state.searchResults = [];
|
|
|
|
- } else {
|
|
|
|
- state.searchResults = pokemonLookup
|
|
|
|
- .search(state.searchTerm, { limit: 10 })
|
|
|
|
- .map(({ item }) => item);
|
|
|
|
- }
|
|
|
|
- renderSearch();
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-const onSearchSpaceChanged = () => {
|
|
|
|
- const old = state.searchSpace ?? "Jab";
|
|
|
|
- state.searchSpace = old === "RGB" ? "Jab" : "RGB";
|
|
|
|
- getSearchSpaceDisplayNode().textContent = old;
|
|
|
|
- renderSearch();
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-const onRandomPokemon = () => {
|
|
|
|
- getNameInputNode().value = "";
|
|
|
|
- state.searchResults = Array.from({ length: 10 }, () => pokemonColorData[Math.floor(Math.random() * pokemonColorData.length)]);
|
|
|
|
- renderSearch();
|
|
|
|
-};
|
|
|
|
-
|
|
|
|
-const onPageLoad = () => {
|
|
|
|
- // render static explanations
|
|
|
|
- Object.entries(mathDefinitions).forEach(([id, tex]) => {
|
|
|
|
- document.getElementById(id)?.appendChild(TeXZilla.toMathML(tex));
|
|
|
|
- });
|
|
|
|
-
|
|
|
|
- // fake some events but don't do any scoring
|
|
|
|
- onColorChanged(true);
|
|
|
|
- onMetricChanged(true);
|
|
|
|
- onClusterChoiceChanged(true);
|
|
|
|
- onScaleByClusterChanged(true);
|
|
|
|
- onLimitChanged(true);
|
|
|
|
- // then do a rescore directly, which will do nothing unless old data was loaded
|
|
|
|
- rescore();
|
|
|
|
- // finally render search in case rescore didn't
|
|
|
|
- onSearchChanged();
|
|
|
|
-};
|
|
|