main.js 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  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 clamp = (mn, v, mx) => Math.min(Math.max(v, mn), mx);
  9. const productLift =
  10. (...factors) =>
  11. (...args) =>
  12. factors
  13. .filter((fn) => !!fn)
  14. .map((fn) => fn(...args))
  15. .reduce((x, y) => x * y, 1);
  16. const mapValues = (obj, fn) =>
  17. Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, fn(value, key)]));
  18. // Contrast + Shadow + Hover Colors
  19. const getContrastingTextColor = (hex) => {
  20. const { r, g, b } = d3.color(hex);
  21. return vectorDot([r, g, b], [0.3, 0.6, 0.1]) >= 128
  22. ? "var(--color-dark)"
  23. : "var(--color-light)";
  24. };
  25. // "Visual Importance"
  26. const calcImportance = (chroma, lightness, proportion) =>
  27. chroma +
  28. Math.tanh(100 * (chroma - 0.25)) + // penalty for being <25%
  29. Math.tanh(100 * (chroma - 0.4)) + // penalty for being <40%
  30. lightness +
  31. Math.tanh(100 * (lightness - 0.5)) + // penalty for being <50%
  32. proportion +
  33. Math.tanh(100 * (proportion - 0.05)) + // penalty for being <5%
  34. Math.tanh(100 * (proportion - 0.1)) + // penalty for being <15%
  35. Math.tanh(100 * (proportion - 0.15)) + // penalty for being <15%
  36. Math.tanh(100 * (proportion - 0.25)) + // penalty for being <25%
  37. Math.tanh(100 * (proportion - 0.8)); // penalty for being <50%
  38. // Conversions
  39. const jab2hex = (jab) => d3.jab(...jab).formatHex();
  40. const rgb2hex = (rgb) => d3.rgb(...rgb).formatHex();
  41. const jab2hue = (jab) => d3.jch(d3.jab(...jab)).h || 0;
  42. const rgb2hue = (rgb) => d3.hsl(d3.rgb(...rgb)).h || 0;
  43. const jab2lit = ([j]) => j / 100;
  44. const rgb2lit = (rgb) => d3.hsl(d3.rgb(...rgb)).l || 0;
  45. const jab2chroma = (jab) => d3.jch(d3.jab(...jab)).C / 100;
  46. const rgb2chroma = (rgb) => d3.jch(d3.rgb(...rgb)).C / 100;
  47. // Pre-computation
  48. const buildVectorData = (vector, toHue, toLightness, toChroma, toHex) => {
  49. const sqMag = vectorDot(vector, vector);
  50. const mag = Math.sqrt(sqMag);
  51. const unit = vector.map((c) => c / mag);
  52. const hue = toHue(vector);
  53. const lightness = toLightness(vector);
  54. const chroma = toChroma(vector);
  55. const hex = toHex(vector);
  56. return { vector, sqMag, mag, unit, hue, lightness, chroma, hex };
  57. };
  58. const buildClusterData = (
  59. size,
  60. inertia,
  61. mu1,
  62. mu2,
  63. mu3,
  64. nu1,
  65. nu2,
  66. nu3,
  67. totalSize,
  68. toHue,
  69. toLightness,
  70. toChroma,
  71. toHex
  72. ) => {
  73. const mu = buildVectorData([mu1, mu2, mu3], toHue, toLightness, toChroma, toHex);
  74. const nu = [nu1, nu2, nu3];
  75. const muNuAngle = rad2deg * Math.acos(vectorDot(mu.unit, nu) / vectorMag(nu));
  76. const proportion = size / totalSize;
  77. const importance = calcImportance(mu.chroma, mu.lightness, proportion);
  78. return {
  79. size,
  80. inverseSize: 1 / size,
  81. inertia,
  82. mu,
  83. nu,
  84. muNuAngle,
  85. proportion,
  86. inverseProportion: 1 / proportion,
  87. importance,
  88. };
  89. };
  90. const buildPokemonData = ([name, size, ...values]) => ({
  91. name,
  92. jab: {
  93. total: buildClusterData(
  94. size,
  95. ...values.slice(0, 7),
  96. size,
  97. jab2hue,
  98. jab2lit,
  99. jab2chroma,
  100. jab2hex
  101. ),
  102. clusters: [
  103. buildClusterData(
  104. ...values.slice(7, 15),
  105. size,
  106. jab2hue,
  107. jab2lit,
  108. jab2chroma,
  109. jab2hex
  110. ),
  111. buildClusterData(
  112. ...values.slice(15, 23),
  113. size,
  114. jab2hue,
  115. jab2lit,
  116. jab2chroma,
  117. jab2hex
  118. ),
  119. buildClusterData(
  120. ...values.slice(23, 31),
  121. size,
  122. jab2hue,
  123. jab2lit,
  124. jab2chroma,
  125. jab2hex
  126. ),
  127. buildClusterData(
  128. ...values.slice(31, 39),
  129. size,
  130. jab2hue,
  131. jab2lit,
  132. jab2chroma,
  133. jab2hex
  134. ),
  135. ].filter((c) => c.size !== 0),
  136. },
  137. rgb: {
  138. total: buildClusterData(
  139. size,
  140. ...values.slice(39, 46),
  141. size,
  142. rgb2hue,
  143. rgb2lit,
  144. rgb2chroma,
  145. rgb2hex
  146. ),
  147. clusters: [
  148. buildClusterData(
  149. ...values.slice(46, 54),
  150. size,
  151. rgb2hue,
  152. rgb2lit,
  153. rgb2chroma,
  154. rgb2hex
  155. ),
  156. buildClusterData(
  157. ...values.slice(54, 62),
  158. size,
  159. rgb2hue,
  160. rgb2lit,
  161. rgb2chroma,
  162. rgb2hex
  163. ),
  164. buildClusterData(
  165. ...values.slice(62, 70),
  166. size,
  167. rgb2hue,
  168. rgb2lit,
  169. rgb2chroma,
  170. rgb2hex
  171. ),
  172. buildClusterData(
  173. ...values.slice(70, 78),
  174. size,
  175. rgb2hue,
  176. rgb2lit,
  177. rgb2chroma,
  178. rgb2hex
  179. ),
  180. ].filter((c) => c.size !== 0),
  181. },
  182. });
  183. const pokemonData = databaseV3.map((row) => buildPokemonData(row));
  184. const calcScores = (data, target) => {
  185. const sigma = Math.sqrt(
  186. data.inertia - 2 * vectorDot(data.mu.vector, target.vector) + target.sqMag
  187. );
  188. const bigTheta = 1 - vectorDot(data.nu, target.unit);
  189. const rawPhi = Math.abs(data.mu.hue - target.hue);
  190. return {
  191. sigma,
  192. bigTheta,
  193. alpha: sigma * Math.pow(bigTheta, target.chroma + target.lightness),
  194. theta: rad2deg * Math.acos(vectorDot(data.mu.unit, target.unit)),
  195. phi: Math.min(rawPhi, 360 - rawPhi),
  196. delta: vectorMag(data.mu.vector.map((x, i) => x - target.vector[i])),
  197. manhattan: data.mu.vector
  198. .map((x, i) => Math.abs(x - target.vector[i]))
  199. .reduce((x, y) => x + y),
  200. ch: Math.max(...data.mu.vector.map((x, i) => Math.abs(x - target.vector[i]))),
  201. lightnessDiff: Math.abs(data.mu.lightness - target.lightness),
  202. inertia: data.inertia,
  203. variance: data.inertia - data.mu.sqMag,
  204. muNuAngle: data.muNuAngle,
  205. size: data.size,
  206. lightness: data.mu.lightness,
  207. chroma: data.mu.chroma,
  208. importance: data.importance,
  209. inverseSize: data.inverseSize,
  210. proportion: data.proportion,
  211. inverseProportion: data.inverseProportion,
  212. muHex: data.mu.hex,
  213. };
  214. };
  215. // ---- Styling ----
  216. const rootStyle = document.querySelector(":root").style;
  217. const setColorStyles = (style, hex) => {
  218. const highlight = getContrastingTextColor(hex);
  219. style.setProperty("--highlight", highlight);
  220. style.setProperty("--background", hex);
  221. style.setProperty("--shadow-component", highlight.includes("light") ? "255" : "0");
  222. };
  223. // ---- List Render ----
  224. const renderPokemon = (list, target) => {
  225. target.innerHTML = "";
  226. // TODO
  227. };
  228. // ---- Shared State ----
  229. const state = {
  230. get targetColor() {
  231. return this._targetColor || "";
  232. },
  233. set targetColor(newColor) {
  234. const hex = `#${newColor?.replace("#", "")}`;
  235. if (hex.length !== 7) {
  236. return;
  237. }
  238. setColorStyles(rootStyle, hex);
  239. const oldColor = this._targetColor;
  240. this._targetColor = hex;
  241. if (oldColor) {
  242. const prevButton = document.createElement("button");
  243. prevButton.innerText = oldColor;
  244. prevButton.classList = "color-select";
  245. setColorStyles(prevButton.style, oldColor);
  246. prevButton.addEventListener("click", () => (this.targetColor = oldColor));
  247. document.getElementById("prevColors").prepend(prevButton);
  248. }
  249. document.forms.targetColorForm.elements.colorText.value = hex;
  250. document.forms.targetColorForm.elements.colorPicker.value = hex;
  251. // TODO trigger recalc
  252. },
  253. get colorSearchResults() {
  254. return this._colorSearchResults || [];
  255. },
  256. set colorSearchResults(results) {
  257. this._colorSearchResults = results;
  258. renderColorSearchResults();
  259. },
  260. get nameSearchResults() {
  261. return this._nameSearchResults || [];
  262. },
  263. set nameSearchResults(results) {
  264. this._nameSearchResults = results;
  265. renderNameSearchResults();
  266. },
  267. };
  268. const colorSearchResultsTarget = document.getElementById("color-results");
  269. const nameSearchResultsTarget = document.getElementById("name-results");
  270. function renderColorSearchResults() {
  271. renderPokemon(state.colorSearchResults, colorSearchResultsTarget);
  272. }
  273. function renderNameSearchResults() {
  274. renderPokemon(state.nameSearchResults, nameSearchResultsTarget);
  275. }
  276. // ---- Form Controls ----
  277. document.forms.targetColorForm.elements.colorText.addEventListener(
  278. "input",
  279. ({ target }) => {
  280. if (target.willValidate && !target.validity.valid) {
  281. target.value = target.dataset.lastValid || "";
  282. } else {
  283. state.targetColor = target.dataset.lastValid = target.value;
  284. }
  285. }
  286. );
  287. document.forms.targetColorForm.elements.colorPicker.addEventListener(
  288. "change",
  289. ({ target }) => {
  290. state.targetColor = target.value;
  291. }
  292. );
  293. const randomizeTargetColor = () =>
  294. (state.targetColor = d3
  295. .hsl(Math.random() * 360, Math.random(), Math.random())
  296. .formatHex());
  297. document.forms.targetColorForm.elements.randomColor.addEventListener(
  298. "click",
  299. randomizeTargetColor
  300. );
  301. randomizeTargetColor();