main.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. // ---- Math and Utilities ----
  2. // Vector Math
  3. const vectorDot = (u, v) => u.map((x, i) => x * v[i]).reduce((x, y) => x + y);
  4. const unitVector = v => {
  5. const mag = Math.hypot(...v);
  6. return v.map(c => c / mag);
  7. };
  8. // Angle Math
  9. const rad2deg = 180 / Math.PI;
  10. const twoPi = 2 * Math.PI;
  11. // Color Conversion
  12. const hex2rgb = hex => {
  13. hex = hex.replace("#", "");
  14. const red = hex.substr(0, 2);
  15. const grn = hex.substr(2, 2);
  16. const blu = hex.substr(4, 2);
  17. return [red, grn, blu].map(c => parseInt(c, 16));
  18. };
  19. // calculated from analyze.py
  20. RGB_TO_LMS = [
  21. [0.4121965, 0.53627432, 0.05143268],
  22. [0.2119195, 0.68071831, 0.10738379],
  23. [0.08834911, 0.28185414, 0.63018663],
  24. ];
  25. LMS_TO_OKLAB = [
  26. [0.2104542553, 0.793617785, -0.0040720468],
  27. [1.9779984951, -2.428592205, 0.4505937099],
  28. [0.0259040371, 0.7827717662, -0.808675766],
  29. ];
  30. const hex2oklab = hex => {
  31. const lrgb = hex2rgb(hex).map(c => {
  32. const v = c / 255;
  33. return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
  34. });
  35. const lms = RGB_TO_LMS.map(row => Math.cbrt(vectorDot(row, lrgb)));
  36. return LMS_TO_OKLAB.map(row => vectorDot(row, lms));
  37. };
  38. // Misc
  39. const clamp = (min, value, max) => Math.min(Math.max(value, min), max);
  40. const productLift =
  41. (...factors) =>
  42. (...args) =>
  43. factors
  44. .filter(fn => !!fn)
  45. .map(fn => fn(...args))
  46. .reduce((x, y) => x * y, 1);
  47. // Pre-computation
  48. const getColorData = hex => {
  49. const lab = hex2oklab(hex);
  50. return {
  51. hex,
  52. vector: lab,
  53. unit: unitVector(lab),
  54. chroma: Math.hypot(lab[1], lab[2]),
  55. hue: (Math.atan2(lab[2], lab[1]) + twoPi) % twoPi,
  56. };
  57. };
  58. const pokemonData = database.map(({ total, clusters, ...rest }) => ({
  59. total: {
  60. ...total,
  61. unitCentroid: unitVector(total.centroid),
  62. stddev: Math.sqrt(total.variance),
  63. proportion: 1,
  64. inverseSize: 1 / total.size,
  65. },
  66. clusters: clusters.map(c => ({
  67. ...c,
  68. unitCentroid: unitVector(c.centroid),
  69. stddev: Math.sqrt(c.variance),
  70. proportion: c.size / total.size,
  71. inverseSize: 1 / c.size,
  72. })),
  73. ...rest,
  74. }));
  75. const pokemonLookup = new Fuse(pokemonData, { keys: ["name"] });
  76. const calcScores = (data, target) => {
  77. const { centroid, unitCentroid, tilt, variance, chroma, hue } = data;
  78. const delta = Math.hypot(...centroid.map((c, i) => c - target.vector[i]));
  79. return {
  80. ...data,
  81. delta,
  82. psi: Math.sqrt(variance + delta * delta),
  83. omega: 1 - vectorDot(unitCentroid, target.unit),
  84. lambda: 1 - vectorDot(tilt, target.unit),
  85. deltaL: Math.abs(centroid[0] - target.vector[0]),
  86. deltaC: Math.abs(chroma - target.chroma),
  87. deltaH: Math.abs(hue - target.hue) % Math.PI,
  88. };
  89. };
  90. const sortOrders = {
  91. max: (a, b) => b - a,
  92. min: (a, b) => a - b,
  93. };
  94. // who needs a framework?
  95. const makeTemplate = (id, definition = () => ({})) => {
  96. const content = document.getElementById(id).content;
  97. return (...args) => {
  98. const fragment = content.cloneNode(true);
  99. const binds = Object.fromEntries(
  100. Array.from(fragment.querySelectorAll("[bind-to]")).map(element => {
  101. const name = element.getAttribute("bind-to");
  102. element.removeAttribute("bind-to");
  103. return [name, element];
  104. })
  105. );
  106. Object.entries(definition(...args))
  107. .map(([name, settings]) => [binds[name], settings])
  108. .filter(([bind]) => !!bind)
  109. .forEach(([bind, settings]) =>
  110. Object.entries(settings).forEach(([setting, value]) => {
  111. if (setting.startsWith("@")) {
  112. bind.addEventListener(setting.slice(1), value);
  113. } else if (setting.startsWith("--")) {
  114. bind.style.setProperty(setting, value);
  115. } else if (setting === "dataset") {
  116. Object.entries(value).forEach(([key, data]) => (bind.dataset[key] = data));
  117. } else if (setting === "append") {
  118. if (Array.isArray(value)) {
  119. bind.append(...value);
  120. } else {
  121. bind.append(value);
  122. }
  123. } else {
  124. bind[setting] = value;
  125. }
  126. })
  127. );
  128. return [fragment, binds];
  129. };
  130. };
  131. // ---- Selectors ----
  132. const rootStyle = document.querySelector(":root").style;
  133. const colorSearchResultsTarget = document.getElementById("color-results");
  134. const nameSearchResultsTarget = document.getElementById("name-results");
  135. const prevColorsSidebar = document.getElementById("prevColors");
  136. const clusterRankingTitle = document.getElementById("cls-title");
  137. const clusterMetricSection = document.getElementById("cls-metric-mount");
  138. const clusterFunctionSection = document.getElementById("cls-fn");
  139. const colorCalculateForm = document.forms.colorCalculateForm;
  140. const colorSortForm = document.forms.colorSortForm;
  141. const targetColorElements = document.forms.targetColorForm.elements;
  142. const colorDisplayElements = document.forms.colorDisplayForm.elements;
  143. const nameSearchFormElements = document.forms.nameSearchForm.elements;
  144. // ---- Add Metric Selects ----
  145. const createMetricSelect = makeTemplate("metric-select-template");
  146. const [{ firstElementChild: sortMetricForm }] = createMetricSelect();
  147. const [{ firstElementChild: clusterMetricForm }] = createMetricSelect();
  148. document.getElementById("sort-metric-mount").append(sortMetricForm);
  149. sortMetricForm.elements.metricKind.value = "whole";
  150. document.getElementById("cls-metric-mount").append(clusterMetricForm);
  151. clusterMetricForm.elements.metricKind.value = "stat";
  152. const updateMetricSelects = form => {
  153. const kind = form.elements.metricKind.value;
  154. form.elements.whole.disabled = kind !== "whole";
  155. form.elements.mean.disabled = kind !== "mean";
  156. form.elements.stat.disabled = kind !== "stat";
  157. form.elements.metric.value = form.elements[kind].value;
  158. };
  159. // bit of a hack, but lets us control this all from the template
  160. const metricSymbols = Object.fromEntries(
  161. Array.from(document.querySelectorAll("option")).map(el => [
  162. el.value,
  163. el.textContent.split("(")[1].split(")")[0],
  164. ])
  165. );
  166. const updateMetricDisplays = () => {
  167. updateMetricSelects(sortMetricForm);
  168. updateMetricSelects(clusterMetricForm);
  169. colorCalculateForm.elements.sortMetricSymbolP.value =
  170. colorCalculateForm.elements.sortMetricSymbolB.value =
  171. metricSymbols[
  172. sortMetricForm.elements[sortMetricForm.elements.metricKind.value].value
  173. ];
  174. colorCalculateForm.elements.clusterMetricSymbol.value =
  175. metricSymbols[
  176. clusterMetricForm.elements[clusterMetricForm.elements.metricKind.value].value
  177. ];
  178. };
  179. // ---- Styling ----
  180. const getColorStyles = hex => {
  181. const rgb = hex2rgb(hex);
  182. const lum = vectorDot(rgb, [0.3, 0.6, 0.1]);
  183. const highlight = lum >= 128 ? "var(--color-dark)" : "var(--color-light)";
  184. return {
  185. "--highlight": highlight,
  186. "--background": hex,
  187. "--shadow-component": lum >= 128 ? "0" : "255",
  188. };
  189. };
  190. const setColorStyles = (style, hex) =>
  191. Object.entries(getColorStyles(hex)).forEach(([prop, value]) =>
  192. style.setProperty(prop, value)
  193. );
  194. // ---- Pokemon Display ----
  195. // pulled out bc the render uses them
  196. const metricScores = {};
  197. const bestClusterIndices = {};
  198. const objectiveValues = {};
  199. const createPokemonTooltip = makeTemplate("pkmn-data-template", data =>
  200. Object.fromEntries(
  201. Object.entries(data).map(([metric, value]) => [
  202. metric,
  203. {
  204. innerText: Array.isArray(value)
  205. ? value.map(v => v.toFixed(2)).join(", ")
  206. : (value * (metric === "hue" || metric === "deltaH" ? rad2deg : 1))
  207. .toFixed?.(2)
  208. ?.replace(".00", ""),
  209. },
  210. ])
  211. )
  212. );
  213. const createPokemonTile = makeTemplate(
  214. "pkmn-tile-template",
  215. (pkmnName, enableTotalFlags, enableClusterFlags) => {
  216. const formattedName = pkmnName
  217. .split("-")
  218. .map(part => part.charAt(0).toUpperCase() + part.substr(1))
  219. .join(" ");
  220. const name = {
  221. innerText: formattedName,
  222. title: formattedName,
  223. };
  224. let spriteName = pkmnName
  225. .toLowerCase()
  226. .replace("-gmax", "-gigantamax")
  227. .replace("-alola", "-alolan")
  228. .replace("-galar", "-galarian")
  229. .replace("-hisui", "-hisuian")
  230. .replace("-paldea", "-paldean")
  231. .replace("-paldean-combat", "-paldean") // tauros
  232. .replace("-paldean-blaze", "-paldean-fire") // tauros
  233. .replace("-paldean-aqua", "-paldean-water") // tauros
  234. .replace("-phony", "") // sinistea and polteageist
  235. .replace("darmanitan-galarian", "darmanitan-galarian-standard")
  236. .replace("chienpao", "chien-pao")
  237. .replace("tinglu", "ting-lu")
  238. .replace("wochien", "wo-chien")
  239. .replace("chiyu", "chi-yu");
  240. if (
  241. [
  242. "flabebe",
  243. "floette",
  244. "florges",
  245. "vivillon",
  246. "basculin",
  247. "furfrou",
  248. "magearna",
  249. "alcremie",
  250. ].find(s => spriteName.includes(s))
  251. ) {
  252. spriteName = spriteName.replace(/-.*$/, "");
  253. }
  254. const imageErrorHandler = ({ target }) => {
  255. target.removeEventListener("error", imageErrorHandler);
  256. target.src = `https://img.pokemondb.net/sprites/scarlet-violet/icon/${spriteName}.png`;
  257. };
  258. const link = {
  259. href: `https://pokemondb.net/pokedex/${spriteName}`,
  260. };
  261. const image = {
  262. alt: formattedName,
  263. src: `https://img.pokemondb.net/sprites/sword-shield/icon/${spriteName}.png`,
  264. "@error": imageErrorHandler,
  265. };
  266. const score = {
  267. innerText: objectiveValues[pkmnName].toFixed(2),
  268. };
  269. const { total, clusters } = metricScores[pkmnName];
  270. const buttonBinds = [
  271. [clusters[0], "cls1Btn", "cls1Data"],
  272. [clusters[1], "cls2Btn", "cls2Data"],
  273. [clusters[2], "cls3Btn", "cls3Data"],
  274. [clusters[3], "cls4Btn", "cls4Data"],
  275. [total, "totalBtn", "totalData"],
  276. ]
  277. .filter(([data]) => !!data)
  278. .map(([data, button, tooltip], index) => {
  279. return {
  280. [button]: {
  281. dataset: {
  282. included: enableClusterFlags && index === bestClusterIndices[pkmnName],
  283. },
  284. hidden: false,
  285. innerText: data.hex,
  286. "@click"() {
  287. model.setTargetColor(data.hex);
  288. },
  289. ...getColorStyles(data.hex),
  290. },
  291. [tooltip]: {
  292. append: createPokemonTooltip(data)[0],
  293. },
  294. };
  295. })
  296. .reduce((a, b) => ({ ...a, ...b }), {});
  297. buttonBinds.totalBtn.dataset.included = enableTotalFlags;
  298. return { name, image, link, score, ...buttonBinds };
  299. }
  300. );
  301. const renderPokemon = (list, target) => {
  302. target.innerText = "";
  303. const {
  304. sortUseWholeImage,
  305. sortUseBestCluster,
  306. sortUseClusterSize,
  307. sortUseInvClusterSize,
  308. sortUseTotalSize,
  309. sortUseInvTotalSize,
  310. } = Object.fromEntries(new FormData(colorCalculateForm).entries());
  311. const enableTotalFlags = !!(
  312. sortUseWholeImage ||
  313. sortUseTotalSize ||
  314. sortUseInvTotalSize
  315. );
  316. const enableClusterFlags = !!(
  317. sortUseBestCluster ||
  318. sortUseClusterSize ||
  319. sortUseInvClusterSize
  320. );
  321. target.append(
  322. ...list.map(name => createPokemonTile(name, enableTotalFlags, enableClusterFlags)[0])
  323. );
  324. };
  325. // ---- Calculation Logic ----
  326. const model = {
  327. setTargetColor(newColor) {
  328. const hex = `#${newColor?.replace("#", "")}`;
  329. if (hex.length !== 7) {
  330. return;
  331. }
  332. setColorStyles(rootStyle, hex);
  333. const oldColor = this.targetColor;
  334. this.targetColor = hex;
  335. targetColorElements.colorText.value = hex;
  336. targetColorElements.colorText.dataset.lastValid = hex;
  337. targetColorElements.colorPicker.value = hex;
  338. if (oldColor) {
  339. const prevButton = document.createElement("button");
  340. prevButton.innerText = oldColor;
  341. prevButton.classList = "color-select";
  342. setColorStyles(prevButton.style, oldColor);
  343. prevButton.addEventListener("click", () => this.setTargetColor(oldColor));
  344. prevColorsSidebar.prepend(prevButton);
  345. }
  346. const targetData = getColorData(hex);
  347. pokemonData.forEach(({ name, total, clusters }) => {
  348. metricScores[name] = {
  349. total: calcScores(total, targetData),
  350. clusters: clusters.map(c => calcScores(c, targetData)),
  351. };
  352. });
  353. this.calculateObjective();
  354. },
  355. calculateObjective() {
  356. const {
  357. clusterUseClusterSize,
  358. clusterUseInvClusterSize,
  359. clusterUseTotalSize,
  360. clusterUseInvTotalSize,
  361. clusterSortOrder,
  362. sortUseWholeImage,
  363. sortUseBestCluster,
  364. sortUseClusterSize,
  365. sortUseInvClusterSize,
  366. sortUseTotalSize,
  367. sortUseInvTotalSize,
  368. } = Object.fromEntries(new FormData(colorCalculateForm).entries());
  369. const clsMetric = clusterMetricForm.elements.metric.value;
  370. const getClusterScore = productLift(
  371. cluster => cluster[clsMetric],
  372. clusterUseClusterSize && (cluster => cluster.size),
  373. clusterUseInvClusterSize && (cluster => cluster.inverseSize),
  374. clusterUseTotalSize && ((_, total) => total.size),
  375. clusterUseInvTotalSize && ((_, total) => total.inverseSize)
  376. );
  377. const clsSort = sortOrders[clusterSortOrder];
  378. const getBestClusterIndex = ({ total, clusters }) =>
  379. clusters
  380. .map((c, i) => [getClusterScore(c, total), i])
  381. .reduce((a, b) => (clsSort(a[0], b[0]) > 0 ? b : a))[1];
  382. Object.entries(metricScores).forEach(([name, scores]) => {
  383. bestClusterIndices[name] = getBestClusterIndex(scores);
  384. });
  385. const metric = sortMetricForm.elements.metric.value;
  386. const getSortScore = productLift(
  387. sortUseWholeImage && (({ total }) => total[metric]),
  388. sortUseBestCluster && (({ clusters }, i) => clusters[i][metric]),
  389. sortUseClusterSize && (({ clusters }, i) => clusters[i].size),
  390. sortUseInvClusterSize && (({ clusters }, i) => clusters[i].inverseSize),
  391. sortUseTotalSize && (({ total }) => total.size),
  392. sortUseInvTotalSize && (({ total }) => total.inverseSize)
  393. );
  394. Object.entries(metricScores).forEach(([name, scores]) => {
  395. objectiveValues[name] = getSortScore(scores, bestClusterIndices[name]);
  396. });
  397. this.renderNameSearchResults();
  398. this.rank();
  399. },
  400. rank() {
  401. const { sortOrder } = Object.fromEntries(new FormData(colorSortForm).entries());
  402. const compare = sortOrders[sortOrder];
  403. const sortFn = (a, b) => compare(objectiveValues[a], objectiveValues[b]);
  404. this.ranked = pokemonData
  405. .map(({ name }) => name)
  406. .sort((a, b) => sortFn(a, b) || a.localeCompare(b));
  407. this.renderColorSearchResults();
  408. },
  409. setNameSearchResults(newNameResults) {
  410. this.nameSearchResults = newNameResults;
  411. this.renderNameSearchResults();
  412. },
  413. renderNameSearchResults() {
  414. renderPokemon(this.nameSearchResults ?? [], nameSearchResultsTarget);
  415. },
  416. renderColorSearchResults() {
  417. renderPokemon(
  418. this.ranked.slice(0, parseInt(colorDisplayElements.resultsToDisplay.value)),
  419. colorSearchResultsTarget
  420. );
  421. },
  422. };
  423. // ---- Form Controls ----
  424. nameSearchFormElements.input.addEventListener("input", ({ target: { value } }) => {
  425. model.setNameSearchResults(
  426. pokemonLookup.search(value, { limit: 24 }).map(({ item: { name } }) => name)
  427. );
  428. });
  429. nameSearchFormElements.clear.addEventListener("click", () => {
  430. nameSearchFormElements.input.value = "";
  431. model.setNameSearchResults([]);
  432. });
  433. nameSearchFormElements.random.addEventListener("click", () => {
  434. model.setNameSearchResults(
  435. Array.from(
  436. { length: 24 },
  437. () => pokemonData[Math.floor(Math.random() * pokemonData.length)].name
  438. )
  439. );
  440. });
  441. targetColorElements.colorText.addEventListener("input", ({ target }) => {
  442. if (target.willValidate && !target.validity.valid) {
  443. target.value = target.dataset.lastValid || "";
  444. } else {
  445. model.setTargetColor(target.value);
  446. }
  447. });
  448. targetColorElements.colorPicker.addEventListener("change", ({ target }) =>
  449. model.setTargetColor(target.value)
  450. );
  451. const randomizeTargetColor = () =>
  452. model.setTargetColor(
  453. [Math.random(), Math.random(), Math.random()]
  454. .map(component =>
  455. Math.floor(component * 256)
  456. .toString(16)
  457. .padStart(2, "0")
  458. )
  459. .reduce((x, y) => x + y)
  460. );
  461. targetColorElements.randomColor.addEventListener("click", randomizeTargetColor);
  462. colorDisplayElements.resultsToDisplay.addEventListener(
  463. "input",
  464. ({ target: { value } }) => {
  465. colorDisplayElements.output.value = value;
  466. }
  467. );
  468. colorDisplayElements.resultsToDisplay.addEventListener("change", () =>
  469. model.renderColorSearchResults()
  470. );
  471. Array.from(colorSortForm.elements).forEach(el =>
  472. el.addEventListener("change", () => model.rank())
  473. );
  474. Array.from(colorCalculateForm.elements).forEach(el =>
  475. el.addEventListener("change", () => {
  476. const { sortUseBestCluster, sortUseClusterSize, sortUseInvClusterSize } =
  477. Object.fromEntries(new FormData(colorCalculateForm).entries());
  478. clusterRankingTitle.dataset.faded =
  479. clusterMetricSection.dataset.faded =
  480. clusterFunctionSection.dataset.faded =
  481. !(sortUseBestCluster || sortUseClusterSize || sortUseInvClusterSize);
  482. model.calculateObjective();
  483. })
  484. );
  485. sortMetricForm.addEventListener("change", () => {
  486. updateMetricDisplays();
  487. model.calculateObjective();
  488. });
  489. clusterMetricForm.addEventListener("change", () => {
  490. updateMetricDisplays();
  491. model.calculateObjective();
  492. });
  493. // ---- Initial Setup ----
  494. updateMetricDisplays();
  495. randomizeTargetColor();