form.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. const selectors = {
  2. get colorSelect() {
  3. return document.forms.colorSelect.elements;
  4. },
  5. set colorText(hex) {
  6. selectors.colorSelect.colorText.value = hex;
  7. },
  8. set colorPicker(hex) {
  9. selectors.colorSelect.colorPicker.value = hex;
  10. },
  11. get sortControl() {
  12. return document.forms.sortControl;
  13. },
  14. get resultsToDisplay() {
  15. return selectors.sortControl.elements.resultsToDisplay.value;
  16. },
  17. get colorSpace() {
  18. return selectors.sortControl.elements.colorSpace.value;
  19. },
  20. get prevColors() {
  21. return document.getElementById("prev-colors");
  22. },
  23. get pokemonTemplate() {
  24. return document.getElementById("pkmn-template").content;
  25. },
  26. get colorSearchResults() {
  27. return document.getElementById("color-results");
  28. },
  29. get nameSearchResults() {
  30. return document.getElementById("name-results");
  31. },
  32. set background(hex) {
  33. document.querySelector(":root").style.setProperty("--background", hex);
  34. },
  35. set highlight(hex) {
  36. document.querySelector(":root").style.setProperty("--highlight", hex);
  37. },
  38. get metricSelectTemplate() {
  39. return document.getElementById("metric-select-template").content;
  40. },
  41. get sortFunction() {
  42. return document.forms.sortFunction;
  43. },
  44. get sortMetric() {
  45. return document.forms.sortMetric.elements.sortMetric.value;
  46. },
  47. get sortOrder() {
  48. return selectors.sortFunction.elements.sortOrder.checked ? "max" : "min";
  49. },
  50. get sortUseBestCluster() {
  51. return selectors.sortFunction.elements.useBestCluster.checked;
  52. },
  53. get sortUseWholeImage() {
  54. return selectors.sortFunction.elements.useWholeImage.checked;
  55. },
  56. get sortClusterSize() {
  57. return selectors.sortFunction.elements.clusterSize.checked;
  58. },
  59. get sortInverseClusterSize() {
  60. return selectors.sortFunction.elements.invClusterSize.checked;
  61. },
  62. get sortTotalSize() {
  63. return selectors.sortFunction.elements.totalSize.checked;
  64. },
  65. get sortInverseTotalSize() {
  66. return selectors.sortFunction.elements.invTotalSize.checked;
  67. },
  68. set sortMetricSymbol(sym) {
  69. selectors.sortFunction.elements.metricSymbolP.value = sym;
  70. selectors.sortFunction.elements.metricSymbolB.value = sym;
  71. },
  72. get sortIsUsingCluster() {
  73. return (
  74. selectors.sortUseBestCluster ||
  75. selectors.sortClusterSize ||
  76. selectors.sortInverseClusterSize
  77. );
  78. },
  79. get clusterFunction() {
  80. return document.forms.clusterFunction;
  81. },
  82. get clusterSortMetric() {
  83. return document.forms.clusterMetric.elements.sortMetric.value;
  84. },
  85. get clusterSortOrder() {
  86. return selectors.clusterFunction.elements.sortOrder.checked ? "max" : "min";
  87. },
  88. get clusterSortClusterSize() {
  89. return selectors.clusterFunction.elements.clusterSize.checked;
  90. },
  91. get clusterSortInverseClusterSize() {
  92. return selectors.clusterFunction.elements.invClusterSize.checked;
  93. },
  94. get clusterSortTotalSize() {
  95. return selectors.clusterFunction.elements.totalSize.checked;
  96. },
  97. get clusterSortInverseTotalSize() {
  98. return selectors.clusterFunction.elements.invTotalSize.checked;
  99. },
  100. set clusterMetricSymbol(sym) {
  101. selectors.clusterFunction.elements.metricSymbol.value = sym;
  102. },
  103. };
  104. const onMetricChange = (elements, skipUpdates = false) => {
  105. const kind = elements.metricKind.value;
  106. elements.whole.disabled = kind !== "whole";
  107. elements.mean.disabled = kind !== "mean";
  108. elements.statistic.disabled = kind !== "statistic";
  109. elements.sortMetric.value = elements[kind].value;
  110. if (!skipUpdates) {
  111. // terrible hack
  112. selectors.sortMetricSymbol = document
  113. .querySelector(`option[value=${selectors.sortMetric}]`)
  114. .textContent.at(-2);
  115. selectors.clusterMetricSymbol = document
  116. .querySelector(`option[value=${selectors.clusterSortMetric}]`)
  117. .textContent.at(-2);
  118. updateSort();
  119. }
  120. };
  121. const onColorChange = (inputValue, skipUpdates = false) => {
  122. const colorInput = "#" + (inputValue?.replace("#", "") ?? "FFFFFF");
  123. if (colorInput.length !== 7) {
  124. return;
  125. }
  126. const rgb = d3.color(colorInput);
  127. if (!rgb) {
  128. return;
  129. }
  130. const hex = rgb.formatHex();
  131. selectors.colorText = hex;
  132. selectors.colorPicker = hex;
  133. const contrast = getContrastingTextColor(hex);
  134. const newColor = document.createElement("div");
  135. newColor.innerHTML = hex;
  136. newColor.style = `
  137. color: ${contrast};
  138. background-color: ${hex};
  139. `;
  140. selectors.prevColors.prepend(newColor);
  141. selectors.background = hex;
  142. selectors.highlight = contrast;
  143. if (!skipUpdates) {
  144. updateScores(rgb);
  145. updateSort();
  146. }
  147. };
  148. const randomColor = () =>
  149. d3.hsl(Math.random() * 360, Math.random(), Math.random()).formatHex();
  150. const sortOrders = {
  151. max: (a, b) => b - a,
  152. min: (a, b) => a - b,
  153. };
  154. const getCardinalityFactorExtractor = (
  155. clusterSize,
  156. invClusterSize,
  157. totalSize,
  158. invTotalSize
  159. ) => {
  160. const extractors = [];
  161. if (clusterSize) {
  162. extractors.push((scores) => scores.clusters.map(({ size }) => size));
  163. }
  164. if (invClusterSize) {
  165. extractors.push((scores) => scores.clusters.map(({ inverseSize }) => inverseSize));
  166. }
  167. if (totalSize) {
  168. extractors.push((scores) => scores.clusters.map(() => scores.total.size));
  169. }
  170. if (invTotalSize) {
  171. extractors.push((scores) => scores.clusters.map(() => scores.total.inverseSize));
  172. }
  173. return (scores) =>
  174. extractors
  175. .map((ext) => ext(scores))
  176. .reduce(
  177. (acc, xs) => acc.map((a, i) => a * xs[i]),
  178. scores.clusters.map(() => 1)
  179. );
  180. };
  181. const currentScores = {};
  182. const currentBestClusterIndices = {};
  183. const currentSortValues = {};
  184. let sortedData = [];
  185. const updateScores = (rgb) => {
  186. const { J, a, b } = d3.jab(rgb);
  187. const targetJab = buildVectorData([J, a, b], jab2hue, jab2lit, jab2chroma, jab2hex);
  188. const targetRgb = buildVectorData(
  189. [rgb.r, rgb.g, rgb.b],
  190. rgb2hue,
  191. rgb2lit,
  192. rgb2chroma,
  193. rgb2hex
  194. );
  195. pokemonData.forEach(({ name, jab, rgb }) => {
  196. currentScores[name] = {
  197. jab: {
  198. total: calcScores(jab.total, targetJab),
  199. clusters: jab.clusters.map((c) => calcScores(c, targetJab)),
  200. },
  201. rgb: {
  202. total: calcScores(rgb.total, targetRgb),
  203. clusters: rgb.clusters.map((c) => calcScores(c, targetRgb)),
  204. },
  205. };
  206. });
  207. };
  208. const updateSort = () => {
  209. // fade out cluster stuff if not in use
  210. selectors.clusterFunction.classList.toggle("fade", !selectors.sortIsUsingCluster);
  211. document.forms.clusterMetric.classList.toggle("fade", !selectors.sortIsUsingCluster);
  212. // update cluster rankings
  213. const clusterSortOrder = sortOrders[selectors.clusterSortOrder];
  214. const getClusterCardinalityFactors = getCardinalityFactorExtractor(
  215. selectors.clusterSortClusterSize,
  216. selectors.clusterSortInverseClusterSize,
  217. selectors.clusterSortTotalSize,
  218. selectors.clusterSortInverseTotalSize
  219. );
  220. pokemonData.forEach(({ name }) => {
  221. const { jab, rgb } = currentScores[name];
  222. // multiply scales with the intended metric, and find the index of the best value
  223. const forSpace = (clusters, factors) =>
  224. clusters
  225. .map((c, i) => [c[selectors.clusterSortMetric] * factors[i], i])
  226. .reduce((a, b) => (clusterSortOrder(a[0], b[0]) > 0 ? b : a))[1];
  227. currentBestClusterIndices[name] = {
  228. jab: forSpace(jab.clusters, getClusterCardinalityFactors(jab)),
  229. rgb: forSpace(rgb.clusters, getClusterCardinalityFactors(rgb)),
  230. };
  231. });
  232. // set up for actual sort
  233. const getCardinalityFactors = getCardinalityFactorExtractor(
  234. selectors.sortClusterSize,
  235. selectors.sortInverseClusterSize,
  236. selectors.sortTotalSize,
  237. selectors.sortInverseTotalSize
  238. );
  239. const factors = [
  240. (name) =>
  241. getCardinalityFactors(currentScores[name][selectors.colorSpace])[
  242. currentBestClusterIndices[name][selectors.colorSpace]
  243. ],
  244. ];
  245. if (selectors.sortUseWholeImage) {
  246. factors.push(
  247. (name) => currentScores[name][selectors.colorSpace].total[selectors.sortMetric]
  248. );
  249. }
  250. if (selectors.sortUseBestCluster) {
  251. factors.push(
  252. (name) =>
  253. currentScores[name][selectors.colorSpace].clusters[
  254. currentBestClusterIndices[name][selectors.colorSpace]
  255. ][selectors.sortMetric]
  256. );
  257. }
  258. pokemonData.forEach(({ name }) => {
  259. currentSortValues[name] = factors.map((fn) => fn(name)).reduce((x, y) => x * y);
  260. });
  261. // update actual sorted data
  262. const sortOrder = sortOrders[selectors.sortOrder];
  263. sortedData = pokemonData
  264. .map(({ name }) => name)
  265. .sort((a, b) => sortOrder(currentSortValues[a], currentSortValues[b]));
  266. // and desplay results
  267. showResults();
  268. };
  269. const getSprite = (() => {
  270. const stripForm = [
  271. "flabebe",
  272. "floette",
  273. "florges",
  274. "vivillon",
  275. "basculin",
  276. "furfrou",
  277. "magearna",
  278. "alcremie",
  279. ];
  280. return (pokemon) => {
  281. pokemon = pokemon
  282. .replace("-alola", "-alolan")
  283. .replace("-galar", "-galarian")
  284. .replace("-phony", "") // sinistea and polteageist
  285. .replace("darmanitan-galarian", "darmanitan-galarian-standard");
  286. if (stripForm.find((s) => pokemon.includes(s))) {
  287. pokemon = pokemon.replace(/-.*$/, "");
  288. }
  289. return `https://img.pokemondb.net/sprites/sword-shield/icon/${pokemon}.png`;
  290. };
  291. })();
  292. const makePokemonTile = (name) => {
  293. const clone = selectors.pokemonTemplate.cloneNode(true);
  294. const img = clone.querySelector("img");
  295. img.src = getSprite(name);
  296. img.alt = name;
  297. const formattedName = name
  298. .split("-")
  299. .map((part) => part.charAt(0).toUpperCase() + part.substr(1))
  300. .join(" ");
  301. clone.querySelector(".pkmn-name").innerText = formattedName;
  302. clone.querySelector(".pkmn-name").title = formattedName;
  303. clone.querySelector(".pkmn-score").innerText = currentSortValues[name].toFixed(3);
  304. const { total, clusters } = currentScores[name][selectors.colorSpace];
  305. const mu = clone.querySelector(".pkmn-total");
  306. mu.innerText = total.muHex;
  307. mu.style = `
  308. color: ${getContrastingTextColor(total.muHex)};
  309. background-color: ${total.muHex};
  310. `;
  311. clusters.forEach((cls, i) => {
  312. const clsDiv = clone.querySelector(`.pkmn-cls${i + 1}`);
  313. clsDiv.firstChild.innerText = (cls.proportion * 100).toFixed(2) + "%";
  314. clsDiv.lastChild.innerText = cls.muHex;
  315. clsDiv.style = `
  316. color: ${getContrastingTextColor(cls.muHex)};
  317. background-color: ${cls.muHex};
  318. `;
  319. });
  320. if (selectors.sortUseWholeImage) {
  321. clone.querySelector(".pkmn").classList.add("pkmn-total-selected");
  322. }
  323. if (selectors.sortIsUsingCluster) {
  324. clone
  325. .querySelector(".pkmn")
  326. .classList.add(
  327. `pkmn-cls${currentBestClusterIndices[name][selectors.colorSpace] + 1}-selected`
  328. );
  329. }
  330. return clone;
  331. };
  332. const pokemonLookup = new Fuse(pokemonData, { keys: ["name"] });
  333. let currentNameSearchResults = [];
  334. const searchByName = (newSearch) => {
  335. currentNameSearchResults = pokemonLookup
  336. .search(newSearch, { limit: 100 })
  337. .map(({ item: { name } }) => name);
  338. showResults();
  339. };
  340. const randomPokemon = () => {
  341. currentNameSearchResults = Array.from(
  342. { length: 100 },
  343. () => pokemonData[Math.floor(Math.random() * pokemonData.length)].name
  344. );
  345. showResults();
  346. };
  347. const showResults = () => {
  348. selectors.colorSearchResults.innerHTML = "";
  349. sortedData.slice(0, selectors.resultsToDisplay).forEach((name) => {
  350. selectors.colorSearchResults.appendChild(makePokemonTile(name));
  351. });
  352. selectors.nameSearchResults.innerHTML = "";
  353. currentNameSearchResults.slice(0, selectors.resultsToDisplay).forEach((name) => {
  354. selectors.nameSearchResults.appendChild(makePokemonTile(name));
  355. });
  356. };
  357. window.addEventListener("load", () => {
  358. const metricSelect = selectors.metricSelectTemplate;
  359. document.forms.sortMetric.appendChild(metricSelect.cloneNode(true));
  360. document.forms.sortMetric.querySelector("legend").textContent = "Sort Metric";
  361. document.forms.sortMetric.elements.metricKind.value = "whole";
  362. document.forms.sortMetric.elements.whole.value = "alpha";
  363. onMetricChange(document.forms.sortMetric.elements, true);
  364. document.forms.clusterMetric.appendChild(metricSelect.cloneNode(true));
  365. document.forms.clusterMetric.querySelector("legend").textContent = "Cluster Metric";
  366. document.forms.clusterMetric.elements.metricKind.value = "statistic";
  367. document.forms.clusterMetric.elements.statistic.value = "importance";
  368. onMetricChange(document.forms.clusterMetric.elements, true);
  369. document.body.addEventListener("click", ({ target: { innerText }, detail }) => {
  370. if (detail === 2 && innerText?.includes("#")) {
  371. const clickedHex = innerText?.match(/.*(#[0-9a-fA-F]{6}).*/)?.[1] ?? "";
  372. if (clickedHex) {
  373. onColorChange(clickedHex);
  374. }
  375. }
  376. });
  377. onColorChange(randomColor());
  378. });