form.js 13 KB

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