form.js 13 KB

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