main.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  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 vectorMag = v => Math.sqrt(vectorDot(v, v));
  5. // Angle Math
  6. const rad2deg = 180 / Math.PI;
  7. // Misc
  8. const productLift =
  9. (...factors) =>
  10. (...args) =>
  11. factors
  12. .filter(fn => !!fn)
  13. .map(fn => fn(...args))
  14. .reduce((x, y) => x * y, 1);
  15. // Pre-computation
  16. const getVectorDataBuilder = (toHue, toLightness, toChroma, toHex) => vector => {
  17. const sqMag = vectorDot(vector, vector);
  18. const mag = Math.sqrt(sqMag);
  19. const unit = vector.map(c => c / mag);
  20. const hue = toHue(vector);
  21. const lightness = toLightness(vector);
  22. const chroma = toChroma(vector);
  23. const hex = toHex(vector);
  24. return { vector, sqMag, mag, unit, hue, lightness, chroma, hex };
  25. };
  26. const buildVectorDataJab = getVectorDataBuilder(
  27. jab => d3.jch(d3.jab(...jab)).h || 0, // Jab -> hue
  28. ([j]) => j / 100, // Jab -> lightness
  29. jab => d3.jch(d3.jab(...jab)).C / 100, // Jab -> chroma
  30. jab => d3.jab(...jab).formatHex() // Jab -> hex
  31. );
  32. const buildVectorDataRgb = getVectorDataBuilder(
  33. rgb => d3.hsl(d3.rgb(...rgb)).h || 0, // RGB -> hue
  34. rgb => d3.hsl(d3.rgb(...rgb)).l || 0, // RGB -> lightness
  35. rgb => d3.jch(d3.rgb(...rgb)).C / 100, // RGB -> chroma
  36. rgb => d3.rgb(...rgb).formatHex() // RGB -> hex
  37. );
  38. const buildClusterData = (
  39. size,
  40. inertia,
  41. mu1,
  42. mu2,
  43. mu3,
  44. nu1,
  45. nu2,
  46. nu3,
  47. totalSize,
  48. buildVectorDataForSpace
  49. ) => {
  50. const mu = buildVectorDataForSpace([mu1, mu2, mu3]);
  51. const nu = [nu1, nu2, nu3];
  52. const muNuAngle = rad2deg * Math.acos(vectorDot(mu.unit, nu) / vectorMag(nu));
  53. const proportion = size / totalSize;
  54. const importance = // "Visual Importance"
  55. mu.chroma +
  56. Math.tanh(100 * (mu.chroma - 0.25)) + // penalty for being <25%
  57. Math.tanh(100 * (mu.chroma - 0.4)) + // penalty for being <40%
  58. mu.lightness +
  59. Math.tanh(100 * (mu.lightness - 0.5)) + // penalty for being <50%
  60. proportion +
  61. Math.tanh(100 * (proportion - 0.05)) + // penalty for being <5%
  62. Math.tanh(100 * (proportion - 0.1)) + // penalty for being <15%
  63. Math.tanh(100 * (proportion - 0.15)) + // penalty for being <15%
  64. Math.tanh(100 * (proportion - 0.25)) + // penalty for being <25%
  65. Math.tanh(100 * (proportion - 0.8)); // penalty for being <50%
  66. return {
  67. size,
  68. inverseSize: 1 / size,
  69. inertia,
  70. mu,
  71. nu,
  72. muNuAngle,
  73. proportion,
  74. inverseProportion: 1 / proportion,
  75. importance,
  76. };
  77. };
  78. const pokemonData = databaseV3.map(([name, size, ...values]) => ({
  79. name,
  80. jab: {
  81. total: buildClusterData(size, ...values.slice(0, 7), size, buildVectorDataJab),
  82. clusters: [
  83. buildClusterData(...values.slice(7, 15), size, buildVectorDataJab),
  84. buildClusterData(...values.slice(15, 23), size, buildVectorDataJab),
  85. buildClusterData(...values.slice(23, 31), size, buildVectorDataJab),
  86. buildClusterData(...values.slice(31, 39), size, buildVectorDataJab),
  87. ].filter(c => c.size !== 0),
  88. },
  89. rgb: {
  90. total: buildClusterData(size, ...values.slice(39, 46), size, buildVectorDataRgb),
  91. clusters: [
  92. buildClusterData(...values.slice(46, 54), size, buildVectorDataRgb),
  93. buildClusterData(...values.slice(54, 62), size, buildVectorDataRgb),
  94. buildClusterData(...values.slice(62, 70), size, buildVectorDataRgb),
  95. buildClusterData(...values.slice(70, 78), size, buildVectorDataRgb),
  96. ].filter(c => c.size !== 0),
  97. },
  98. }));
  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. // ---- Styling ----
  135. const rootStyle = document.querySelector(":root").style;
  136. const setColorStyles = (style, hex) => {
  137. const { r, g, b } = d3.color(hex);
  138. const highlight =
  139. vectorDot([r, g, b], [0.3, 0.6, 0.1]) >= 128
  140. ? "var(--color-dark)"
  141. : "var(--color-light)";
  142. style.setProperty("--highlight", highlight);
  143. style.setProperty("--background", hex);
  144. style.setProperty("--shadow-component", highlight.includes("light") ? "255" : "0");
  145. };
  146. // ---- Pokemon Display ----
  147. // pulled out bc the render uses them
  148. const metricScores = {};
  149. const bestClusterIndices = {};
  150. const objectiveValues = {};
  151. const pokemonTileTemplate = document.getElementById("pkmn-tile-template").content;
  152. const pokemonDataTemplate = document.getElementById("pkmn-data-template").content;
  153. const loadTemplateWithBinds = content => {
  154. const fragment = content.cloneNode(true);
  155. const binds = Object.fromEntries(
  156. Array.from(fragment.querySelectorAll("[bind-to]")).map(element => {
  157. const name = element.getAttribute("bind-to");
  158. element.removeAttribute("bind-to");
  159. return [name, element];
  160. })
  161. );
  162. return [fragment, binds];
  163. };
  164. const getSpriteName = (() => {
  165. const stripForm = [
  166. "flabebe",
  167. "floette",
  168. "florges",
  169. "vivillon",
  170. "basculin",
  171. "furfrou",
  172. "magearna",
  173. "alcremie",
  174. ];
  175. return pokemon => {
  176. pokemon = pokemon
  177. .replace("-alola", "-alolan")
  178. .replace("-galar", "-galarian")
  179. .replace("-hisui", "-hisuian")
  180. .replace("-paldea", "-paldean")
  181. .replace("-paldeanfire", "-paldean-fire") // tauros
  182. .replace("-paldeanwater", "-paldean-water") // tauros
  183. .replace("-phony", "") // sinistea and polteageist
  184. .replace("darmanitan-galarian", "darmanitan-galarian-standard")
  185. .replace("chienpao", "chien-pao")
  186. .replace("tinglu", "ting-lu")
  187. .replace("wochien", "wo-chien")
  188. .replace("chiyu", "chi-yu");
  189. if (stripForm.find(s => pokemon.includes(s))) {
  190. pokemon = pokemon.replace(/-.*$/, "");
  191. }
  192. return pokemon;
  193. };
  194. })();
  195. const renderPokemon = (list, target) => {
  196. target.innerText = "";
  197. const {
  198. sortUseWholeImage,
  199. sortUseBestCluster,
  200. sortUseClusterSize,
  201. sortUseInvClusterSize,
  202. sortUseTotalSize,
  203. sortUseInvTotalSize,
  204. } = Object.fromEntries(new FormData(document.forms.colorCalculateForm).entries());
  205. const enableTotalFlags = !!(
  206. sortUseWholeImage ||
  207. sortUseTotalSize ||
  208. sortUseInvTotalSize
  209. );
  210. const enableClusterFlags = !!(
  211. sortUseBestCluster ||
  212. sortUseClusterSize ||
  213. sortUseInvClusterSize
  214. );
  215. list.forEach(pkmnName => {
  216. const [tile, { image, name, score, ...binds }] =
  217. loadTemplateWithBinds(pokemonTileTemplate);
  218. const spriteName = getSpriteName(pkmnName);
  219. const imageErrHandler = () => {
  220. image.removeEventListener("error", imageErrHandler);
  221. image.src = `https://img.pokemondb.net/sprites/scarlet-violet/icon/${spriteName}.png`;
  222. };
  223. image.addEventListener("error", imageErrHandler);
  224. image.src = `https://img.pokemondb.net/sprites/sword-shield/icon/${spriteName}.png`;
  225. name.innerText =
  226. name.title =
  227. image.alt =
  228. pkmnName
  229. .split("-")
  230. .map(part => part.charAt(0).toUpperCase() + part.substr(1))
  231. .join(" ");
  232. const colorSpace = document.forms.colorSortForm.elements.colorSpace.value;
  233. score.innerText = objectiveValues[pkmnName][colorSpace].toFixed(2);
  234. const { total, clusters } = metricScores[pkmnName][colorSpace];
  235. [
  236. [clusters[0], binds.cls1Btn, binds.cls1Data],
  237. [clusters[1], binds.cls2Btn, binds.cls2Data],
  238. [clusters[2], binds.cls3Btn, binds.cls3Data],
  239. [clusters[3], binds.cls4Btn, binds.cls4Data],
  240. [total, binds.totalBtn, binds.totalData],
  241. ]
  242. .filter(([data]) => !!data)
  243. .forEach(([data, button, dataTile], index) => {
  244. button.hidden = false;
  245. button.innerText = data.muHex;
  246. button.dataset.included =
  247. enableClusterFlags && index === bestClusterIndices[pkmnName][colorSpace];
  248. setColorStyles(button.style, data.muHex);
  249. button.addEventListener("click", () => {
  250. model.setTargetColor(data.muHex);
  251. });
  252. const [tooltip, tooltipBinds] = loadTemplateWithBinds(pokemonDataTemplate);
  253. Object.entries(tooltipBinds).forEach(([metricName, target]) => {
  254. target.innerText = data[metricName].toFixed(2).replace(".00", "");
  255. });
  256. dataTile.append(tooltip);
  257. });
  258. binds.totalBtn.dataset.included = enableTotalFlags;
  259. target.append(tile);
  260. });
  261. };
  262. const colorSearchResultsTarget = document.getElementById("color-results");
  263. const nameSearchResultsTarget = document.getElementById("name-results");
  264. // ---- Name Lookup ----
  265. const lookupLimit = 24;
  266. const pokemonLookup = new Fuse(pokemonData, { keys: ["name"] });
  267. const nameSearchResults = [];
  268. const renderNameSearchResults = () => {
  269. renderPokemon(nameSearchResults, nameSearchResultsTarget);
  270. };
  271. document.forms.nameSearchForm.elements.input.addEventListener(
  272. "input",
  273. ({ target: { value } }) => {
  274. nameSearchResults.splice(
  275. 0,
  276. Infinity,
  277. ...pokemonLookup
  278. .search(value, { limit: lookupLimit })
  279. .map(({ item: { name } }) => name)
  280. );
  281. renderNameSearchResults();
  282. }
  283. );
  284. document.forms.nameSearchForm.elements.clear.addEventListener("click", () => {
  285. nameSearchResults.splice(0);
  286. document.forms.nameSearchForm.elements.input.value = "";
  287. renderNameSearchResults();
  288. });
  289. document.forms.nameSearchForm.elements.random.addEventListener("click", () => {
  290. nameSearchResults.splice(
  291. 0,
  292. Infinity,
  293. ...Array.from(
  294. { length: lookupLimit },
  295. () => pokemonData[Math.floor(Math.random() * pokemonData.length)].name
  296. )
  297. );
  298. renderNameSearchResults();
  299. });
  300. // ---- Calculation Logic ----
  301. const model = new (class {
  302. #targetColor = "";
  303. ranked = [];
  304. setTargetColor(newColor) {
  305. const hex = `#${newColor?.replace("#", "")}`;
  306. if (hex.length !== 7) {
  307. return;
  308. }
  309. setColorStyles(rootStyle, hex);
  310. const oldColor = this.#targetColor;
  311. this.#targetColor = hex;
  312. document.forms.targetColorForm.elements.colorText.value = hex;
  313. document.forms.targetColorForm.elements.colorText.dataset.lastValid = hex;
  314. document.forms.targetColorForm.elements.colorPicker.value = hex;
  315. if (oldColor) {
  316. const prevButton = document.createElement("button");
  317. prevButton.innerText = oldColor;
  318. prevButton.classList = "color-select";
  319. setColorStyles(prevButton.style, oldColor);
  320. prevButton.addEventListener("click", () => this.setTargetColor(oldColor));
  321. document.getElementById("prevColors").prepend(prevButton);
  322. }
  323. const rgb = d3.rgb(hex);
  324. const { J, a, b } = d3.jab(rgb);
  325. const targetJab = buildVectorDataJab([J, a, b]);
  326. const targetRgb = buildVectorDataRgb([rgb.r, rgb.g, rgb.b]);
  327. pokemonData.forEach(({ name, jab, rgb }) => {
  328. metricScores[name] = {
  329. jab: {
  330. total: calcScores(jab.total, targetJab),
  331. clusters: jab.clusters.map(c => calcScores(c, targetJab)),
  332. },
  333. rgb: {
  334. total: calcScores(rgb.total, targetRgb),
  335. clusters: rgb.clusters.map(c => calcScores(c, targetRgb)),
  336. },
  337. };
  338. });
  339. this.calculateObjective();
  340. }
  341. calculateObjective() {
  342. const {
  343. clusterUseClusterSize,
  344. clusterUseInvClusterSize,
  345. clusterUseTotalSize,
  346. clusterUseInvTotalSize,
  347. clusterSortOrder,
  348. sortUseWholeImage,
  349. sortUseBestCluster,
  350. sortUseClusterSize,
  351. sortUseInvClusterSize,
  352. sortUseTotalSize,
  353. sortUseInvTotalSize,
  354. } = Object.fromEntries(new FormData(document.forms.colorCalculateForm).entries());
  355. const clsMetric = document.forms.clusterMetricForm.elements.metric.value;
  356. const getClusterScore = productLift(
  357. cluster => cluster[clsMetric],
  358. clusterUseClusterSize && (cluster => cluster.size),
  359. clusterUseInvClusterSize && (cluster => cluster.inverseSize),
  360. clusterUseTotalSize && ((_, total) => total.size),
  361. clusterUseInvTotalSize && ((_, total) => total.inverseSize)
  362. );
  363. const clsSort = sortOrders[clusterSortOrder];
  364. const getBestClusterIndex = ({ total, clusters }) =>
  365. clusters
  366. .map((c, i) => [getClusterScore(c, total), i])
  367. .reduce((a, b) => (clsSort(a[0], b[0]) > 0 ? b : a))[1];
  368. Object.entries(metricScores).forEach(([name, { jab, rgb }]) => {
  369. bestClusterIndices[name] = {
  370. jab: getBestClusterIndex(jab),
  371. rgb: getBestClusterIndex(rgb),
  372. };
  373. });
  374. const metric = document.forms.sortMetricForm.elements.metric.value;
  375. const getSortScore = productLift(
  376. sortUseWholeImage && (({ total }) => total[metric]),
  377. sortUseBestCluster && (({ clusters }, i) => clusters[i][metric]),
  378. sortUseClusterSize && (({ clusters }, i) => clusters[i].size),
  379. sortUseInvClusterSize && (({ clusters }, i) => clusters[i].inverseSize),
  380. sortUseTotalSize && (({ total }) => total.size),
  381. sortUseInvTotalSize && (({ total }) => total.inverseSize)
  382. );
  383. Object.entries(metricScores).forEach(([name, { jab, rgb }]) => {
  384. objectiveValues[name] = {
  385. jab: getSortScore(jab, bestClusterIndices[name].jab),
  386. rgb: getSortScore(rgb, bestClusterIndices[name].rgb),
  387. };
  388. });
  389. renderNameSearchResults();
  390. this.rank();
  391. }
  392. rank() {
  393. const { colorSpace, sortOrder } = Object.fromEntries(
  394. new FormData(document.forms.colorSortForm).entries()
  395. );
  396. const compare = sortOrders[sortOrder];
  397. const sortFn = (a, b) =>
  398. compare(objectiveValues[a][colorSpace], objectiveValues[b][colorSpace]);
  399. this.ranked = pokemonData
  400. .map(({ name }) => name)
  401. .sort((a, b) => sortFn(a, b) || a.localeCompare(b));
  402. this.renderColorSearchResults();
  403. }
  404. renderColorSearchResults() {
  405. renderPokemon(
  406. this.ranked.slice(
  407. 0,
  408. parseInt(document.forms.colorDisplayForm.elements.resultsToDisplay.value)
  409. ),
  410. colorSearchResultsTarget
  411. );
  412. }
  413. })();
  414. // ---- Form Controls ----
  415. document.forms.targetColorForm.elements.colorText.addEventListener(
  416. "input",
  417. ({ target }) => {
  418. if (target.willValidate && !target.validity.valid) {
  419. target.value = target.dataset.lastValid || "";
  420. } else {
  421. model.setTargetColor(target.value);
  422. }
  423. }
  424. );
  425. document.forms.targetColorForm.elements.colorPicker.addEventListener(
  426. "change",
  427. ({ target }) => model.setTargetColor(target.value)
  428. );
  429. const randomizeTargetColor = () =>
  430. model.setTargetColor(
  431. d3.hsl(Math.random() * 360, Math.random(), Math.random()).formatHex()
  432. );
  433. document.forms.targetColorForm.elements.randomColor.addEventListener(
  434. "click",
  435. randomizeTargetColor
  436. );
  437. document.forms.colorDisplayForm.elements.resultsToDisplay.addEventListener(
  438. "input",
  439. ({ target: { value } }) => {
  440. document.forms.colorDisplayForm.elements.output.value = value;
  441. }
  442. );
  443. document.forms.colorDisplayForm.elements.resultsToDisplay.addEventListener("change", () =>
  444. model.renderColorSearchResults()
  445. );
  446. Array.from(document.forms.colorSortForm.elements).forEach(el =>
  447. el.addEventListener("change", () => model.rank())
  448. );
  449. const clusterRankingTitle = document.getElementById("cls-title");
  450. const clusterMetricSection = document.getElementById("cls-metric-mount");
  451. const clusterFunctionSection = document.getElementById("cls-fn");
  452. Array.from(document.forms.colorCalculateForm.elements).forEach(el =>
  453. el.addEventListener("change", () => {
  454. const { sortUseBestCluster, sortUseClusterSize, sortUseInvClusterSize } =
  455. Object.fromEntries(new FormData(document.forms.colorCalculateForm).entries());
  456. clusterRankingTitle.dataset.faded =
  457. clusterMetricSection.dataset.faded =
  458. clusterFunctionSection.dataset.faded =
  459. !(sortUseBestCluster || sortUseClusterSize || sortUseInvClusterSize);
  460. model.calculateObjective();
  461. })
  462. );
  463. // ---- Add Metric Selections ----
  464. const metricSelectTemplate = document.getElementById("metric-select-template").content;
  465. const sortMetricForm = metricSelectTemplate.cloneNode(true).firstElementChild;
  466. sortMetricForm.id = "sortMetricForm";
  467. const clusterMetricForm = metricSelectTemplate.cloneNode(true).firstElementChild;
  468. clusterMetricForm.id = "clusterMetricForm";
  469. document.getElementById("sort-metric-mount").append(sortMetricForm);
  470. document.getElementById("cls-metric-mount").append(clusterMetricForm);
  471. document.forms.sortMetricForm.elements.metricKind.value = "whole";
  472. document.forms.clusterMetricForm.elements.metricKind.value = "stat";
  473. const updateMetricSelects = form => {
  474. const kind = form.elements.metricKind.value;
  475. form.elements.whole.disabled = kind !== "whole";
  476. form.elements.mean.disabled = kind !== "mean";
  477. form.elements.stat.disabled = kind !== "stat";
  478. form.elements.metric.value = form.elements[kind].value;
  479. };
  480. const getMetricSymbol = metricName =>
  481. // terrible hack
  482. document.querySelector(`option[value=${metricName}]`).textContent.at(-2);
  483. const onMetricChange = () => {
  484. updateMetricSelects(document.forms.sortMetricForm);
  485. updateMetricSelects(document.forms.clusterMetricForm);
  486. document.forms.colorCalculateForm.elements.sortMetricSymbolP.value =
  487. document.forms.colorCalculateForm.elements.sortMetricSymbolB.value = getMetricSymbol(
  488. document.forms.sortMetricForm.elements[
  489. document.forms.sortMetricForm.elements.metricKind.value
  490. ].value
  491. );
  492. document.forms.colorCalculateForm.elements.clusterMetricSymbol.value = getMetricSymbol(
  493. document.forms.clusterMetricForm.elements[
  494. document.forms.clusterMetricForm.elements.metricKind.value
  495. ].value
  496. );
  497. };
  498. onMetricChange();
  499. document.forms.sortMetricForm.addEventListener("change", () => {
  500. onMetricChange();
  501. model.calculateObjective();
  502. });
  503. document.forms.clusterMetricForm.addEventListener("change", () => {
  504. onMetricChange();
  505. model.calculateObjective();
  506. });
  507. // ---- Pick Starting Color ----
  508. randomizeTargetColor();