nearest.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  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 vectorMag = v => Math.sqrt(vectorDot(v, v));
  26. const vectorNorm = v => { const n = vectorMag(v); return [ n, v.map(c => c / n) ]; };
  27. // Angle Math
  28. const angleDiff = (a, b) => { const raw = Math.abs(a - b); return raw < 180 ? raw : (360 - raw); };
  29. const acosDeg = v => Math.acos(v) * 180 / Math.PI;
  30. // Pre-Compute Y Data
  31. const pokemonColorData = database.map(data => {
  32. const yRGBColor = d3.rgb(...data.yRGB);
  33. const [ yJABNorm, yJABHat ] = vectorNorm(data.yJAB);
  34. const [ yRGBNorm, yRGBHat ] = vectorNorm(data.yRGB);
  35. const [ _, yChromaHat ] = vectorNorm(data.yJAB.slice(1));
  36. return {
  37. ...data,
  38. yJABHex: d3.jab(...data.yJAB).formatHex(),
  39. yJABNorm, yJABHat,
  40. yRGBHex: yRGBColor.formatHex(),
  41. yRGBNorm, yRGBHat,
  42. yChromaHat,
  43. yHueAngle: d3.hsl(yRGBColor).h,
  44. }
  45. });
  46. const pokemonLookup = new Fuse(pokemonColorData, { keys: [ "name" ] });
  47. // Color Calculations
  48. const getContrastingTextColor = rgb => vectorDot(rgb, [0.3, 0.6, 0.1]) >= 128 ? "#222" : "#ddd";
  49. const readColorInput = () => {
  50. const colorInput = "#" + (getColorInputNode()?.value?.replace("#", "") ?? "FFFFFF");
  51. if (colorInput.length !== 7) {
  52. return;
  53. }
  54. const rgb = d3.color(colorInput);
  55. const { J, a, b } = d3.jab(rgb);
  56. const qJAB = [ J, a, b ];
  57. const qRGB = [ rgb.r, rgb.g, rgb.b ];
  58. const [ qJABNorm, qJABHat ] = vectorNorm(qJAB);
  59. const qJABNormSq = qJABNorm * qJABNorm;
  60. const [ _, qChromaHat ] = vectorNorm(qJAB.slice(1));
  61. const [ qRGBNorm, qRGBHat ] = vectorNorm(qRGB);
  62. const qRGBNormSq = qRGBNorm * qRGBNorm;
  63. const qHueAngle = d3.hsl(rgb).h;
  64. return {
  65. qHex: rgb.formatHex(),
  66. qJAB, qJABHat, qJABNorm, qJABNormSq, qChromaHat,
  67. qRGB, qRGBHat, qRGBNorm, qRGBNormSq, qHueAngle,
  68. };
  69. };
  70. // State
  71. const state = {
  72. metric: null,
  73. includeX: null,
  74. normQY: null,
  75. closeCoeff: null,
  76. numPoke: null,
  77. searchTerm: null,
  78. targetColor: null,
  79. searchResults: null,
  80. };
  81. // Metrics
  82. const scoringMetrics = [
  83. ({ xJAB, xRGB, yJAB, yRGB }) => [
  84. xJAB - 2 * vectorDot(yJAB, state.targetColor.qJAB),
  85. xRGB - 2 * vectorDot(yRGB, state.targetColor.qRGB),
  86. ],
  87. ({ yJABHat, yRGBHat }) => [
  88. -vectorDot(yJABHat, state.targetColor.qJABHat),
  89. -vectorDot(yRGBHat, state.targetColor.qRGBHat),
  90. ],
  91. ({ yChromaHat, yHueAngle }) => [
  92. acosDeg(vectorDot(state.targetColor.qChromaHat, yChromaHat)),
  93. angleDiff(state.targetColor.qHueAngle, yHueAngle),
  94. ],
  95. ({ xJAB, xRGB, yJAB, yRGB, yJABHat, yRGBHat }) => [
  96. (state.includeX ? xJAB : 0) - state.closeCoeff * vectorDot(
  97. state.normQY ? yJABHat : yJAB,
  98. state.normQY ? state.targetColor.qJABHat : state.targetColor.qJAB
  99. ),
  100. (state.includeX ? xRGB : 0) - state.closeCoeff * vectorDot(
  101. state.normQY ? yRGBHat : yRGB,
  102. state.normQY ? state.targetColor.qRGBHat : state.targetColor.qRGB
  103. ),
  104. ]
  105. ];
  106. const calcDisplayMetrics = ({
  107. xJAB, xRGB, yJABHat, yJABNorm, yRGBHat, yRGBNorm, yChromaHat, yHueAngle,
  108. }) => {
  109. // TODO - case on state.metric to avoid recalculation of subterms?
  110. const cosAngleJAB = vectorDot(state.targetColor.qJABHat, yJABHat);
  111. const yTermJAB = cosAngleJAB * yJABNorm * state.targetColor.qJABNorm;
  112. const cosAngleRGB = vectorDot(state.targetColor.qRGBHat, yRGBHat);
  113. const yTermRGB = cosAngleRGB * yRGBNorm * state.targetColor.qRGBNorm;
  114. return {
  115. stdDevRGB: Math.sqrt(xRGB - 2 * yTermRGB + state.targetColor.qRGBNormSq),
  116. stdDevJAB: Math.sqrt(xJAB - 2 * yTermJAB + state.targetColor.qJABNormSq),
  117. angleJAB: acosDeg(cosAngleJAB),
  118. angleRGB: acosDeg(cosAngleRGB),
  119. chromaAngle: acosDeg(vectorDot(state.targetColor.qChromaHat, yChromaHat)),
  120. hueAngle: angleDiff(state.targetColor.qHueAngle, yHueAngle),
  121. };
  122. };
  123. // Math Rendering
  124. const renderQVec = (q, node, sub) => {
  125. node.innerHTML = TeXZilla.toMathMLString(`\\vec{q}_{\\text{${sub}}} = \\left(${q.join(", ")}\\right)`);
  126. };
  127. const renderVec = math => `\\vec{${math.charAt(0)}}${math.substr(1)}`;
  128. const renderNorm = vec => `\\frac{${vec}}{\\left|\\left|${vec}\\right|\\right|}`;
  129. const metricText = [
  130. "\\text{RMS}_{P} ~ \\arg\\min_{P}\\left[X\\left(P\\right) - 2\\vec{q}\\cdot \\vec{Y}\\left(P\\right)\\right]",
  131. `\\angle \\left(\\vec{q}, \\vec{Y}\\left(P\\right)\\right) ~ \\arg\\min_{P}\\left[-${renderNorm(renderVec("q"))}\\cdot ${renderNorm(renderVec("Y\\left(P\\right)"))}\\right]`,
  132. "\\angle \\left(\\vec{q}_{\\perp}, \\vec{Y}\\left(P\\right)_{\\perp} \\right)",
  133. ];
  134. const updateObjective = () => {
  135. let tex = metricText?.[state.metric];
  136. if (!tex) {
  137. const { includeX, normQY, closeCoeff } = state;
  138. const xTerm = includeX ? "X\\left(P\\right)" : "";
  139. const qyMod = normQY ? c => renderNorm(renderVec(c)) : renderVec;
  140. tex = `\\arg\\min_{P}\\left[${xTerm}-${closeCoeff === 1 ? "" : closeCoeff}${qyMod("q")}\\cdot ${qyMod("Y\\left(P\\right)")}\\right]`;
  141. }
  142. const objFnNode = getObjFnDisplay();
  143. clearNodeContents(objFnNode);
  144. objFnNode.appendChild(TeXZilla.toMathML(tex));
  145. };
  146. // Pokemon Rendering
  147. const stripForm = ["flabebe", "floette", "florges", "vivillon", "basculin", "furfrou", "magearna"];
  148. const getSprite = pokemon => {
  149. pokemon = pokemon
  150. .replace("-alola", "-alolan")
  151. .replace("-galar", "-galarian")
  152. .replace("darmanitan-galarian", "darmanitan-galarian-standard");
  153. if (stripForm.find(s => pokemon.includes(s))) {
  154. pokemon = pokemon.replace(/-.*$/, "");
  155. }
  156. return `https://img.pokemondb.net/sprites/sword-shield/icon/${pokemon}.png`;
  157. };
  158. const renderPokemon = (data, classes = {}) => {
  159. const { name, yJAB, yJABHex, yRGB, yRGBHex } = data;
  160. const { labelClass = "", rgbClass = "", jabClass = "" } = classes;
  161. let { resultsClass = "" } = classes;
  162. let displayMetrics = {};
  163. if (!state.targetColor) {
  164. // no color selected need to skip scores
  165. resultsClass = "hide";
  166. } else {
  167. displayMetrics = calcDisplayMetrics(data);
  168. }
  169. const {
  170. stdDevJAB = 0, stdDevRGB = 0,
  171. angleJAB = 0, angleRGB = 0,
  172. chromaAngle = 0, hueAngle = 0,
  173. } = displayMetrics;
  174. const titleName = name.charAt(0).toUpperCase() + name.substr(1);
  175. const textHex = getContrastingTextColor(yRGB);
  176. const rgbVec = yRGB.map(c => c.toFixed()).join(", ");
  177. const jabVec = yJAB.map(c => c.toFixed(1)).join(", ");
  178. const pkmn = document.createElement("div");
  179. pkmn.setAttribute("class", "pokemon_tile");
  180. pkmn.innerHTML = `
  181. <div class="pokemon_tile-image-wrapper">
  182. <img src="${getSprite(name)}" />
  183. </div>
  184. <div class="pokemon_tile-info_panel">
  185. <span class="pokemon_tile-pokemon_name">${titleName}</span>
  186. <div class="pokemon_tile-results">
  187. <div class="pokemon_tile-labels ${labelClass}">
  188. <span class="${jabClass}">Jab: </span>
  189. <span class="${rgbClass}">RGB: </span>
  190. </div>
  191. <div class="pokemon_tile-score_column ${resultsClass}">
  192. <span class="pokemon_tile-no_flex ${jabClass}">
  193. (${stdDevJAB.toFixed(2)}, ${angleJAB.toFixed(2)}&deg;, ${chromaAngle.toFixed(2)}&deg;)
  194. </span>
  195. <span class="pokemon_tile-no_flex ${rgbClass}">
  196. (${stdDevRGB.toFixed(2)}, ${angleRGB.toFixed(2)}&deg;, ${hueAngle.toFixed(2)}&deg;)
  197. </span>
  198. </div>
  199. <div class="pokemon_tile-hex_column">
  200. <div class="pokemon_tile-hex_color ${jabClass}" style="background-color: ${yJABHex}; color: ${textHex}">
  201. <span>${yJABHex}</span><span class="pokemon_tile-vector">(${jabVec})</span>
  202. </div>
  203. <div class="pokemon_tile-hex_color ${rgbClass}" style="background-color: ${yRGBHex}; color: ${textHex}">
  204. <span>${yRGBHex}</span><span class="pokemon_tile-vector">(${rgbVec})</span>
  205. </div>
  206. </div>
  207. </div>
  208. </div>
  209. `;
  210. return pkmn;
  211. };
  212. const getPokemonAppender = targetList => (pokemonData, classes) => {
  213. const li = document.createElement("li");
  214. li.appendChild(renderPokemon(pokemonData, classes));
  215. targetList.appendChild(li);
  216. };
  217. // Update Search Results
  218. const renderSearch = () => {
  219. const resultsNode = getSearchListNode();
  220. const append = getPokemonAppender(resultsNode);
  221. clearNodeContents(resultsNode);
  222. state.searchResults?.forEach(pkmn => append(pkmn));
  223. };
  224. // Scoring
  225. const rescore = () => {
  226. if (!state.targetColor) {
  227. return;
  228. }
  229. const metricFn = scoringMetrics[state.metric ?? 0];
  230. // TODO might like to save this somewhere instead of recomputing when limit changes
  231. const scores = pokemonColorData.map(data => ({ ...data, scores: metricFn(data) }));
  232. const jabList = getScoreListJABNode();
  233. const appendJAB = getPokemonAppender(jabList);
  234. const rgbList = getScoreListRGBNode();
  235. const appendRGB = getPokemonAppender(rgbList);
  236. // extract best CIECAM02 results
  237. const bestJAB = scores
  238. .sort((a, b) => a.scores[0] - b.scores[0])
  239. .slice(0, state.numPoke);
  240. clearNodeContents(jabList);
  241. bestJAB.forEach(data => appendJAB(data, { labelClass: "hide", rgbClass: "hide" }));
  242. // extract best RGB results
  243. const bestRGB = scores
  244. .sort((a, b) => a.scores[1] - b.scores[1])
  245. .slice(0, state.numPoke);
  246. clearNodeContents(rgbList);
  247. bestRGB.forEach(data => appendRGB(data, { labelClass: "hide", jabClass: "hide" }));
  248. // update the rendered search results as well
  249. renderSearch();
  250. };
  251. // Listeners
  252. const onColorChanged = skipScore => {
  253. const readColor = readColorInput();
  254. if (readColor) {
  255. state.targetColor = readColor;
  256. renderQVec(state.targetColor.qJAB.map(c => c.toFixed(2)), getQJABDisplay(), "Jab");
  257. renderQVec(state.targetColor.qRGB.map(c => c.toFixed()), getQRGBDisplay(), "RGB");
  258. const textColor = getContrastingTextColor(state.targetColor.qRGB);
  259. document.querySelector("body").setAttribute("style", `background: ${state.targetColor.qHex}; color: ${textColor}`);
  260. state.targetColor
  261. if (!skipScore) {
  262. rescore();
  263. }
  264. }
  265. };
  266. const onRandomColor = () => {
  267. const color = [Math.random(), Math.random(), Math.random()].map(c => c * 255);
  268. getColorInputNode().value = d3.rgb(...color).formatHex();
  269. onColorChanged(); // triggers rescore
  270. };
  271. const onCustomControlsChanged = skipScore => {
  272. state.includeX = getIncludeXToggleNode()?.checked ?? false;
  273. state.normQY = getNormQYToggleNode()?.checked ?? false;
  274. state.closeCoeff = parseFloat(getCloseCoeffSliderNode()?.value ?? 2);
  275. getCloseCoeffDisplayNode().innerHTML = state.closeCoeff;
  276. updateObjective();
  277. if (!skipScore) {
  278. rescore();
  279. }
  280. }
  281. const onMetricChanged = skipScore => {
  282. const metric = getMetricDropdownNode()?.selectedIndex ?? 0;
  283. if (metric === state.metric) {
  284. return;
  285. }
  286. state.metric = metric;
  287. if (state.metric === 3) { // Custom
  288. showCustomControls();
  289. onCustomControlsChanged(skipScore); // triggers rescore
  290. } else {
  291. hideCustomControls();
  292. updateObjective();
  293. if (!skipScore) {
  294. rescore();
  295. }
  296. }
  297. };
  298. const onLimitChanged = skipScore => {
  299. state.numPoke = parseInt(getLimitSliderNode()?.value ?? 10);
  300. getLimitDisplayNode().textContent = state.numPoke;
  301. if (!skipScore) {
  302. // TODO don't need to rescore just need to expand
  303. rescore();
  304. }
  305. };
  306. const onSearchChanged = () => {
  307. state.searchTerm = getNameInputNode()?.value?.toLowerCase() ?? "";
  308. if (state.searchTerm.length === 0) {
  309. state.searchResults = [];
  310. } else {
  311. state.searchResults = pokemonLookup
  312. .search(state.searchTerm, { limit: 10 })
  313. .map(({ item }) => item);
  314. }
  315. renderSearch();
  316. };
  317. const onRandomPokemon = () => {
  318. getNameInputNode().value = "";
  319. state.searchResults = Array.from({ length: 10 }, () => pokemonColorData[Math.floor(Math.random() * pokemonColorData.length)]);
  320. renderSearch();
  321. };
  322. const onPageLoad = () => {
  323. // fake some events but don't do any scoring
  324. onColorChanged(true);
  325. onMetricChanged(true);
  326. onLimitChanged(true);
  327. // then do a rescore directly, which will do nothing unless old data was loaded
  328. rescore();
  329. // finally render search in case rescore didn't
  330. onSearchChanged();
  331. };