form.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. const selectors = {
  2. get sortControl() {
  3. return document.forms.sortControl.elements;
  4. },
  5. get clusterControl() {
  6. return document.forms.clusterControl.elements;
  7. },
  8. get colorSelect(){
  9. return document.forms.colorSelect.elements;
  10. },
  11. get resultsToDisplay() {
  12. return selectors.sortControl.resultsToDisplay.value;
  13. },
  14. get colorSpace() {
  15. return selectors.sortControl.colorSpace.value;
  16. },
  17. get sortMetric() {
  18. return selectors.sortControl.sortMetric.value;
  19. },
  20. get sortOrder() {
  21. return selectors.sortControl.sortOrder.checked ? "max" : "min";
  22. },
  23. get scaleFactor() {
  24. return selectors.sortControl.rescaleFactor.value;
  25. },
  26. get useClusters() {
  27. return selectors.sortControl.useClusters.value;
  28. },
  29. get clusterSortMetric() {
  30. return selectors.clusterControl.sortMetric.value;
  31. },
  32. get clusterSortOrder() {
  33. return selectors.clusterControl.sortOrder.checked ? "max" : "min";
  34. },
  35. get clusterScaleFactor() {
  36. return selectors.clusterControl.rescaleFactor.value;
  37. },
  38. get prevColors() {
  39. return document.getElementById("prev-colors");
  40. },
  41. get metricFormTemplate() {
  42. return document.getElementById("metric-form-template").content;
  43. },
  44. get scaleFormTemplate() {
  45. return document.getElementById("scale-form-template").content;
  46. },
  47. get sizeFactorFormTemplate() {
  48. return document.getElementById("size-factor-template").content;
  49. },
  50. get pokemonTemplate() {
  51. return document.getElementById("pkmn-template").content;
  52. },
  53. get colorSearchResults() {
  54. return document.getElementById("color-results");
  55. },
  56. get nameSearchResults() {
  57. return document.getElementById("name-results");
  58. },
  59. set background(hex) {
  60. document.querySelector(":root").style.setProperty("--background", hex);
  61. },
  62. set highlight(hex) {
  63. document.querySelector(":root").style.setProperty("--highlight", hex);
  64. },
  65. set colorText(hex) {
  66. selectors.colorSelect.colorText.value = hex;
  67. },
  68. set colorPicker(hex) {
  69. selectors.colorSelect.colorPicker.value = hex;
  70. },
  71. };
  72. const onMetricChange = (elements, skipUpdates = false) => {
  73. elements.sortOrderLabel.value = elements.sortOrder.checked
  74. ? "Maximizing"
  75. : "Minimizing";
  76. const kind = elements.metricKind.value;
  77. elements.whole.disabled = kind !== "whole";
  78. elements.mean.disabled = kind !== "mean";
  79. elements.statistic.disabled = kind !== "statistic";
  80. elements.sortMetric.value = elements[kind].value;
  81. if (!skipUpdates) {
  82. updateSort();
  83. }
  84. };
  85. const onColorChange = (inputValue, skipUpdates = false) => {
  86. const colorInput = "#" + (inputValue?.replace("#", "") ?? "FFFFFF");
  87. if (colorInput.length !== 7) {
  88. return;
  89. }
  90. const rgb = d3.color(colorInput);
  91. if (!rgb) {
  92. return;
  93. }
  94. const hex = rgb.formatHex();
  95. selectors.colorText = hex;
  96. selectors.colorPicker = hex;
  97. const contrast = getContrastingTextColor(hex);
  98. const newColor = document.createElement("div");
  99. newColor.innerHTML = hex;
  100. newColor.style = `
  101. color: ${contrast};
  102. background-color: ${hex};
  103. `;
  104. selectors.prevColors.prepend(newColor);
  105. selectors.background = hex;
  106. selectors.highlight = contrast;
  107. if (!skipUpdates) {
  108. updateScores(rgb);
  109. updateSort();
  110. }
  111. };
  112. const randomColor = () =>
  113. d3.hsl(Math.random() * 360, Math.random(), Math.random()).formatHex();
  114. const sortOrders = {
  115. max: (a, b) => b - a,
  116. min: (a, b) => a - b,
  117. };
  118. const scaleOptions = {
  119. none: () => [1, 1, 1],
  120. direct: (scores) => scores.clusters.map((c) => c.proportion),
  121. inverse: (scores) => scores.clusters.map((c) => c.inverseProportion),
  122. size: (scores) => [scores.total.size, scores.total.size, scores.total.size],
  123. inverseSize: (scores) => [
  124. scores.total.inverseSize,
  125. scores.total.inverseSize,
  126. scores.total.inverseSize,
  127. ],
  128. };
  129. const cardinalityTerms = {
  130. clusterSize: (scores, index) => scores.clusters[index].size,
  131. invClusterSize: (scores, index) => scores.clusters[index].inverseSize,
  132. totalSize: (scores) => scores.total.size,
  133. invTotalSize: (scores) => scores.total.inverseSize,
  134. };
  135. const currentScores = {};
  136. const currentBestClusterIndices = {};
  137. const currentSortValues = {};
  138. let sortedData = [];
  139. const updateScores = (rgb) => {
  140. const { J, a, b } = d3.jab(rgb);
  141. const targetJab = buildVectorData([J, a, b], jab2hue, jab2lit, jab2chroma, jab2hex);
  142. const targetRgb = buildVectorData(
  143. [rgb.r, rgb.g, rgb.b],
  144. rgb2hue,
  145. rgb2lit,
  146. rgb2chroma,
  147. rgb2hex
  148. );
  149. pokemonData.forEach(({ name, jab, rgb }) => {
  150. currentScores[name] = {
  151. jab: {
  152. total: calcScores(jab.total, targetJab),
  153. clusters: jab.clusters.map((c) => calcScores(c, targetJab)),
  154. },
  155. rgb: {
  156. total: calcScores(rgb.total, targetRgb),
  157. clusters: rgb.clusters.map((c) => calcScores(c, targetRgb)),
  158. },
  159. };
  160. });
  161. };
  162. const updateSort = () => {
  163. // update cluster rankings
  164. const clusterSortOrder = sortOrders[selectors.clusterSortOrder];
  165. const clusterScaleOption = scaleOptions[selectors.clusterScaleFactor];
  166. pokemonData.forEach(({ name }) => {
  167. const { jab, rgb } = currentScores[name];
  168. // multiply scale with the intended metric, and find the index of the best value
  169. const forSpace = (clusters, scales) =>
  170. clusters
  171. .map((c, i) => [c[selectors.clusterSortMetric] * scales[i], i])
  172. .reduce((a, b) => (clusterSortOrder(a[0], b[0]) > 0 ? b : a))[1];
  173. currentBestClusterIndices[name] = {
  174. jab: forSpace(jab.clusters, clusterScaleOption(jab)),
  175. rgb: forSpace(rgb.clusters, clusterScaleOption(rgb)),
  176. };
  177. });
  178. // set up for actual sort
  179. const scaleOption = scaleOptions[selectors.scaleFactor];
  180. switch (selectors.useClusters) {
  181. case "off":
  182. pokemonData.forEach(({ name }) => {
  183. currentSortValues[name] =
  184. currentScores[name][selectors.colorSpace].total[selectors.sortMetric];
  185. });
  186. break;
  187. case "on":
  188. pokemonData.forEach(({ name }) => {
  189. const index = currentBestClusterIndices[name][selectors.colorSpace];
  190. currentSortValues[name] =
  191. scaleOption(currentScores[name][selectors.colorSpace])[index] *
  192. currentScores[name][selectors.colorSpace].clusters[index][selectors.sortMetric];
  193. });
  194. break;
  195. case "mult":
  196. pokemonData.forEach(({ name }) => {
  197. const index = currentBestClusterIndices[name][selectors.colorSpace];
  198. currentSortValues[name] =
  199. currentScores[name][selectors.colorSpace].total[selectors.sortMetric] *
  200. scaleOption(currentScores[name][selectors.colorSpace])[index] *
  201. currentScores[name][selectors.colorSpace].clusters[index][selectors.sortMetric];
  202. });
  203. break;
  204. }
  205. // update actual sorted data
  206. const sortOrder = sortOrders[selectors.sortOrder];
  207. sortedData = pokemonData
  208. .map(({ name }) => name)
  209. .sort((a, b) => sortOrder(currentSortValues[a], currentSortValues[b]));
  210. // and desplay results
  211. showResults();
  212. };
  213. const getSprite = (() => {
  214. const stripForm = [
  215. "flabebe",
  216. "floette",
  217. "florges",
  218. "vivillon",
  219. "basculin",
  220. "furfrou",
  221. "magearna",
  222. "alcremie",
  223. ];
  224. return (pokemon) => {
  225. pokemon = pokemon
  226. .replace("-alola", "-alolan")
  227. .replace("-galar", "-galarian")
  228. .replace("-phony", "") // sinistea and polteageist
  229. .replace("darmanitan-galarian", "darmanitan-galarian-standard");
  230. if (stripForm.find((s) => pokemon.includes(s))) {
  231. pokemon = pokemon.replace(/-.*$/, "");
  232. }
  233. return `https://img.pokemondb.net/sprites/sword-shield/icon/${pokemon}.png`;
  234. };
  235. })();
  236. const makePokemonTile = (name) => {
  237. const clone = selectors.pokemonTemplate.cloneNode(true);
  238. const img = clone.querySelector("img");
  239. img.src = getSprite(name);
  240. img.alt = name;
  241. clone.querySelector(".pkmn-name").innerText = name
  242. .split("-")
  243. .map((part) => part.charAt(0).toUpperCase() + part.substr(1))
  244. .join(" ");
  245. clone.querySelector(".pkmn-score").innerText = currentSortValues[name].toFixed(3);
  246. const { total, clusters } = currentScores[name][selectors.colorSpace];
  247. const mu = clone.querySelector(".pkmn-total");
  248. mu.innerText = total.muHex;
  249. mu.style = `
  250. color: ${getContrastingTextColor(total.muHex)};
  251. background-color: ${total.muHex};
  252. `;
  253. clusters.forEach((cls, i) => {
  254. const clsDiv = clone.querySelector(`.pkmn-cls${i + 1}`);
  255. clsDiv.firstChild.innerText = (cls.proportion * 100).toFixed(2) + "%";
  256. clsDiv.lastChild.innerText = cls.muHex;
  257. clsDiv.style = `
  258. color: ${getContrastingTextColor(cls.muHex)};
  259. background-color: ${cls.muHex};
  260. `;
  261. });
  262. if (selectors.useClusters === "off") {
  263. clone.querySelector(".pkmn").classList.add("pkmn-total-selected");
  264. } else {
  265. clone
  266. .querySelector(".pkmn")
  267. .classList.add(
  268. `pkmn-cls${currentBestClusterIndices[name][selectors.colorSpace] + 1}-selected`
  269. );
  270. }
  271. return clone;
  272. };
  273. const pokemonLookup = new Fuse(pokemonData, { keys: ["name"] });
  274. let currentNameSearchResults = [];
  275. const searchByName = (newSearch) => {
  276. currentNameSearchResults = pokemonLookup
  277. .search(newSearch, { limit: 10 })
  278. .map(({ item: { name } }) => name);
  279. showResults();
  280. };
  281. const randomPokemon = () => {
  282. currentNameSearchResults = Array.from(
  283. { length: 10 },
  284. () => pokemonData[Math.floor(Math.random() * pokemonData.length)].name
  285. );
  286. showResults();
  287. };
  288. const showResults = () => {
  289. selectors.colorSearchResults.innerHTML = "";
  290. sortedData.slice(0, selectors.resultsToDisplay).forEach((name) => {
  291. selectors.colorSearchResults.appendChild(makePokemonTile(name));
  292. });
  293. selectors.nameSearchResults.innerHTML = "";
  294. currentNameSearchResults.forEach((name) => {
  295. selectors.nameSearchResults.appendChild(makePokemonTile(name));
  296. });
  297. };
  298. window.addEventListener("load", () => {
  299. const metricTemplate = selectors.metricFormTemplate;
  300. selectors.sortControl.metric.prepend(metricTemplate.cloneNode(true));
  301. selectors.sortControl.metricKind.value = "whole";
  302. selectors.sortControl.whole.value = "alpha";
  303. onMetricChange(selectors.sortControl, true);
  304. selectors.clusterControl.metric.appendChild(metricTemplate.cloneNode(true));
  305. selectors.clusterControl.sortOrder.checked = true;
  306. selectors.clusterControl.metricKind.value = "statistic";
  307. selectors.clusterControl.statistic.value = "importance";
  308. onMetricChange(selectors.clusterControl, true);
  309. const scaleTemplate = selectors.scaleFormTemplate;
  310. selectors.sortControl.rescaleSection.appendChild(scaleTemplate.cloneNode(true));
  311. selectors.sortControl.rescaleFactor.value = "inverse";
  312. selectors.clusterControl.rescaleSection.appendChild(scaleTemplate.cloneNode(true));
  313. selectors.clusterControl.rescaleFactor.value = "none";
  314. document.body.addEventListener("click", ({ target: { innerText }, detail }) => {
  315. if (detail === 2 && innerText?.includes("#")) {
  316. const clickedHex = innerText?.match(/.*(#[0-9a-fA-F]{6}).*/)?.[1] ?? "";
  317. if (clickedHex) {
  318. onColorChange(clickedHex);
  319. }
  320. }
  321. });
  322. onColorChange(randomColor());
  323. });