diff options
| author | Gabriel Schneider <[email protected]> | 2026-03-25 13:48:52 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-03-25 13:49:41 -0300 |
| commit | ffb30c53b76adbee64fc6b0df59ab7599dec3d9e (patch) | |
| tree | 344b5c1424e3099ac0110fafcabd2c67d51f864f /publish/main.js | |
| download | 0x4200.cafe-ffb30c53b76adbee64fc6b0df59ab7599dec3d9e.tar.gz 0x4200.cafe-ffb30c53b76adbee64fc6b0df59ab7599dec3d9e.zip | |
initial kodama site
Diffstat (limited to 'publish/main.js')
| -rw-r--r-- | publish/main.js | 362 |
1 files changed, 362 insertions, 0 deletions
diff --git a/publish/main.js b/publish/main.js new file mode 100644 index 0000000..5caefb6 --- /dev/null +++ b/publish/main.js @@ -0,0 +1,362 @@ +// Mobile TOC: responsive open / close behavior for the table of contents. +function toggleDetailsOpen() { + const details = document.querySelector("#toc>div>details"); + if (!details) return; + + if (window.matchMedia("(max-width: 1000px)").matches) { + details.removeAttribute("open"); + } else { + details.setAttribute("open", ""); + } +} + +document.addEventListener("DOMContentLoaded", toggleDetailsOpen); +window.addEventListener("resize", toggleDetailsOpen); + +// Theme: option selection, persistence, and dynamic color invert logic. +const KODAMA_THEME_KEY = `kodama-theme`; + +function storeSelectedTheme(name) { + localStorage.setItem(KODAMA_THEME_KEY, name); +} + +function getCurrentTheme() { + return localStorage.getItem(KODAMA_THEME_KEY) || window.primaryTheme; +} + +function selectTheme(themeName) { + storeSelectedTheme(themeName); + requestAnimationFrame(applyDynamicColorInvert); +} + +function applyFavorTheme() { + const favoredTheme = getCurrentTheme(); + if (favoredTheme) { + const selector = `input[type='radio'][id='${favoredTheme}']`; + const favoredOption = window.themeOptions.querySelector(selector); + + if (favoredOption) { + favoredOption.checked = true; + } else if (window.primaryOption) { + // Fallback to primary theme, if the favored theme is not found + // e.g., when the theme options have changed, and the stored theme is no longer available. + window.primaryOption.checked = true; + } + } + applyDynamicColorInvert(); +} + +document.addEventListener("DOMContentLoaded", function () { + window.themeOptions = document.getElementById("theme-options"); + const templateContent = document.getElementById("theme-option-template").content; + + customElements.define( + "theme-option", + class extends HTMLElement { + constructor() { + super(); + + const node = templateContent.cloneNode(true); + const themeName = this.getAttribute("name"); + + const input = node.querySelector("input"); + input.setAttribute("id", themeName); + input.setAttribute("value", themeName); + + const label = node.querySelector("label"); + label.setAttribute("for", themeName); + label.addEventListener("click", () => selectTheme(themeName)); + + while (this.firstChild) { + label.appendChild(this.firstChild); + } + this.appendChild(node); + } + }, + ); + + window.primaryOption = window.themeOptions.querySelector("input[type='radio']"); + window.primaryTheme = primaryOption?.value; + + applyFavorTheme(); +}); + +/// SPSA filter solver +/// +/// Also see: +/// - https://en.wikipedia.org/wiki/Simultaneous_perturbation_stochastic_approximation +/// - https://github.com/pranjalworm/css-filter-generator +/// +/// Principle +/// +/// invert → sepia → saturate → hue-rotate → brightness → contrast +/// loss = |ΔR| + |ΔG| + |ΔB| + |ΔH| + |ΔS| + |ΔL| +/// + +const clamp = (value) => Math.max(0, Math.min(255, value)); + +const multiply = ([r, g, b], matrix) => { + const nr = clamp(r * matrix[0] + g * matrix[1] + b * matrix[2]); + const ng = clamp(r * matrix[3] + g * matrix[4] + b * matrix[5]); + const nb = clamp(r * matrix[6] + g * matrix[7] + b * matrix[8]); + return [nr, ng, nb]; +}; + +const hueRotate = ([r, g, b], angle = 0) => { + const rad = (angle / 180) * Math.PI; + const sin = Math.sin(rad); + const cos = Math.cos(rad); + const matrix = [ + 0.213 + cos * 0.787 - sin * 0.213, + 0.715 - cos * 0.715 - sin * 0.715, + 0.072 - cos * 0.072 + sin * 0.928, + 0.213 - cos * 0.213 + sin * 0.143, + 0.715 + cos * 0.285 + sin * 0.140, + 0.072 - cos * 0.072 - sin * 0.283, + 0.213 - cos * 0.213 - sin * 0.787, + 0.715 - cos * 0.715 + sin * 0.715, + 0.072 + cos * 0.928 + sin * 0.072, + ]; + return multiply([r, g, b], matrix); +}; + +const grayscale = ([r, g, b], value = 1) => { + const matrix = [ + 0.2126 + 0.7874 * (1 - value), + 0.7152 - 0.7152 * (1 - value), + 0.0722 - 0.0722 * (1 - value), + 0.2126 - 0.2126 * (1 - value), + 0.7152 + 0.2848 * (1 - value), + 0.0722 - 0.0722 * (1 - value), + 0.2126 - 0.2126 * (1 - value), + 0.7152 - 0.7152 * (1 - value), + 0.0722 + 0.9278 * (1 - value), + ]; + return multiply([r, g, b], matrix); +}; + +const sepia = ([r, g, b], value = 1) => { + const matrix = [ + 0.393 + 0.607 * (1 - value), + 0.769 - 0.769 * (1 - value), + 0.189 - 0.189 * (1 - value), + 0.349 - 0.349 * (1 - value), + 0.686 + 0.314 * (1 - value), + 0.168 - 0.168 * (1 - value), + 0.272 - 0.272 * (1 - value), + 0.534 - 0.534 * (1 - value), + 0.131 + 0.869 * (1 - value), + ]; + return multiply([r, g, b], matrix); +}; + +const saturate = ([r, g, b], value = 1) => { + const matrix = [ + 0.213 + 0.787 * value, + 0.715 - 0.715 * value, + 0.072 - 0.072 * value, + 0.213 - 0.213 * value, + 0.715 + 0.285 * value, + 0.072 - 0.072 * value, + 0.213 - 0.213 * value, + 0.715 - 0.715 * value, + 0.072 + 0.928 * value, + ]; + return multiply([r, g, b], matrix); +}; + +const linear = ([r, g, b], slope = 1, intercept = 0) => { + const scale = 255; + return [ + clamp(r * slope + intercept * scale), + clamp(g * slope + intercept * scale), + clamp(b * slope + intercept * scale), + ]; +}; + +const brightness = (rgb, value = 1) => linear(rgb, value); +const contrast = (rgb, value = 1) => linear(rgb, value, -0.5 * value + 0.5); + +const invert = ([r, g, b], value = 1) => { + const nr = (value + r / 255 * (1 - 2 * value)) * 255; + const ng = (value + g / 255 * (1 - 2 * value)) * 255; + const nb = (value + b / 255 * (1 - 2 * value)) * 255; + return [clamp(nr), clamp(ng), clamp(nb)]; +}; + +// Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA. +const rgbToHsl = (r, g, b) => { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + if (max === min) { + h = s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + } + return { h: h * 100, s: s * 100, l: l * 100 }; +}; + +const applyFilters = (rgb, [inv, sep, sat, hue, bri, con]) => { + let color = [...rgb]; + color = invert(color, inv / 100); + color = sepia(color, sep / 100); + color = saturate(color, sat / 100); + color = hueRotate(color, hue * 3.6); + color = brightness(color, bri / 100); + color = contrast(color, con / 100); + return color; +}; + +const lossFn = (targetRgb, filters) => { + const resultRgb = applyFilters([0, 0, 0], filters); + const targetHsl = rgbToHsl(...targetRgb); + const resultHsl = rgbToHsl(...resultRgb); + return ( + Math.abs(resultRgb[0] - targetRgb[0]) + + Math.abs(resultRgb[1] - targetRgb[1]) + + Math.abs(resultRgb[2] - targetRgb[2]) + + Math.abs(resultHsl.h - targetHsl.h) + + Math.abs(resultHsl.s - targetHsl.s) + + Math.abs(resultHsl.l - targetHsl.l) + ); +}; + +const fix = (value, idx) => { + let max = 100; + if (idx === 2) max = 7500; // saturate + else if (idx === 4 || idx === 5) max = 200; // brightness, contrast + if (idx === 3) { // hue-rotate + if (value > max) return value % max; + if (value < 0) return max + (value % max); + return value; + } + return Math.max(0, Math.min(max, value)); +}; + +const spsa = (targetRgb, A, a, c, initial, iters) => { + const alpha = 1; + const gamma = 1 / 6; + let values = [...initial]; + let best = [...values]; + let bestLoss = Infinity; + + for (let k = 0; k < iters; k++) { + const ck = c / Math.pow(k + 1, gamma); + const deltas = Array(6).fill().map(() => (Math.random() > 0.5 ? 1 : -1)); + const highArgs = values.map((v, i) => v + ck * deltas[i]); + const lowArgs = values.map((v, i) => v - ck * deltas[i]); + const lossDiff = lossFn(targetRgb, highArgs) - lossFn(targetRgb, lowArgs); + + values = values.map((v, i) => { + const g = (lossDiff / (2 * ck)) * deltas[i]; + const ak = a[i] / Math.pow(A + k + 1, alpha); + return fix(v - ak * g, i); + }); + + const loss = lossFn(targetRgb, values); + if (loss < bestLoss) { + best = [...values]; + bestLoss = loss; + } + } + return { values: best, loss: bestLoss }; +}; + +// Threshold for acceptable color loss. This value determines when the SPSA algorithm stops early. +// A loss below this value is considered visually indistinguishable for most use cases. +// The value 25 was determined empirically to balance performance and color accuracy. +const ACCEPTABLE_COLOR_LOSS = 25; + +const solveWide = (targetRgb) => { + const A = 5; + const c = 15; + const a = [60, 180, 18000, 600, 1.2, 1.2]; + let best = { loss: Infinity, values: null }; + for (let i = 0; best.loss > ACCEPTABLE_COLOR_LOSS && i < 3; i++) { + const initial = [50, 20, 3750, 50, 100, 100]; + const result = spsa(targetRgb, A, a, c, initial, 1000); + if (result.loss < best.loss) best = result; + } + return best; +}; + +const solveNarrow = (targetRgb, wideResult) => { + const A = wideResult.loss; + const c = 2; + const A1 = A + 1; + const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1]; + return spsa(targetRgb, A, a, c, wideResult.values, 500); +}; + +const cssFilterString = (filters) => { + const [inv, sep, sat, hue, bri, con] = filters.map(Math.round); + const hueDeg = Math.round(hue * 3.6); + return `filter: invert(${inv}%) sepia(${sep}%) saturate(${sat}%) hue-rotate(${hueDeg}deg) brightness(${bri}%) contrast(${con}%);`; +}; + +const solveColor = (targetRgb) => { + const wide = solveWide(targetRgb); + const narrow = solveNarrow(targetRgb, wide); + return { + values: narrow.values, + loss: narrow.loss, + filter: cssFilterString(narrow.values), + }; +}; + +// Reuse a single canvas / context to avoid memory leaks +const colorParseCanvas = document.createElement('canvas'); +colorParseCanvas.width = 1; +colorParseCanvas.height = 1; +const colorParseContext = colorParseCanvas.getContext('2d'); + +const toRGBArray = (cssColor) => { + colorParseContext.fillStyle = cssColor; + colorParseContext.fillRect(0, 0, 1, 1); + + // The `getImageData().data` returns a Uint8ClampedArray with 4 values per pixel (RGBA). + // also see: https://developer.mozilla.org/zh-CN/docs/Web/API/ImageData/data + const rgba = colorParseContext.getImageData(0, 0, 1, 1).data; + return Array.from(rgba.slice(0, 3)); +}; + +const DYNAMIC_STYLE_ELEMENT_ID = 'dynamic-color-invert-style'; + +function memorizedSolvedFilter(color) { + const colorKey = `solved-${color}`; + const storedFilterOption = localStorage.getItem(colorKey); + if (storedFilterOption) { + return storedFilterOption; + } else { + const filter = solveColor(color).filter; + localStorage.setItem(colorKey, filter); + return filter; + } +} + +function applyDynamicColorInvert() { + const textColor = getComputedStyle(document.body).getPropertyValue('--text-color'); + if (textColor && textColor.length > 0) { + const rgb = toRGBArray(textColor); + const filter = memorizedSolvedFilter(rgb); + + const existingStyle = document.getElementById(DYNAMIC_STYLE_ELEMENT_ID); + if (existingStyle) { + existingStyle.textContent = `.color-invert {${filter}}`; + } else { + const style = document.createElement('style'); + style.id = DYNAMIC_STYLE_ELEMENT_ID; + style.textContent = `.color-invert {${filter}}`; + document.head.appendChild(style); + } + } +} |
