nearest.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. // Selectors + DOM Manipulation
  2. const getColorInputNode = () => document.getElementById("color-input");
  3. const getMetricDropdownNode = () => document.getElementById("metric");
  4. const getIncludeXToggleNode = () => document.getElementById("include-x");
  5. const getNormQYToggleNode = () => document.getElementById("norm-q-y");
  6. const getCloseCoeffSliderNode = () => document.getElementById("close-coeff");
  7. const getCloseCoeffDisplayNode = () => document.getElementById("close-coeff-display");
  8. const getLimitSliderNode = () => document.getElementById("num-poke");
  9. const getLimitDisplayNode = () => document.getElementById("num-poke-display");
  10. const getNameInputNode = () => document.getElementById("pokemon-name");
  11. const getScoreListJABNode = () => document.getElementById("best-list-jab");
  12. const getScoreListRGBNode = () => document.getElementById("best-list-rgb");
  13. const getSearchListNode = () => document.getElementById("search-list");
  14. const getHideableControlNodes = () => document.querySelectorAll(".hideable_control");
  15. const getQJABDisplay = () => document.getElementById("q-vec-jab");
  16. const getQRGBDisplay = () => document.getElementById("q-vec-rgb");
  17. const getObjFnDisplay = () => document.getElementById("obj-fn");
  18. const clearNodeContents = node => { node.innerHTML = ""; };
  19. const hideCustomControls = () => getHideableControlNodes()
  20. .forEach(n => n.setAttribute("class", "hideable_control hideable_control--hidden"));
  21. const showCustomControls = () => getHideableControlNodes()
  22. .forEach(n => n.setAttribute("class", "hideable_control"));
  23. // Vector Math
  24. const vectorDot = (u, v) => u.map((x, i) => x * v[i]).reduce((x, y) => x + y);
  25. const vectorSqMag = v => vectorDot(v, v);
  26. const vectorMag = v => Math.sqrt(vectorSqMag(v));
  27. const vectorSqDist = (u, v) => vectorSqMag(u.map((x, i) => x - v[i]));
  28. const vectorDist = (u, v) => Math.sqrt(vectorSqDist(u, v));
  29. const vectorNorm = v => { const n = vectorMag(v); return [ n, v.map(c => c / n) ]; };
  30. // Angle Math
  31. const angleDiff = (a, b) => { const raw = Math.abs(a - b); return raw < 180 ? raw : (360 - raw); };
  32. const rad2deg = 180 / Math.PI;
  33. // Pre-Compute Y Data
  34. const pokemonColorData = database.map(data => {
  35. const yRGBColor = d3.rgb(...data.yRGB);
  36. const [ yJABNorm, yJABHat ] = vectorNorm(data.yJAB);
  37. const [ yRGBNorm, yRGBHat ] = vectorNorm(data.yRGB);
  38. return {
  39. ...data,
  40. yJABHex: d3.jab(...data.yJAB).formatHex(),
  41. yJABNorm, yJABHat,
  42. yRGBHex: yRGBColor.formatHex(),
  43. yRGBNorm, yRGBHat,
  44. yHueAngleJAB: rad2deg * Math.atan2(data.yJAB[2], data.yJAB[1]),
  45. yHueAngleRGB: d3.hsl(yRGBColor).h,
  46. }
  47. });
  48. const pokemonLookup = new Fuse(pokemonColorData, { keys: [ "name" ] });
  49. // Color Calculations
  50. const getContrastingTextColor = rgb => vectorDot(rgb, [0.3, 0.6, 0.1]) >= 128 ? "#222" : "#ddd";
  51. const readColorInput = () => {
  52. const colorInput = "#" + (getColorInputNode()?.value?.replace("#", "") ?? "FFFFFF");
  53. if (colorInput.length !== 7) {
  54. return;
  55. }
  56. const rgb = d3.color(colorInput);
  57. const { J, a, b } = d3.jab(rgb);
  58. const qJAB = [ J, a, b ];
  59. const qRGB = [ rgb.r, rgb.g, rgb.b ];
  60. const [ qJABNorm, qJABHat ] = vectorNorm(qJAB);
  61. const qJABNormSq = qJABNorm * qJABNorm;
  62. const [ qRGBNorm, qRGBHat ] = vectorNorm(qRGB);
  63. const qRGBNormSq = qRGBNorm * qRGBNorm;
  64. return {
  65. qHex: rgb.formatHex(),
  66. qJAB, qJABHat, qJABNorm, qJABNormSq,
  67. qRGB, qRGBHat, qRGBNorm, qRGBNormSq,
  68. qHueAngleJAB: rad2deg * Math.atan2(b, a),
  69. qHueAngleRGB: d3.hsl(rgb).h,
  70. };
  71. };
  72. // State
  73. const state = {
  74. metric: null,
  75. includeX: null,
  76. normQY: null,
  77. closeCoeff: null,
  78. numPoke: null,
  79. searchTerm: null,
  80. targetColor: null,
  81. searchResults: null,
  82. };
  83. // Metrics
  84. const scoringMetrics = [
  85. ({ xJAB, xRGB, yJAB, yRGB }) => [
  86. xJAB - 2 * vectorDot(yJAB, state.targetColor.qJAB),
  87. xRGB - 2 * vectorDot(yRGB, state.targetColor.qRGB),
  88. ],
  89. ({ yJABHat, yRGBHat }) => [
  90. -vectorDot(yJABHat, state.targetColor.qJABHat),
  91. -vectorDot(yRGBHat, state.targetColor.qRGBHat),
  92. ],
  93. ({ yJAB, yRGB }) => [
  94. vectorSqDist(state.targetColor.qJAB, yJAB),
  95. vectorSqDist(state.targetColor.qRGB, yRGB),
  96. ],
  97. ({ yHueAngleJAB, yHueAngleRGB }) => [
  98. angleDiff(state.targetColor.qHueAngleJAB, yHueAngleJAB),
  99. angleDiff(state.targetColor.qHueAngleRGB, yHueAngleRGB),
  100. ],
  101. // TODO - might want an alternative metric of subbing these Z's in for Y
  102. ({ zJAB, zRGB }) => [
  103. Math.min(...zJAB.map(z => vectorSqDist(z, state.targetColor.qJAB))),
  104. Math.min(...zRGB.map(z => vectorSqDist(z, state.targetColor.qRGB))),
  105. ],
  106. ({ zJAB, zRGB }) => [
  107. Math.max(...zJAB.map(z => vectorSqDist(z, state.targetColor.qJAB))),
  108. Math.max(...zRGB.map(z => vectorSqDist(z, state.targetColor.qRGB))),
  109. ],
  110. ({ xJAB, xRGB, yJAB, yRGB, yJABHat, yRGBHat }) => [
  111. (state.includeX ? xJAB : 0) - state.closeCoeff * vectorDot(
  112. state.normQY ? yJABHat : yJAB,
  113. state.normQY ? state.targetColor.qJABHat : state.targetColor.qJAB
  114. ),
  115. (state.includeX ? xRGB : 0) - state.closeCoeff * vectorDot(
  116. state.normQY ? yRGBHat : yRGB,
  117. state.normQY ? state.targetColor.qRGBHat : state.targetColor.qRGB
  118. ),
  119. ],
  120. ];
  121. const calcDisplayMetrics = ({
  122. xJAB, xRGB, yJAB, yRGB, yJABHat, yJABNorm, yRGBHat, yRGBNorm, yHueAngleJAB, yHueAngleRGB,
  123. }) => {
  124. // TODO - case on state.metric to avoid recalculation of subterms?
  125. const cosAngleJAB = vectorDot(state.targetColor.qJABHat, yJABHat);
  126. const yTermJAB = cosAngleJAB * yJABNorm * state.targetColor.qJABNorm;
  127. const cosAngleRGB = vectorDot(state.targetColor.qRGBHat, yRGBHat);
  128. const yTermRGB = cosAngleRGB * yRGBNorm * state.targetColor.qRGBNorm;
  129. // TODO Z-dists?
  130. return {
  131. stdDevRGB: Math.sqrt(xRGB - 2 * yTermRGB + state.targetColor.qRGBNormSq),
  132. stdDevJAB: Math.sqrt(xJAB - 2 * yTermJAB + state.targetColor.qJABNormSq),
  133. angleJAB: rad2deg * Math.acos(cosAngleJAB),
  134. angleRGB: rad2deg * Math.acos(cosAngleRGB),
  135. meanDistJAB: vectorDist(state.targetColor.qJAB, yJAB),
  136. meanDistRGB: vectorDist(state.targetColor.qRGB, yRGB),
  137. hueAngleJAB: angleDiff(state.targetColor.qHueAngleJAB, yHueAngleJAB),
  138. hueAngleRGB: angleDiff(state.targetColor.qHueAngleRGB, yHueAngleRGB),
  139. };
  140. };
  141. // Math Rendering
  142. const renderQVec = (q, node, sub) => {
  143. node.innerHTML = TeXZilla.toMathMLString(String.raw`\vec{q}_{\text{${sub}}} = \left(\text{${q.join(", ")}}\right)`);
  144. };
  145. const mathDefinitions = {
  146. "x-definition": String.raw`
  147. X\left(P\right) = \frac{1}{\left|P\right|}\sum_{p\in P}{\left|\left|\vec{p}\right|\right|^2}
  148. `,
  149. "y-definition": String.raw`
  150. \vec{Y}\left(P\right) = \frac{1}{\left|P\right|}\sum_{p\in P}{\vec{p}}
  151. `,
  152. "v-perp-definition": String.raw`
  153. \vec{v}_{\perp} = \text{oproj}_{\left\{\vec{J}, \vec{L}\right\}}{\vec{v}}
  154. `,
  155. "del-h-definition": String.raw`
  156. \Delta{H} = \angle \left(\vec{q}_{\perp}, \vec{Y}_{\perp}\left(P\right) \right)
  157. `,
  158. "cluster-definition": String.raw`
  159. \left\{P_1, P_2, P_3\right\} = \arg\min_{\left\{P_1, P_2, P_3\right\}} \sum_{i=1}^3 \sum_{p\inP_i} \left|\left| \vec{p} - \vec{Y}\left(P_i\right) \right|\right|^2
  160. `,
  161. "z-best-definition": String.raw`
  162. \vec{Z}_{\text{best}}\left(P\right) = \vec{Y}\left(\arg\min_{P_i} \left|\left| \vec{q} - \vec{Y}\left(P_i\right) \right|\right| \right)
  163. `,
  164. "z-worst-definition": String.raw`
  165. \vec{Z}_{\text{worst}}\left(P\right) = \vec{Y}\left(\arg\max_{P_i} \left|\left| \vec{q} - \vec{Y}\left(P_i\right) \right|\right| \right)
  166. `,
  167. "result-definition": String.raw`
  168. \left(
  169. \text{RMS}_P\left(q\right),
  170. \angle \left(\vec{q}, \vec{Y}\left(P\right)\right),
  171. \left|\left| \vec{q} - \vec{Y}\left(P\right) \right|\right|,
  172. \Delta{H}
  173. \right)
  174. `,
  175. };
  176. const metricText = [
  177. String.raw`\text{RMS}_{P}\left(q\right) ~ \arg\min_{P}\left[X\left(P\right) - 2\vec{q}\cdot \vec{Y}\left(P\right)\right]`,
  178. String.raw`\angle \left(\vec{q}, \vec{Y}\left(P\right)\right) ~ \arg\max_{P}\left[\cos\left(\angle \left(\vec{q}, \vec{Y}\left(P\right)\right)\right)\right]`,
  179. String.raw`\left|\left| \vec{q} - \vec{Y}\left(P\right) \right|\right| ~ \arg\min_{P}\left[\left|\left| \vec{q} - \vec{Y}\left(P\right) \right|\right|^2\right]`,
  180. String.raw`\Delta{H}`,
  181. String.raw`\left|\left| \vec{q} - \vec{Z}_{\text{best}}\left(P\right) \right|\right|`,
  182. String.raw`\left|\left| \vec{q} - \vec{Z}_{\text{worst}}\left(P\right) \right|\right|`,
  183. ].map(s => TeXZilla.toMathML(s));
  184. const renderVec = math => String.raw`\vec{${math.charAt(0)}}${math.substr(1)}`;
  185. const renderNorm = vec => String.raw`\frac{${vec}}{\left|\left|${vec}\right|\right|}`;
  186. const updateObjective = () => {
  187. let tex = metricText?.[state.metric];
  188. if (!tex) {
  189. const { includeX, normQY, closeCoeff } = state;
  190. if (!includeX && closeCoeff === 0) {
  191. tex = TeXZilla.toMathML(String.raw`\text{Empty Metric}`);
  192. } else {
  193. const qyMod = normQY ? c => renderNorm(renderVec(c)) : renderVec;
  194. tex = TeXZilla.toMathML(String.raw`
  195. \arg
  196. \m${includeX ? "in" : "ax"}_{P}
  197. \left[
  198. ${includeX ? String.raw`X\left(P\right)` : ""}
  199. ${closeCoeff === 0 ? "" : String.raw`
  200. ${includeX ? "-" : ""}
  201. ${(includeX && closeCoeff !== 1) ? closeCoeff : ""}
  202. ${qyMod("q")}
  203. \cdot
  204. ${qyMod(String.raw`Y\left(P\right)`)}
  205. `}
  206. \right]
  207. `);
  208. }
  209. }
  210. const objFnNode = getObjFnDisplay();
  211. clearNodeContents(objFnNode);
  212. objFnNode.appendChild(tex);
  213. };
  214. // Pokemon Rendering
  215. const stripForm = ["flabebe", "floette", "florges", "vivillon", "basculin", "furfrou", "magearna"];
  216. const getSprite = pokemon => {
  217. pokemon = pokemon
  218. .replace("-alola", "-alolan")
  219. .replace("-galar", "-galarian")
  220. .replace("darmanitan-galarian", "darmanitan-galarian-standard");
  221. if (stripForm.find(s => pokemon.includes(s))) {
  222. pokemon = pokemon.replace(/-.*$/, "");
  223. }
  224. return `https://img.pokemondb.net/sprites/sword-shield/icon/${pokemon}.png`;
  225. };
  226. const renderPokemon = (data, classes = {}) => {
  227. const { name, yJAB, yJABHex, yRGB, yRGBHex } = data;
  228. const { labelClass = "", rgbClass = "", jabClass = "", tileClass = "" } = classes;
  229. let { resultsClass = "" } = classes;
  230. let displayMetrics = {};
  231. if (!state.targetColor) {
  232. // no color selected need to skip scores
  233. resultsClass = "hide";
  234. } else {
  235. displayMetrics = calcDisplayMetrics(data);
  236. }
  237. const {
  238. stdDevJAB = 0, stdDevRGB = 0,
  239. angleJAB = 0, angleRGB = 0,
  240. meanDistJAB = 0, meanDistRGB,
  241. hueAngleJAB = 0, hueAngleRGB = 0,
  242. } = displayMetrics;
  243. const titleName = name.split("-").map(part => part.charAt(0).toUpperCase() + part.substr(1)).join(" ");
  244. const textHex = getContrastingTextColor(yRGB);
  245. const rgbVec = yRGB.map(c => c.toFixed()).join(", ");
  246. const jabVec = yJAB.map(c => c.toFixed(1)).join(", ");
  247. const pkmn = document.createElement("div");
  248. pkmn.setAttribute("class", `pokemon_tile ${tileClass}`);
  249. pkmn.innerHTML = `
  250. <div class="pokemon_tile-image-wrapper">
  251. <img src="${getSprite(name)}" />
  252. </div>
  253. <div class="pokemon_tile-info_panel">
  254. <span class="pokemon_tile-pokemon_name">${titleName}</span>
  255. <div class="pokemon_tile-results">
  256. <div class="pokemon_tile-labels ${labelClass}">
  257. <span class="${jabClass}">Jab: </span>
  258. <span class="${rgbClass}">RGB: </span>
  259. </div>
  260. <div class="pokemon_tile-score_column ${resultsClass}">
  261. <span class="${jabClass}">
  262. (${stdDevJAB.toFixed(2)}, ${angleJAB.toFixed(2)}&deg;, ${meanDistJAB.toFixed(2)}, ${hueAngleJAB.toFixed(2)}&deg;)
  263. </span>
  264. <span class="${rgbClass}">
  265. (${stdDevRGB.toFixed(2)}, ${angleRGB.toFixed(2)}&deg;, ${meanDistRGB.toFixed(2)}, ${hueAngleRGB.toFixed(2)}&deg;)
  266. </span>
  267. </div>
  268. <div class="pokemon_tile-hex_column">
  269. <div class="pokemon_tile-hex_color ${jabClass}" style="background-color: ${yJABHex}; color: ${textHex}">
  270. <span>${yJABHex}</span><span class="pokemon_tile-vector">(${jabVec})</span>
  271. </div>
  272. <div class="pokemon_tile-hex_color ${rgbClass}" style="background-color: ${yRGBHex}; color: ${textHex}">
  273. <span>${yRGBHex}</span><span class="pokemon_tile-vector">(${rgbVec})</span>
  274. </div>
  275. </div>
  276. </div>
  277. </div>
  278. `;
  279. return pkmn;
  280. };
  281. const getPokemonAppender = targetList => (pokemonData, classes) => {
  282. const li = document.createElement("li");
  283. li.appendChild(renderPokemon(pokemonData, classes));
  284. targetList.appendChild(li);
  285. };
  286. // Update Search Results
  287. const renderSearch = () => {
  288. const resultsNode = getSearchListNode();
  289. const append = getPokemonAppender(resultsNode);
  290. clearNodeContents(resultsNode);
  291. state.searchResults?.forEach(pkmn => append(pkmn));
  292. };
  293. // Scoring
  294. const rescore = () => {
  295. if (!state.targetColor) {
  296. return;
  297. }
  298. const metricFn = scoringMetrics[state.metric ?? 0];
  299. // TODO might like to save this somewhere instead of recomputing when limit changes
  300. const scores = pokemonColorData.map(data => ({ ...data, scores: metricFn(data) }));
  301. const jabList = getScoreListJABNode();
  302. const appendJAB = getPokemonAppender(jabList);
  303. const rgbList = getScoreListRGBNode();
  304. const appendRGB = getPokemonAppender(rgbList);
  305. // extract best CIECAM02 results
  306. const bestJAB = scores
  307. .sort((a, b) => a.scores[0] - b.scores[0])
  308. .slice(0, state.numPoke);
  309. clearNodeContents(jabList);
  310. bestJAB.forEach(data => appendJAB(data, { labelClass: "hide", rgbClass: "hide", tileClass: "pokemon_tile--smaller" }));
  311. // extract best RGB results
  312. const bestRGB = scores
  313. .sort((a, b) => a.scores[1] - b.scores[1])
  314. .slice(0, state.numPoke);
  315. clearNodeContents(rgbList);
  316. bestRGB.forEach(data => appendRGB(data, { labelClass: "hide", jabClass: "hide", tileClass: "pokemon_tile--smaller" }));
  317. // update the rendered search results as well
  318. renderSearch();
  319. };
  320. // Listeners
  321. const onColorChanged = skipScore => {
  322. const readColor = readColorInput();
  323. if (readColor) {
  324. state.targetColor = readColor;
  325. renderQVec(state.targetColor.qJAB.map(c => c.toFixed(2)), getQJABDisplay(), "Jab");
  326. renderQVec(state.targetColor.qRGB.map(c => c.toFixed()), getQRGBDisplay(), "RGB");
  327. const textColor = getContrastingTextColor(state.targetColor.qRGB);
  328. document.querySelector("body").setAttribute("style", `background: ${state.targetColor.qHex}; color: ${textColor}`);
  329. state.targetColor
  330. if (!skipScore) {
  331. rescore();
  332. }
  333. }
  334. };
  335. const onRandomColor = () => {
  336. const color = [Math.random(), Math.random(), Math.random()].map(c => c * 255);
  337. getColorInputNode().value = d3.rgb(...color).formatHex();
  338. onColorChanged(); // triggers rescore
  339. };
  340. const onCustomControlsChanged = skipScore => {
  341. state.includeX = getIncludeXToggleNode()?.checked ?? false;
  342. state.normQY = getNormQYToggleNode()?.checked ?? false;
  343. state.closeCoeff = parseFloat(getCloseCoeffSliderNode()?.value ?? 2);
  344. getCloseCoeffDisplayNode().innerHTML = state.closeCoeff;
  345. updateObjective();
  346. if (!skipScore) {
  347. rescore();
  348. }
  349. }
  350. const onMetricChanged = skipScore => {
  351. const metric = getMetricDropdownNode()?.selectedIndex ?? 0;
  352. if (metric === state.metric) {
  353. return;
  354. }
  355. state.metric = metric;
  356. if (state.metric === 6) { // Custom
  357. showCustomControls();
  358. onCustomControlsChanged(skipScore); // triggers rescore
  359. } else {
  360. hideCustomControls();
  361. updateObjective();
  362. if (!skipScore) {
  363. rescore();
  364. }
  365. }
  366. };
  367. const onLimitChanged = skipScore => {
  368. state.numPoke = parseInt(getLimitSliderNode()?.value ?? 10);
  369. getLimitDisplayNode().textContent = state.numPoke;
  370. if (!skipScore) {
  371. // TODO don't need to rescore just need to expand
  372. rescore();
  373. }
  374. };
  375. const onSearchChanged = () => {
  376. state.searchTerm = getNameInputNode()?.value?.toLowerCase() ?? "";
  377. if (state.searchTerm.length === 0) {
  378. state.searchResults = [];
  379. } else {
  380. state.searchResults = pokemonLookup
  381. .search(state.searchTerm, { limit: 10 })
  382. .map(({ item }) => item);
  383. }
  384. renderSearch();
  385. };
  386. const onRandomPokemon = () => {
  387. getNameInputNode().value = "";
  388. state.searchResults = Array.from({ length: 10 }, () => pokemonColorData[Math.floor(Math.random() * pokemonColorData.length)]);
  389. renderSearch();
  390. };
  391. const onPageLoad = () => {
  392. // render static explanations
  393. Object.entries(mathDefinitions).forEach(([id, tex]) => {
  394. document.getElementById(id).appendChild(TeXZilla.toMathML(tex));
  395. });
  396. // fake some events but don't do any scoring
  397. onColorChanged(true);
  398. onMetricChanged(true);
  399. onLimitChanged(true);
  400. // then do a rescore directly, which will do nothing unless old data was loaded
  401. rescore();
  402. // finally render search in case rescore didn't
  403. onSearchChanged();
  404. };