main.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  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. lightness: 100 * total.centroid[0],
  63. abar: total.centroid[1],
  64. bbar: total.centroid[2],
  65. proportion: null,
  66. beta: 1,
  67. inverseSize: 1 / total.size,
  68. },
  69. clusters: clusters.map(c => ({
  70. ...c,
  71. unitCentroid: unitVector(c.centroid),
  72. lightness: 100 * c.centroid[0],
  73. abar: c.centroid[1],
  74. bbar: c.centroid[2],
  75. proportion: ((100 * c.size) / total.size).toFixed(2),
  76. beta: Math.sqrt((c.chroma * c.size) / total.size),
  77. inverseSize: 1 / c.size,
  78. })),
  79. ...rest,
  80. }));
  81. const pokemonLookup = new Fuse(pokemonData, { keys: ["name"] });
  82. const calcScores = (data, target) => {
  83. const { centroid, unitCentroid, tilt, variance, chroma, hue } = data;
  84. const delta = Math.hypot(...centroid.map((c, i) => c - target.vector[i]));
  85. const psi = Math.sqrt(variance + delta * delta);
  86. const omega = 1 - vectorDot(tilt, target.unit);
  87. return {
  88. ...data,
  89. hue: data.hue * rad2deg,
  90. delta,
  91. psi,
  92. theta: Math.acos(vectorDot(unitCentroid, target.unit)) * rad2deg,
  93. omega,
  94. alpha: 100 * Math.sqrt(psi * omega),
  95. deltaL: Math.abs(centroid[0] - target.vector[0]),
  96. deltaC: Math.abs(chroma - target.chroma),
  97. deltaH: (Math.abs(hue - target.hue) % Math.PI) * rad2deg,
  98. };
  99. };
  100. const sortOrders = {
  101. max: (a, b) => b - a,
  102. min: (a, b) => a - b,
  103. };
  104. // who needs a framework?
  105. const makeTemplate = (id, definition = () => ({})) => {
  106. const content = document.getElementById(id).content;
  107. return (...args) => {
  108. const fragment = content.cloneNode(true);
  109. const binds = Object.fromEntries(
  110. Array.from(fragment.querySelectorAll("[bind-to]")).map(element => {
  111. const name = element.getAttribute("bind-to");
  112. element.removeAttribute("bind-to");
  113. return [name, element];
  114. })
  115. );
  116. Object.entries(definition(...args))
  117. .map(([name, settings]) => [binds[name], settings])
  118. .filter(([bind]) => !!bind)
  119. .forEach(([bind, settings]) =>
  120. Object.entries(settings).forEach(([setting, value]) => {
  121. if (setting.startsWith("@")) {
  122. bind.addEventListener(setting.slice(1), value);
  123. } else if (setting.startsWith("--")) {
  124. bind.style.setProperty(setting, value);
  125. } else if (setting === "dataset") {
  126. Object.entries(value).forEach(([key, data]) => (bind.dataset[key] = data));
  127. } else if (setting === "append") {
  128. if (Array.isArray(value)) {
  129. bind.append(...value);
  130. } else {
  131. bind.append(value);
  132. }
  133. } else {
  134. bind[setting] = value;
  135. }
  136. })
  137. );
  138. return [fragment, binds];
  139. };
  140. };
  141. // ---- Selectors ----
  142. const rootStyle = document.querySelector(":root").style;
  143. const colorSearchResultsTarget = document.getElementById("color-results");
  144. const nameSearchResultsTarget = document.getElementById("name-results");
  145. const prevColorsSidebar = document.getElementById("prevColors");
  146. const clusterRankingTitle = document.getElementById("cls-title");
  147. const clusterMetricSection = document.getElementById("cls-metric-mount");
  148. const clusterFunctionSection = document.getElementById("cls-fn");
  149. const colorCalculateForm = document.forms.colorCalculateForm;
  150. const colorSortForm = document.forms.colorSortForm;
  151. const targetColorElements = document.forms.targetColorForm.elements;
  152. const colorDisplayElements = document.forms.colorDisplayForm.elements;
  153. const filterElements = document.forms.filterControl.elements;
  154. const nameSearchFormElements = document.forms.nameSearchForm.elements;
  155. // ---- Add Metric Selects ----
  156. const createMetricSelect = makeTemplate("metric-select-template");
  157. const [{ firstElementChild: sortMetricForm }] = createMetricSelect();
  158. const [{ firstElementChild: clusterMetricForm }] = createMetricSelect();
  159. document.getElementById("sort-metric-mount").append(sortMetricForm);
  160. sortMetricForm.elements.metricKind.value = "compare";
  161. document.getElementById("cls-metric-mount").append(clusterMetricForm);
  162. clusterMetricForm.elements.metricKind.value = "stat";
  163. const updateMetricSelects = form => {
  164. const kind = form.elements.metricKind.value;
  165. form.elements.compare.disabled = kind !== "compare";
  166. form.elements.stat.disabled = kind !== "stat";
  167. form.elements.metric.value = form.elements[kind].value;
  168. };
  169. // bit of a hack, but lets us control this all from the template
  170. const metricSymbols = Object.fromEntries(
  171. Array.from(document.querySelectorAll("option")).map(el => [
  172. el.value,
  173. el.textContent.split("(")[1].split(")")[0],
  174. ])
  175. );
  176. const updateMetricDisplays = () => {
  177. updateMetricSelects(sortMetricForm);
  178. updateMetricSelects(clusterMetricForm);
  179. colorCalculateForm.elements.sortMetricSymbolP.value =
  180. colorCalculateForm.elements.sortMetricSymbolB.value =
  181. metricSymbols[
  182. sortMetricForm.elements[sortMetricForm.elements.metricKind.value].value
  183. ];
  184. colorCalculateForm.elements.clusterMetricSymbol.value =
  185. metricSymbols[
  186. clusterMetricForm.elements[clusterMetricForm.elements.metricKind.value].value
  187. ];
  188. };
  189. // ---- Styling ----
  190. const getColorStyles = hex => {
  191. const rgb = hex2rgb(hex);
  192. const lum = vectorDot(rgb, [0.3, 0.6, 0.1]);
  193. const highlight = lum >= 128 ? "var(--color-dark)" : "var(--color-light)";
  194. return {
  195. "--highlight": highlight,
  196. "--background": hex,
  197. "--shadow-component": lum >= 128 ? "0" : "255",
  198. };
  199. };
  200. const setColorStyles = (style, hex) =>
  201. Object.entries(getColorStyles(hex)).forEach(([prop, value]) =>
  202. style.setProperty(prop, value)
  203. );
  204. // ---- Pokemon Display ----
  205. // pulled out bc the render uses them
  206. const metricScores = {};
  207. const bestClusterIndices = {};
  208. const objectiveValues = {};
  209. const createPokemonTooltip = makeTemplate("pkmn-data-template", data =>
  210. Object.fromEntries(
  211. Object.entries(data).map(([metric, value]) => [
  212. metric,
  213. {
  214. innerText: Array.isArray(value)
  215. ? value.map(v => v.toFixed(2)).join(", ")
  216. : value?.toFixed?.(3)?.replace(".000", ""),
  217. },
  218. ])
  219. )
  220. );
  221. const createPokemonTile = makeTemplate(
  222. "pkmn-tile-template",
  223. ({ name, species, color }, enableTotalFlags, enableClusterFlags) => {
  224. const formattedName = name
  225. .split("-")
  226. .map(part => part.charAt(0).toUpperCase() + part.substr(1))
  227. .join(" ");
  228. let spriteName = name
  229. .toLowerCase()
  230. .replace("'", "") // farfetchd line
  231. .replace("-gmax", "-gigantamax")
  232. .replace("-alola", "-alolan")
  233. .replace("-galar", "-galarian")
  234. .replace("-hisui", "-hisuian")
  235. .replace("-paldea", "-paldean")
  236. .replace("-paldean-combat", "-paldean") // tauros
  237. .replace("-paldean-blaze", "-paldean-fire") // tauros
  238. .replace("-paldean-aqua", "-paldean-water") // tauros
  239. .replace("-phony", "") // sinistea and polteageist
  240. .replace(". ", "-") // mr mime + rime
  241. .replace("darmanitan-galarian", "darmanitan-galarian-standard")
  242. .replace("hippopotas-m", "hippopotas")
  243. .replace("hippowdon-m", "hippowdon")
  244. .replace("unfezant-m", "unfezant")
  245. .replace("frillish-m", "frillish")
  246. .replace("jellicent-m", "jellicent")
  247. .replace("chienpao", "chien-pao")
  248. .replace("tinglu", "ting-lu")
  249. .replace("wochien", "wo-chien")
  250. .replace("chiyu", "chi-yu");
  251. if (
  252. [
  253. "flabebe",
  254. "floette",
  255. "florges",
  256. "vivillon",
  257. "basculin",
  258. "furfrou",
  259. "magearna",
  260. "alcremie",
  261. ].find(s => spriteName.includes(s))
  262. ) {
  263. spriteName = spriteName.replace(/-.*$/, "");
  264. }
  265. const imageErrorHandler2 = ({ target }) => {
  266. target.removeEventListener("error", imageErrorHandler2);
  267. target.src = `https://img.pokemondb.net/sprites/scarlet-violet/icon/${spriteName}.png`;
  268. };
  269. const imageErrorHandler1 = ({ target }) => {
  270. target.removeEventListener("error", imageErrorHandler1);
  271. target.addEventListener("error", imageErrorHandler2);
  272. target.src = `https://img.pokemondb.net/sprites/sword-shield/icon/${spriteName}.png`;
  273. };
  274. const image = {
  275. alt: formattedName,
  276. src: `https://img.pokemondb.net/sprites/sword-shield/normal/${spriteName}.png`,
  277. "@error": imageErrorHandler1,
  278. };
  279. const link = {
  280. href: `https://pokemondb.net/pokedex/${species.replace("'", "")}`,
  281. };
  282. const score = {
  283. innerText: objectiveValues[name].toFixed(2),
  284. };
  285. const { total, clusters } = metricScores[name];
  286. const buttonBinds = [
  287. [clusters[0], "cls1Btn", "cls1Data"],
  288. [clusters[1], "cls2Btn", "cls2Data"],
  289. [clusters[2], "cls3Btn", "cls3Data"],
  290. [clusters[3], "cls4Btn", "cls4Data"],
  291. [total, "totalBtn", "totalData"],
  292. ]
  293. .filter(([data]) => !!data)
  294. .map(([data, button, tooltip], index) => ({
  295. [button]: {
  296. dataset: {
  297. included: enableClusterFlags && index === bestClusterIndices[name],
  298. },
  299. hidden: false,
  300. innerText: data.hex + (data.proportion ? ` - ${data.proportion}%` : ""),
  301. "@click"() {
  302. model.setTargetColor(data.hex);
  303. },
  304. ...getColorStyles(data.hex),
  305. },
  306. [tooltip]: {
  307. append: createPokemonTooltip(data)[0],
  308. },
  309. }))
  310. .reduce((a, b) => ({ ...a, ...b }), {});
  311. buttonBinds.totalBtn.dataset.included = enableTotalFlags;
  312. return {
  313. name: {
  314. innerText: formattedName,
  315. title: formattedName,
  316. },
  317. image,
  318. color: { innerText: color },
  319. link,
  320. score,
  321. tileRoot: { dataset: { info: "hide" } },
  322. infoHover: {
  323. "@mouseenter"({ target }) {
  324. target.closest(".pkmn-tile").dataset.info = "show";
  325. },
  326. "@mouseleave"({ target }) {
  327. target.closest(".pkmn-tile").dataset.info = "hide";
  328. },
  329. },
  330. ...buttonBinds,
  331. };
  332. }
  333. );
  334. const renderPokemon = (list, target) => {
  335. target.innerText = "";
  336. const {
  337. sortUseWholeImage,
  338. sortUseBestCluster,
  339. sortUseClusterSize,
  340. sortUseInvClusterSize,
  341. sortUseTotalSize,
  342. sortUseInvTotalSize,
  343. } = Object.fromEntries(new FormData(colorCalculateForm).entries());
  344. const enableTotalFlags = !!(
  345. sortUseWholeImage ||
  346. sortUseTotalSize ||
  347. sortUseInvTotalSize
  348. );
  349. const enableClusterFlags = !!(
  350. sortUseBestCluster ||
  351. sortUseClusterSize ||
  352. sortUseInvClusterSize
  353. );
  354. target.append(
  355. ...list.map(pkmn => createPokemonTile(pkmn, enableTotalFlags, enableClusterFlags)[0])
  356. );
  357. };
  358. // ---- Calculation Logic ----
  359. const model = {
  360. setTargetColor(newColor) {
  361. const hex = `#${newColor?.replace("#", "")}`;
  362. if (hex.length !== 7) {
  363. return;
  364. }
  365. setColorStyles(rootStyle, hex);
  366. const oldColor = this.targetColor;
  367. this.targetColor = hex;
  368. targetColorElements.colorText.value = hex;
  369. targetColorElements.colorText.dataset.lastValid = hex;
  370. targetColorElements.colorPicker.value = hex;
  371. if (oldColor) {
  372. const prevButton = document.createElement("button");
  373. prevButton.innerText = oldColor;
  374. prevButton.classList = "color-select";
  375. setColorStyles(prevButton.style, oldColor);
  376. prevButton.addEventListener("click", () => this.setTargetColor(oldColor));
  377. prevColorsSidebar.prepend(prevButton);
  378. }
  379. const targetData = getColorData(hex);
  380. targetColorElements.info.value = `
  381. (${(targetData.vector[0] * 100).toFixed(2)}%,
  382. ${targetData.chroma.toFixed(4)},
  383. ${(targetData.hue * rad2deg).toFixed(1)}°)
  384. `;
  385. pokemonData.forEach(({ name, total, clusters }) => {
  386. metricScores[name] = {
  387. total: calcScores(total, targetData),
  388. clusters: clusters.map(c => calcScores(c, targetData)),
  389. };
  390. });
  391. this.calculateObjective();
  392. },
  393. calculateObjective() {
  394. const {
  395. clusterUseClusterSize,
  396. clusterUseInvClusterSize,
  397. clusterUseTotalSize,
  398. clusterUseInvTotalSize,
  399. clusterSortOrder,
  400. sortUseWholeImage,
  401. sortUseBestCluster,
  402. sortUseClusterSize,
  403. sortUseInvClusterSize,
  404. sortUseTotalSize,
  405. sortUseInvTotalSize,
  406. } = Object.fromEntries(new FormData(colorCalculateForm).entries());
  407. const clsMetric = clusterMetricForm.elements.metric.value;
  408. const getClusterScore = productLift(
  409. cluster => cluster[clsMetric],
  410. clusterUseClusterSize && (cluster => cluster.size),
  411. clusterUseInvClusterSize && (cluster => cluster.inverseSize),
  412. clusterUseTotalSize && ((_, total) => total.size),
  413. clusterUseInvTotalSize && ((_, total) => total.inverseSize)
  414. );
  415. const clsSort = sortOrders[clusterSortOrder];
  416. const getBestClusterIndex = ({ total, clusters }) =>
  417. clusters
  418. .map((c, i) => [getClusterScore(c, total), i])
  419. .reduce((a, b) => (clsSort(a[0], b[0]) > 0 ? b : a))[1];
  420. Object.entries(metricScores).forEach(([name, scores]) => {
  421. bestClusterIndices[name] = getBestClusterIndex(scores);
  422. });
  423. const metric = sortMetricForm.elements.metric.value;
  424. const getSortScore = productLift(
  425. sortUseWholeImage && (({ total }) => total[metric]),
  426. sortUseBestCluster && (({ clusters }, i) => clusters[i][metric]),
  427. sortUseClusterSize && (({ clusters }, i) => clusters[i].size),
  428. sortUseInvClusterSize && (({ clusters }, i) => clusters[i].inverseSize),
  429. sortUseTotalSize && (({ total }) => total.size),
  430. sortUseInvTotalSize && (({ total }) => total.inverseSize)
  431. );
  432. Object.entries(metricScores).forEach(([name, scores]) => {
  433. objectiveValues[name] = getSortScore(scores, bestClusterIndices[name]);
  434. });
  435. this.renderNameSearchResults();
  436. this.rank();
  437. },
  438. rank() {
  439. const { sortOrder } = Object.fromEntries(new FormData(colorSortForm).entries());
  440. const compare = sortOrders[sortOrder];
  441. const sortFn = (a, b) => compare(objectiveValues[a], objectiveValues[b]);
  442. this.ranked = pokemonData
  443. .slice(0)
  444. .sort((a, b) => sortFn(a.name, b.name) || a.name.localeCompare(b.name));
  445. this.renderColorSearchResults();
  446. },
  447. setNameSearchResults(newNameResults) {
  448. this.nameSearchResults = newNameResults;
  449. this.renderNameSearchResults();
  450. },
  451. renderNameSearchResults() {
  452. renderPokemon(this.nameSearchResults ?? [], nameSearchResultsTarget);
  453. },
  454. renderColorSearchResults() {
  455. const count = parseInt(colorDisplayElements.resultsToDisplay.value);
  456. const dexNums = new Set();
  457. const toRender = [];
  458. for (const pkmn of this.ranked) {
  459. if (toRender.length >= count) {
  460. break;
  461. }
  462. if (!filterElements.allowNoStartForms.checked && pkmn.traits.includes("nostart")) {
  463. continue;
  464. }
  465. if (!filterElements.allowRepeatDexNum.checked) {
  466. if (dexNums.has(pkmn.num)) {
  467. continue;
  468. }
  469. dexNums.add(pkmn.num);
  470. }
  471. toRender.push(pkmn);
  472. }
  473. renderPokemon(toRender, colorSearchResultsTarget);
  474. },
  475. };
  476. // ---- Form Controls ----
  477. nameSearchFormElements.input.addEventListener("input", ({ target: { value } }) => {
  478. model.setNameSearchResults(
  479. pokemonLookup.search(value, { limit: 24 }).map(({ item }) => item)
  480. );
  481. });
  482. nameSearchFormElements.clear.addEventListener("click", () => {
  483. nameSearchFormElements.input.value = "";
  484. model.setNameSearchResults([]);
  485. });
  486. nameSearchFormElements.random.addEventListener("click", () => {
  487. model.setNameSearchResults(
  488. Array.from(
  489. { length: 24 },
  490. () => pokemonData[Math.floor(Math.random() * pokemonData.length)]
  491. )
  492. );
  493. });
  494. targetColorElements.colorText.addEventListener("input", ({ target }) => {
  495. if (target.willValidate && !target.validity.valid) {
  496. target.value = target.dataset.lastValid || "";
  497. } else {
  498. model.setTargetColor(target.value);
  499. }
  500. });
  501. targetColorElements.colorPicker.addEventListener("change", ({ target }) =>
  502. model.setTargetColor(target.value)
  503. );
  504. const randomizeTargetColor = () =>
  505. model.setTargetColor(
  506. [Math.random(), Math.random(), Math.random()]
  507. .map(component =>
  508. Math.floor(component * 256)
  509. .toString(16)
  510. .padStart(2, "0")
  511. )
  512. .reduce((x, y) => x + y)
  513. );
  514. targetColorElements.randomColor.addEventListener("click", randomizeTargetColor);
  515. colorDisplayElements.resultsToDisplay.addEventListener(
  516. "input",
  517. ({ target: { value } }) => {
  518. colorDisplayElements.output.value = value;
  519. }
  520. );
  521. colorDisplayElements.resultsToDisplay.addEventListener("change", () =>
  522. model.renderColorSearchResults()
  523. );
  524. Array.from(filterElements).forEach(el =>
  525. el.addEventListener("change", () => model.renderColorSearchResults())
  526. );
  527. Array.from(colorSortForm.elements).forEach(el =>
  528. el.addEventListener("change", () => model.rank())
  529. );
  530. Array.from(colorCalculateForm.elements).forEach(el =>
  531. el.addEventListener("change", () => {
  532. const { sortUseBestCluster, sortUseClusterSize, sortUseInvClusterSize } =
  533. Object.fromEntries(new FormData(colorCalculateForm).entries());
  534. clusterRankingTitle.dataset.faded =
  535. clusterMetricSection.dataset.faded =
  536. clusterFunctionSection.dataset.faded =
  537. !(sortUseBestCluster || sortUseClusterSize || sortUseInvClusterSize);
  538. model.calculateObjective();
  539. })
  540. );
  541. sortMetricForm.addEventListener("change", () => {
  542. updateMetricDisplays();
  543. model.calculateObjective();
  544. });
  545. clusterMetricForm.addEventListener("change", () => {
  546. updateMetricDisplays();
  547. model.calculateObjective();
  548. });
  549. // ---- Initial Setup ----
  550. updateMetricDisplays();
  551. randomizeTargetColor();