nearest.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. const stripForm = ["flabebe", "floette", "florges", "vivillon", "basculin", "furfrou", "magearna"];
  2. const getSprite = pokemon => {
  3. pokemon = pokemon
  4. .replace("-alola", "-alolan")
  5. .replace("-galar", "-galarian")
  6. .replace("darmanitan-galarian", "darmanitan-galarian-standard");
  7. if (stripForm.find(s => pokemon.includes(s))) {
  8. pokemon = pokemon.replace(/-.*$/, "");
  9. }
  10. return `https://img.pokemondb.net/sprites/sword-shield/icon/${pokemon}.png`;
  11. }
  12. const titleCase = s => s.charAt(0).toUpperCase() + s.substr(1);
  13. const vectorDot = (u, v) => u.map((x, i) => x * v[i]).reduce((x, y) => x + y);
  14. const vectorMag = v => Math.sqrt(vectorDot(v, v));
  15. const vectorNorm = v => { const n = vectorMag(v); return [ n, v.map(c => c / n) ]; };
  16. const acosDeg = v => Math.acos(v) * 180 / Math.PI;
  17. const getContrastingTextColor = rgb => vectorDot(rgb, [0.3, 0.6, 0.1]) >= 128 ? "#222" : "#ddd"
  18. const pokemonLookup = new Fuse(database, { keys: [ "name" ] });
  19. // hex codes already include leading # in these functions
  20. // rgb values are [0, 255] in these functions
  21. const jab2hex = jab => d3.jab(...jab).formatHex();
  22. const rgb2hex = rgb => d3.rgb(...rgb).formatHex();
  23. const rgb2jab = rgb => {
  24. const { J, a, b } = d3.jab(d3.rgb(...rgb));
  25. return [ J, a, b ];
  26. }
  27. const hex2rgb = hex => {
  28. const { r, g, b } = d3.rgb(hex);
  29. return [ r, g, b ];
  30. };
  31. // scoring function
  32. const getCalculator = (closeCoeff, includeX, normQY, qRGB, qJAB) => {
  33. const [ qRGBNorm, qRGBHat ] = vectorNorm(qRGB);
  34. const [ qJABNorm, qJABHat ] = vectorNorm(qJAB);
  35. const qRGBNormSq = qRGBNorm * qRGBNorm;
  36. const qJABNormSq = qJABNorm * qJABNorm;
  37. const [ _, qChromaHat ] = vectorNorm(qJAB.slice(1));
  38. const qHueAngle = d3.hsl(d3.rgb(...qRGB)).h;
  39. return ({ xRGB, yRGB, xJAB, yJAB }) => {
  40. // in an ideal world we wouldn't calculate all these when they might not all be used
  41. // but honestly, we're in the browser, and I'm tired, let's just be lazy for once...
  42. const [ yRGBNorm, yRGBHat ] = vectorNorm(yRGB);
  43. const [ yJABNorm, yJABHat ] = vectorNorm(yJAB);
  44. const [ _, yChromaHat ] = vectorNorm(yJAB.slice(1));
  45. const cosAngleRGB = vectorDot(qRGBHat, yRGBHat);
  46. const cosAngleJAB = vectorDot(qJABHat, yJABHat);
  47. const cosChromaAngle = vectorDot(qChromaHat, yChromaHat);
  48. const yTermRGB = cosAngleRGB * yRGBNorm * qRGBNorm;
  49. const yTermJAB = cosAngleJAB * yJABNorm * qJABNorm;
  50. return {
  51. metrics: {
  52. angleRGB: acosDeg(cosAngleRGB),
  53. angleJAB: acosDeg(cosAngleJAB),
  54. chromaAngle: acosDeg(cosChromaAngle),
  55. hueAngle: Math.abs(qHueAngle - d3.hsl(d3.rgb(...yRGB)).h),
  56. stdDevRGB: Math.sqrt(xRGB - 2 * yTermRGB + qRGBNormSq),
  57. stdDevJAB: Math.sqrt(xJAB - 2 * yTermJAB + qJABNormSq),
  58. },
  59. scoreRGB: (includeX ? xRGB : 0) - closeCoeff * (normQY ? cosAngleRGB : yTermRGB),
  60. scoreJAB: (includeX ? xJAB : 0) - closeCoeff * (normQY ? cosAngleJAB : yTermJAB),
  61. }
  62. }
  63. };
  64. // create a tile of a given hex color
  65. const createTile = hexColor => {
  66. const tile = document.createElement("div");
  67. tile.setAttribute("class", "color-tile");
  68. tile.setAttribute("style", `background-color: ${hexColor};`)
  69. tile.textContent = hexColor;
  70. return tile;
  71. }
  72. const renderPokemon = (
  73. { name, metrics = null, scoreRGB = null, scoreJAB = null, yRGB, yJAB },
  74. { labelClass = "", rgbClass = "", jabClass = "" } = {},
  75. ) => {
  76. const titleName = titleCase(name);
  77. const rgbHex = rgb2hex(yRGB);
  78. const jabHex = jab2hex(yJAB);
  79. const textHex = getContrastingTextColor(yRGB);
  80. const rgbVec = yRGB.map(c => c.toFixed()).join(", ")
  81. const jabVec = yJAB.map(c => c.toFixed(1)).join(", ")
  82. const scoreClass = scoreRGB === null || scoreJAB === null ? "hide" : "";
  83. const pkmn = document.createElement("div");
  84. pkmn.setAttribute("class", "pokemon_tile");
  85. pkmn.innerHTML = `
  86. <div class="pokemon_tile-image-wrapper">
  87. <img src="${getSprite(name)}" />
  88. </div>
  89. <div class="pokemon_tile-info_panel">
  90. <span class="pokemon_tile-pokemon_name">${titleName}</span>
  91. <div class="pokemon_tile-results">
  92. <div class="pokemon_tile-labels ${labelClass}">
  93. <span class="${jabClass}">Jab: </span>
  94. <span class="${rgbClass}">RGB: </span>
  95. </div>
  96. <div class="pokemon_tile-score_column ${scoreClass}">
  97. <span class="pokemon_tile-no_flex ${jabClass}">
  98. (${metrics?.stdDevJAB?.toFixed(2)}, ${metrics?.angleJAB?.toFixed(1)}&deg;, ${metrics?.chromaAngle?.toFixed(1)}&deg;)
  99. </span>
  100. <span class="pokemon_tile-no_flex ${rgbClass}">
  101. (${metrics?.stdDevRGB?.toFixed(2)}, ${metrics?.angleRGB?.toFixed(1)}&deg;, ${metrics?.hueAngle?.toFixed(1)}&deg;)
  102. </span>
  103. </div>
  104. <div class="pokemon_tile-hex_column">
  105. <div class="pokemon_tile-hex_color ${jabClass}" style="background-color: ${jabHex}; color: ${textHex}">
  106. <span>${jabHex}</span><span class="pokemon_tile-vector">(${jabVec})</span>
  107. </div>
  108. <div class="pokemon_tile-hex_color ${rgbClass}" style="background-color: ${rgbHex}; color: ${textHex}">
  109. <span>${rgbHex}</span><span class="pokemon_tile-vector">(${rgbVec})</span>
  110. </div>
  111. </div>
  112. </div>
  113. </div>
  114. `;
  115. return pkmn;
  116. }
  117. const hideCustomControls = () => document
  118. .querySelectorAll(".hideable_control")
  119. .forEach(n => n.setAttribute("class", "hideable_control hideable_control--hidden"));
  120. const showCustomControls = () => document
  121. .querySelectorAll(".hideable_control")
  122. .forEach(n => n.setAttribute("class", "hideable_control"));
  123. let lastColorSearch = null;
  124. let lastPkmnSearch = null;
  125. const paramsChanged = (...args) => {
  126. const old = lastColorSearch;
  127. lastColorSearch = args;
  128. return old === null || old.filter((p, i) => p !== args[i]).length > 0
  129. }
  130. const renderVec = math => `\\vec{${math.charAt(0)}}${math.substr(1)}`;
  131. const renderNorm = vec => `\\frac{${vec}}{\\left|\\left|${vec}\\right|\\right|}`;
  132. const metricText = [
  133. "\\text{RMS}_{P} ~ \\arg\\min_{P}\\left[X\\left(P\\right) - 2\\vec{q}\\cdot \\vec{Y}\\left(P\\right)\\right]",
  134. `\\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]`,
  135. "\\angle \\left(\\vec{q}_{\\perp}, \\vec{Y}\\left(P\\right)_{\\perp} \\right)",
  136. ];
  137. const metricIncludeMinus = [true, false, false, true];
  138. const renderMath = (metric, includeX, normQY, closeCoeff) => {
  139. const found = metricText?.[metric];
  140. if (found) {
  141. return found;
  142. }
  143. const xTerm = includeX ? "X\\left(P\\right)" : "";
  144. const qyMod = normQY ? c => renderNorm(renderVec(c)) : renderVec;
  145. return `\\arg\\min_{P}\\left[${xTerm}-${closeCoeff === 1 ? "" : closeCoeff}${qyMod("q")}\\cdot ${qyMod("Y\\left(P\\right)")}\\right]`;
  146. }
  147. const renderQVec = (q, id, sub) => {
  148. document.getElementById(id).innerHTML = TeXZilla.toMathMLString(`\\vec{q}_{\\text{${sub}}} = \\left(${q.join(", ")}\\right)`);
  149. }
  150. const changePageColors = color => {
  151. // calculate luminance to determine if text should be dark or light
  152. const textColor = getContrastingTextColor([color.r, color.g, color.b]);
  153. document.querySelector("body").setAttribute("style", `background: ${color.formatHex()}; color: ${textColor}`);
  154. }
  155. const readColor = rgb => {
  156. const { J, a, b } = d3.jab(rgb);
  157. return [[rgb.r, rgb.g, rgb.b], [J, a, b]];
  158. }
  159. const onUpdate = (event) => {
  160. if (event) {
  161. event.preventDefault();
  162. }
  163. // Configuration Loading
  164. const metric = document.getElementById("metric")?.selectedIndex ?? 0;
  165. let sortBy;
  166. switch (metric) {
  167. case 0: // Variance/RMS
  168. hideCustomControls();
  169. includeX = true;
  170. normQY = false;
  171. closeCoeff = 2;
  172. sortBy = ({ scoreJAB, scoreRGB }) => [ scoreJAB, scoreRGB ];
  173. break;
  174. case 1: // Mean Angle
  175. hideCustomControls();
  176. includeX = false;
  177. normQY = true;
  178. closeCoeff = 1;
  179. sortBy = ({ scoreJAB, scoreRGB }) => [ scoreJAB, scoreRGB ];
  180. break;
  181. case 2: // Chroma
  182. hideCustomControls();
  183. includeX = false;
  184. normQY = false;
  185. closeCoeff = 0;
  186. sortBy = ({ metrics: { chromaAngle, hueAngle } }) => [ chromaAngle, hueAngle ];
  187. break;
  188. default: // Custom
  189. showCustomControls();
  190. includeX = document.getElementById("include-x")?.checked ?? false;
  191. normQY = document.getElementById("norm-q-y")?.checked ?? false;
  192. closeCoeff = document.getElementById("close-coeff")?.value ?? 2;
  193. sortBy = ({ scoreJAB, scoreRGB }) => [ scoreJAB, scoreRGB ];
  194. break;
  195. }
  196. const useRGB = document.getElementById("color-space")?.textContent === "RGB";
  197. const numPoke = document.getElementById("num-poke")?.value ?? 20;
  198. const pokemonName = document.getElementById("pokemon-name")?.value?.toLowerCase() ?? "";
  199. const colorInput = "#" + (document.getElementById("color-input")?.value?.replace("#", "") ?? "FFFFFF");
  200. // Clear pokemon search
  201. if (pokemonName.length === 0) {
  202. const searchList = document.getElementById("search-list");
  203. searchList.innerHTML = '';
  204. }
  205. // Check if parameters have changed
  206. const newParams = paramsChanged(metric, includeX, normQY, closeCoeff, useRGB, numPoke, colorInput);
  207. if (newParams) {
  208. // Update display values
  209. document.getElementById("close-coeff-display").innerHTML = closeCoeff;
  210. document.getElementById("num-poke-display").textContent = numPoke;
  211. const objFnElem = document.getElementById("obj-fn");
  212. objFnElem.innerHTML = "";
  213. objFnElem.appendChild(TeXZilla.toMathML(renderMath(metric, includeX, normQY, closeCoeff)));
  214. }
  215. // Only modified if current color is valid
  216. let calculator = () => {};
  217. // Lookup by color
  218. if (colorInput.length === 7) {
  219. // Convert input color
  220. const targetColor = d3.color(colorInput);
  221. const [ targetRGB, targetJAB ] = readColor(targetColor);
  222. // Update the color display
  223. changePageColors(targetColor);
  224. renderQVec(targetRGB.map(c => c.toFixed()), "q-vec-rgb", "RGB");
  225. renderQVec(targetJAB.map(c => c.toFixed(2)), "q-vec-jab", "Jab");
  226. // Set the scoring and sorting functions
  227. calculator = getCalculator(closeCoeff, includeX, normQY, targetRGB, targetJAB);
  228. // Rescore Pokemon and update lists if config has changed
  229. if (newParams) {
  230. const scored = database.map(info => ({ ...info, ...calculator(info) }));
  231. const bestListJAB = document.getElementById("best-list-jab");
  232. bestListJAB.innerHTML = '';
  233. scored
  234. .sort((a, b) => sortBy(a)[0] - sortBy(b)[0])
  235. .slice(0, numPoke)
  236. .forEach(info => {
  237. const li = document.createElement("li");
  238. li.appendChild(renderPokemon(info, { labelClass: "hide", rgbClass: "hide" }))
  239. bestListJAB.appendChild(li);
  240. });
  241. const bestListRGB = document.getElementById("best-list-rgb");
  242. bestListRGB.innerHTML = '';
  243. scored
  244. .sort((a, b) => sortBy(a)[1] - sortBy(b)[1])
  245. .slice(0, numPoke)
  246. .forEach(info => {
  247. const li = document.createElement("li");
  248. li.appendChild(renderPokemon(info, { labelClass: "hide", jabClass: "hide" }))
  249. bestListRGB.appendChild(li);
  250. });
  251. }
  252. }
  253. // Lookup by name
  254. if (lastPkmnSearch !== pokemonName || newParams) {
  255. const searchList = document.getElementById("search-list");
  256. searchList.innerHTML = '';
  257. pokemonLookup
  258. .search(pokemonName, { limit: 10 })
  259. // If scoring is impossible, totalScorer will just be identity
  260. .map(({ item }) => ({ ...item, ...calculator(item) }))
  261. .forEach(item => {
  262. const li = document.createElement("li");
  263. li.appendChild(renderPokemon(item))
  264. searchList.appendChild(li);
  265. });
  266. }
  267. lastPkmnSearch = pokemonName;
  268. };
  269. const onRandomColor = () => {
  270. document.getElementById("color-input").value = rgb2hex([Math.random(), Math.random(), Math.random()].map(c => c * 255));
  271. onUpdate();
  272. };