main.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
  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 pokemonLookup = new Fuse(pokemonData, { keys: ["name"] });
  100. const calcScores = (data, target) => {
  101. const sigma = Math.sqrt(
  102. data.inertia - 2 * vectorDot(data.mu.vector, target.vector) + target.sqMag
  103. );
  104. const bigTheta = 1 - vectorDot(data.nu, target.unit);
  105. const rawPhi = Math.abs(data.mu.hue - target.hue);
  106. return {
  107. sigma,
  108. bigTheta,
  109. alpha: sigma * Math.pow(bigTheta, target.chroma + target.lightness),
  110. theta: rad2deg * Math.acos(vectorDot(data.mu.unit, target.unit)),
  111. phi: Math.min(rawPhi, 360 - rawPhi),
  112. delta: vectorMag(data.mu.vector.map((x, i) => x - target.vector[i])),
  113. manhattan: data.mu.vector
  114. .map((x, i) => Math.abs(x - target.vector[i]))
  115. .reduce((x, y) => x + y),
  116. ch: Math.max(...data.mu.vector.map((x, i) => Math.abs(x - target.vector[i]))),
  117. lightnessDiff: Math.abs(data.mu.lightness - target.lightness),
  118. inertia: data.inertia,
  119. variance: data.inertia - data.mu.sqMag,
  120. muNuAngle: data.muNuAngle,
  121. size: data.size,
  122. lightness: data.mu.lightness,
  123. chroma: data.mu.chroma,
  124. importance: data.importance,
  125. inverseSize: data.inverseSize,
  126. proportion: data.proportion,
  127. inverseProportion: data.inverseProportion,
  128. muHex: data.mu.hex,
  129. };
  130. };
  131. const sortOrders = {
  132. max: (a, b) => b - a,
  133. min: (a, b) => a - b,
  134. };
  135. // who needs a framework?
  136. const makeTemplate = (id, definition = () => ({})) => {
  137. const content = document.getElementById(id).content;
  138. return (...args) => {
  139. const fragment = content.cloneNode(true);
  140. const binds = Object.fromEntries(
  141. Array.from(fragment.querySelectorAll("[bind-to]")).map(element => {
  142. const name = element.getAttribute("bind-to");
  143. element.removeAttribute("bind-to");
  144. return [name, element];
  145. })
  146. );
  147. Object.entries(definition(...args))
  148. .map(([name, settings]) => [binds[name], settings])
  149. .filter(([bind]) => !!bind)
  150. .forEach(([bind, settings]) =>
  151. Object.entries(settings).forEach(([setting, value]) => {
  152. if (setting.startsWith("@")) {
  153. bind.addEventListener(setting.slice(1), value);
  154. } else if (setting.startsWith("--")) {
  155. bind.style.setProperty(setting, value);
  156. } else if (setting === "dataset") {
  157. Object.entries(value).forEach(([key, data]) => (bind.dataset[key] = data));
  158. } else if (setting === "append") {
  159. if (Array.isArray(value)) {
  160. bind.append(...value);
  161. } else {
  162. bind.append(value);
  163. }
  164. } else {
  165. bind[setting] = value;
  166. }
  167. })
  168. );
  169. return [fragment, binds];
  170. };
  171. };
  172. // ---- Selectors ----
  173. const rootStyle = document.querySelector(":root").style;
  174. const colorSearchResultsTarget = document.getElementById("color-results");
  175. const nameSearchResultsTarget = document.getElementById("name-results");
  176. const prevColorsSidebar = document.getElementById("prevColors");
  177. const clusterRankingTitle = document.getElementById("cls-title");
  178. const clusterMetricSection = document.getElementById("cls-metric-mount");
  179. const clusterFunctionSection = document.getElementById("cls-fn");
  180. const colorCalculateForm = document.forms.colorCalculateForm;
  181. const colorSortForm = document.forms.colorSortForm;
  182. const targetColorElements = document.forms.targetColorForm.elements;
  183. const colorDisplayElements = document.forms.colorDisplayForm.elements;
  184. const nameSearchFormElements = document.forms.nameSearchForm.elements;
  185. // ---- Add Metric Selects ----
  186. const createMetricSelect = makeTemplate("metric-select-template");
  187. const [{ firstElementChild: sortMetricForm }] = createMetricSelect();
  188. const [{ firstElementChild: clusterMetricForm }] = createMetricSelect();
  189. document.getElementById("sort-metric-mount").append(sortMetricForm);
  190. sortMetricForm.elements.metricKind.value = "whole";
  191. document.getElementById("cls-metric-mount").append(clusterMetricForm);
  192. clusterMetricForm.elements.metricKind.value = "stat";
  193. const updateMetricSelects = form => {
  194. const kind = form.elements.metricKind.value;
  195. form.elements.whole.disabled = kind !== "whole";
  196. form.elements.mean.disabled = kind !== "mean";
  197. form.elements.stat.disabled = kind !== "stat";
  198. form.elements.metric.value = form.elements[kind].value;
  199. };
  200. // bit of a hack, but lets us control this all from the template
  201. const metricSymbols = Object.fromEntries(
  202. Array.from(document.querySelectorAll("option")).map(el => [
  203. el.value,
  204. el.textContent.at(-2),
  205. ])
  206. );
  207. const updateMetricDisplays = () => {
  208. updateMetricSelects(sortMetricForm);
  209. updateMetricSelects(clusterMetricForm);
  210. colorCalculateForm.elements.sortMetricSymbolP.value =
  211. colorCalculateForm.elements.sortMetricSymbolB.value =
  212. metricSymbols[
  213. sortMetricForm.elements[sortMetricForm.elements.metricKind.value].value
  214. ];
  215. colorCalculateForm.elements.clusterMetricSymbol.value =
  216. metricSymbols[
  217. clusterMetricForm.elements[clusterMetricForm.elements.metricKind.value].value
  218. ];
  219. };
  220. // ---- Styling ----
  221. const getColorStyles = hex => {
  222. const { r, g, b } = d3.color(hex);
  223. const highlight =
  224. vectorDot([r, g, b], [0.3, 0.6, 0.1]) >= 128
  225. ? "var(--color-dark)"
  226. : "var(--color-light)";
  227. return {
  228. "--highlight": highlight,
  229. "--background": hex,
  230. "--shadow-component": highlight.includes("light") ? "255" : "0",
  231. };
  232. };
  233. const setColorStyles = (style, hex) =>
  234. Object.entries(getColorStyles(hex)).forEach(([prop, value]) =>
  235. style.setProperty(prop, value)
  236. );
  237. // ---- Pokemon Display ----
  238. // pulled out bc the render uses them
  239. const metricScores = {};
  240. const bestClusterIndices = {};
  241. const objectiveValues = {};
  242. const createPokemonTooltip = makeTemplate("pkmn-data-template", data =>
  243. Object.fromEntries(
  244. Object.entries(data).map(([metric, value]) => [
  245. metric,
  246. { innerText: value.toFixed?.(2)?.replace(".00", "") },
  247. ])
  248. )
  249. );
  250. const createPokemonTile = makeTemplate(
  251. "pkmn-tile-template",
  252. (pkmnName, colorSpace, enableTotalFlags, enableClusterFlags) => {
  253. const formattedName = pkmnName
  254. .split("-")
  255. .map(part => part.charAt(0).toUpperCase() + part.substr(1))
  256. .join(" ");
  257. const name = {
  258. innerText: formattedName,
  259. title: formattedName,
  260. };
  261. let spriteName = pkmnName
  262. .replace("-alola", "-alolan")
  263. .replace("-galar", "-galarian")
  264. .replace("-hisui", "-hisuian")
  265. .replace("-paldea", "-paldean")
  266. .replace("-paldeanfire", "-paldean-fire") // tauros
  267. .replace("-paldeanwater", "-paldean-water") // tauros
  268. .replace("-phony", "") // sinistea and polteageist
  269. .replace("darmanitan-galarian", "darmanitan-galarian-standard")
  270. .replace("chienpao", "chien-pao")
  271. .replace("tinglu", "ting-lu")
  272. .replace("wochien", "wo-chien")
  273. .replace("chiyu", "chi-yu");
  274. if (
  275. [
  276. "flabebe",
  277. "floette",
  278. "florges",
  279. "vivillon",
  280. "basculin",
  281. "furfrou",
  282. "magearna",
  283. "alcremie",
  284. ].find(s => spriteName.includes(s))
  285. ) {
  286. spriteName = spriteName.replace(/-.*$/, "");
  287. }
  288. const imageErrorHandler = ({ target }) => {
  289. target.removeEventListener("error", imageErrorHandler);
  290. target.src = `https://img.pokemondb.net/sprites/scarlet-violet/icon/${spriteName}.png`;
  291. };
  292. const image = {
  293. alt: formattedName,
  294. src: `https://img.pokemondb.net/sprites/sword-shield/icon/${spriteName}.png`,
  295. "@error": imageErrorHandler,
  296. };
  297. const score = {
  298. innerText: objectiveValues[pkmnName][colorSpace].toFixed(2),
  299. };
  300. const { total, clusters } = metricScores[pkmnName][colorSpace];
  301. const buttonBinds = [
  302. [clusters[0], "cls1Btn", "cls1Data"],
  303. [clusters[1], "cls2Btn", "cls2Data"],
  304. [clusters[2], "cls3Btn", "cls3Data"],
  305. [clusters[3], "cls4Btn", "cls4Data"],
  306. [total, "totalBtn", "totalData"],
  307. ]
  308. .filter(([data]) => !!data)
  309. .map(([data, button, tooltip], index) => {
  310. return {
  311. [button]: {
  312. dataset: {
  313. included:
  314. enableClusterFlags && index === bestClusterIndices[pkmnName][colorSpace],
  315. },
  316. hidden: false,
  317. innerText: data.muHex,
  318. "@click"() {
  319. model.setTargetColor(data.muHex);
  320. },
  321. ...getColorStyles(data.muHex),
  322. },
  323. [tooltip]: {
  324. append: createPokemonTooltip(data)[0],
  325. },
  326. };
  327. })
  328. .reduce((a, b) => ({ ...a, ...b }), {});
  329. buttonBinds.totalBtn.dataset.included = enableTotalFlags;
  330. return { name, image, score, ...buttonBinds };
  331. }
  332. );
  333. const renderPokemon = (list, target) => {
  334. target.innerText = "";
  335. const colorSpace = colorSortForm.elements.colorSpace.value;
  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(
  356. name => createPokemonTile(name, colorSpace, enableTotalFlags, enableClusterFlags)[0]
  357. )
  358. );
  359. };
  360. // ---- Calculation Logic ----
  361. const model = {
  362. setTargetColor(newColor) {
  363. const hex = `#${newColor?.replace("#", "")}`;
  364. if (hex.length !== 7) {
  365. return;
  366. }
  367. setColorStyles(rootStyle, hex);
  368. const oldColor = this.targetColor;
  369. this.targetColor = hex;
  370. targetColorElements.colorText.value = hex;
  371. targetColorElements.colorText.dataset.lastValid = hex;
  372. targetColorElements.colorPicker.value = hex;
  373. if (oldColor) {
  374. const prevButton = document.createElement("button");
  375. prevButton.innerText = oldColor;
  376. prevButton.classList = "color-select";
  377. setColorStyles(prevButton.style, oldColor);
  378. prevButton.addEventListener("click", () => this.setTargetColor(oldColor));
  379. prevColorsSidebar.prepend(prevButton);
  380. }
  381. const rgb = d3.rgb(hex);
  382. const { J, a, b } = d3.jab(rgb);
  383. const targetJab = buildVectorDataJab([J, a, b]);
  384. const targetRgb = buildVectorDataRgb([rgb.r, rgb.g, rgb.b]);
  385. pokemonData.forEach(({ name, jab, rgb }) => {
  386. metricScores[name] = {
  387. jab: {
  388. total: calcScores(jab.total, targetJab),
  389. clusters: jab.clusters.map(c => calcScores(c, targetJab)),
  390. },
  391. rgb: {
  392. total: calcScores(rgb.total, targetRgb),
  393. clusters: rgb.clusters.map(c => calcScores(c, targetRgb)),
  394. },
  395. };
  396. });
  397. this.calculateObjective();
  398. },
  399. calculateObjective() {
  400. const {
  401. clusterUseClusterSize,
  402. clusterUseInvClusterSize,
  403. clusterUseTotalSize,
  404. clusterUseInvTotalSize,
  405. clusterSortOrder,
  406. sortUseWholeImage,
  407. sortUseBestCluster,
  408. sortUseClusterSize,
  409. sortUseInvClusterSize,
  410. sortUseTotalSize,
  411. sortUseInvTotalSize,
  412. } = Object.fromEntries(new FormData(colorCalculateForm).entries());
  413. const clsMetric = clusterMetricForm.elements.metric.value;
  414. const getClusterScore = productLift(
  415. cluster => cluster[clsMetric],
  416. clusterUseClusterSize && (cluster => cluster.size),
  417. clusterUseInvClusterSize && (cluster => cluster.inverseSize),
  418. clusterUseTotalSize && ((_, total) => total.size),
  419. clusterUseInvTotalSize && ((_, total) => total.inverseSize)
  420. );
  421. const clsSort = sortOrders[clusterSortOrder];
  422. const getBestClusterIndex = ({ total, clusters }) =>
  423. clusters
  424. .map((c, i) => [getClusterScore(c, total), i])
  425. .reduce((a, b) => (clsSort(a[0], b[0]) > 0 ? b : a))[1];
  426. Object.entries(metricScores).forEach(([name, { jab, rgb }]) => {
  427. bestClusterIndices[name] = {
  428. jab: getBestClusterIndex(jab),
  429. rgb: getBestClusterIndex(rgb),
  430. };
  431. });
  432. const metric = sortMetricForm.elements.metric.value;
  433. const getSortScore = productLift(
  434. sortUseWholeImage && (({ total }) => total[metric]),
  435. sortUseBestCluster && (({ clusters }, i) => clusters[i][metric]),
  436. sortUseClusterSize && (({ clusters }, i) => clusters[i].size),
  437. sortUseInvClusterSize && (({ clusters }, i) => clusters[i].inverseSize),
  438. sortUseTotalSize && (({ total }) => total.size),
  439. sortUseInvTotalSize && (({ total }) => total.inverseSize)
  440. );
  441. Object.entries(metricScores).forEach(([name, { jab, rgb }]) => {
  442. objectiveValues[name] = {
  443. jab: getSortScore(jab, bestClusterIndices[name].jab),
  444. rgb: getSortScore(rgb, bestClusterIndices[name].rgb),
  445. };
  446. });
  447. this.renderNameSearchResults();
  448. this.rank();
  449. },
  450. rank() {
  451. const { colorSpace, sortOrder } = Object.fromEntries(
  452. new FormData(colorSortForm).entries()
  453. );
  454. const compare = sortOrders[sortOrder];
  455. const sortFn = (a, b) =>
  456. compare(objectiveValues[a][colorSpace], objectiveValues[b][colorSpace]);
  457. this.ranked = pokemonData
  458. .map(({ name }) => name)
  459. .sort((a, b) => sortFn(a, b) || a.localeCompare(b));
  460. this.renderColorSearchResults();
  461. },
  462. setNameSearchResults(newNameResults) {
  463. this.nameSearchResults = newNameResults;
  464. this.renderNameSearchResults();
  465. },
  466. renderNameSearchResults() {
  467. renderPokemon(this.nameSearchResults ?? [], nameSearchResultsTarget);
  468. },
  469. renderColorSearchResults() {
  470. renderPokemon(
  471. this.ranked.slice(0, parseInt(colorDisplayElements.resultsToDisplay.value)),
  472. colorSearchResultsTarget
  473. );
  474. },
  475. };
  476. // ---- Form Controls ----
  477. nameSearchFormElements.input.addEventListener("input", ({ target: { value } }) => {
  478. model.setNameSearchResults(
  479. pokemonLookup.search(value, { limit: 24 }).map(({ item: { name } }) => name)
  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)].name
  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. d3.hsl(Math.random() * 360, Math.random(), Math.random()).formatHex()
  507. );
  508. targetColorElements.randomColor.addEventListener("click", randomizeTargetColor);
  509. colorDisplayElements.resultsToDisplay.addEventListener(
  510. "input",
  511. ({ target: { value } }) => {
  512. colorDisplayElements.output.value = value;
  513. }
  514. );
  515. colorDisplayElements.resultsToDisplay.addEventListener("change", () =>
  516. model.renderColorSearchResults()
  517. );
  518. Array.from(colorSortForm.elements).forEach(el =>
  519. el.addEventListener("change", () => model.rank())
  520. );
  521. Array.from(colorCalculateForm.elements).forEach(el =>
  522. el.addEventListener("change", () => {
  523. const { sortUseBestCluster, sortUseClusterSize, sortUseInvClusterSize } =
  524. Object.fromEntries(new FormData(colorCalculateForm).entries());
  525. clusterRankingTitle.dataset.faded =
  526. clusterMetricSection.dataset.faded =
  527. clusterFunctionSection.dataset.faded =
  528. !(sortUseBestCluster || sortUseClusterSize || sortUseInvClusterSize);
  529. model.calculateObjective();
  530. })
  531. );
  532. sortMetricForm.addEventListener("change", () => {
  533. updateMetricDisplays();
  534. model.calculateObjective();
  535. });
  536. clusterMetricForm.addEventListener("change", () => {
  537. updateMetricDisplays();
  538. model.calculateObjective();
  539. });
  540. // ---- Initial Setup ----
  541. updateMetricDisplays();
  542. randomizeTargetColor();