form.js 11 KB

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