diff --git a/crates/brk_playground/src/lib.rs b/crates/brk_playground/src/lib.rs index 14d423edc..3b5f27f1d 100644 --- a/crates/brk_playground/src/lib.rs +++ b/crates/brk_playground/src/lib.rs @@ -14,15 +14,15 @@ pub mod oracle; pub mod render; pub mod signal; -pub use anchors::{get_anchor_ohlc, get_anchor_range, Ohlc}; -pub use conditions::{out_bits, tx_bits, MappedOutputConditions}; -pub use constants::{HeatmapFilter, NUM_BINS, ROUND_USD_AMOUNTS}; +pub use anchors::{Ohlc, get_anchor_ohlc, get_anchor_range}; +pub use conditions::{MappedOutputConditions, out_bits, tx_bits}; +pub use constants::{NUM_BINS, OutputFilter, ROUND_USD_AMOUNTS}; pub use filters::FILTERS; +pub use histogram::load_or_compute_output_conditions; pub use oracle::{ - derive_daily_ohlc, derive_daily_ohlc_with_confidence, derive_height_price, - derive_height_price_with_confidence, derive_ohlc_from_height_prices, - derive_ohlc_from_height_prices_with_confidence, derive_price_from_histogram, - HeightPriceResult, OracleConfig, OracleResult, + HeightPriceResult, OracleConfig, OracleResult, derive_daily_ohlc, + derive_daily_ohlc_with_confidence, derive_height_price, derive_height_price_with_confidence, + derive_ohlc_from_height_prices, derive_ohlc_from_height_prices_with_confidence, + derive_price_from_histogram, }; pub use signal::{compute_expected_bins_per_day, usd_to_bin}; -pub use histogram::load_or_compute_output_conditions; diff --git a/website/scripts/chart/colors.js b/website/scripts/chart/colors.js index b2699d59a..803ecd265 100644 --- a/website/scripts/chart/colors.js +++ b/website/scripts/chart/colors.js @@ -1,4 +1,5 @@ import { oklchToRgba } from "./oklch.js"; +import { dark } from "../utils/theme.js"; /** @type {Map} */ const rgbaCache = new Map(); @@ -48,55 +49,50 @@ function createColor(getter) { return color; } +const globalComputedStyle = getComputedStyle(window.document.documentElement); + /** - * @param {Accessor} dark + * @param {string} name */ -export function createColors(dark) { - const globalComputedStyle = getComputedStyle(window.document.documentElement); - - /** - * @param {string} name - */ - function getColor(name) { - return globalComputedStyle.getPropertyValue(`--${name}`); - } - - /** - * @param {string} property - */ - function getLightDarkValue(property) { - const value = globalComputedStyle.getPropertyValue(property); - const [light, _dark] = value.slice(11, -1).split(", "); - return dark() ? _dark : light; - } - - return { - default: createColor(() => getLightDarkValue("--color")), - gray: createColor(() => getColor("gray")), - border: createColor(() => getLightDarkValue("--border-color")), - - red: createColor(() => getColor("red")), - orange: createColor(() => getColor("orange")), - amber: createColor(() => getColor("amber")), - yellow: createColor(() => getColor("yellow")), - avocado: createColor(() => getColor("avocado")), - lime: createColor(() => getColor("lime")), - green: createColor(() => getColor("green")), - emerald: createColor(() => getColor("emerald")), - teal: createColor(() => getColor("teal")), - cyan: createColor(() => getColor("cyan")), - sky: createColor(() => getColor("sky")), - blue: createColor(() => getColor("blue")), - indigo: createColor(() => getColor("indigo")), - violet: createColor(() => getColor("violet")), - purple: createColor(() => getColor("purple")), - fuchsia: createColor(() => getColor("fuchsia")), - pink: createColor(() => getColor("pink")), - rose: createColor(() => getColor("rose")), - }; +function getColor(name) { + return globalComputedStyle.getPropertyValue(`--${name}`); } /** - * @typedef {ReturnType} Colors + * @param {string} property + */ +function getLightDarkValue(property) { + const value = globalComputedStyle.getPropertyValue(property); + const [light, _dark] = value.slice(11, -1).split(", "); + return dark ? _dark : light; +} + +export const colors = { + default: createColor(() => getLightDarkValue("--color")), + gray: createColor(() => getColor("gray")), + border: createColor(() => getLightDarkValue("--border-color")), + + red: createColor(() => getColor("red")), + orange: createColor(() => getColor("orange")), + amber: createColor(() => getColor("amber")), + yellow: createColor(() => getColor("yellow")), + avocado: createColor(() => getColor("avocado")), + lime: createColor(() => getColor("lime")), + green: createColor(() => getColor("green")), + emerald: createColor(() => getColor("emerald")), + teal: createColor(() => getColor("teal")), + cyan: createColor(() => getColor("cyan")), + sky: createColor(() => getColor("sky")), + blue: createColor(() => getColor("blue")), + indigo: createColor(() => getColor("indigo")), + violet: createColor(() => getColor("violet")), + purple: createColor(() => getColor("purple")), + fuchsia: createColor(() => getColor("fuchsia")), + pink: createColor(() => getColor("pink")), + rose: createColor(() => getColor("rose")), +}; + +/** + * @typedef {typeof colors} Colors * @typedef {keyof Colors} ColorName */ diff --git a/website/scripts/chart/index.js b/website/scripts/chart/index.js index dc8a86626..3cb3a8b33 100644 --- a/website/scripts/chart/index.js +++ b/website/scripts/chart/index.js @@ -8,11 +8,14 @@ import { } from "../modules/lightweight-charts/5.1.0/dist/lightweight-charts.standalone.production.mjs"; import { createLegend } from "./legend.js"; import { capture, canCapture } from "./capture.js"; +import { colors } from "./colors.js"; const lcCreateChart = /** @type {CreateLCChart} */ (untypedLcCreateChart); import { createChoiceField } from "../utils/dom.js"; -import { throttle } from "../utils/timing.js"; -import { serdeBool } from "../utils/serde.js"; +import { createPersistedValue } from "../utils/persisted.js"; +import { onChange as onThemeChange } from "../utils/theme.js"; +import { throttle, debounce } from "../utils/timing.js"; +import { serdeBool, serdeChartableIndex } from "../utils/serde.js"; import { stringToId, numberToShortUSFormat } from "../utils/format.js"; import { style } from "../utils/elements.js"; import { resources } from "../resources.js"; @@ -74,11 +77,7 @@ const lineWidth = /** @type {any} */ (1.5); * @param {string} args.id * @param {HTMLElement} args.parent * @param {Signals} args.signals - * @param {Colors} args.colors * @param {BrkClient} args.brk - * @param {Accessor} args.index - * @param {((unknownTimeScaleCallback: VoidFunction) => void)} [args.timeScaleSetCallback] - * @param {number | null} [args.initialVisibleBarsCount] * @param {true} [args.fitContent] * @param {HTMLElement} [args.captureElement] * @param {{unit: Unit; blueprints: AnySeriesBlueprint[]}[]} [args.config] @@ -86,16 +85,55 @@ const lineWidth = /** @type {any} */ (1.5); export function createChart({ parent, signals, - colors, id: chartId, - index, brk, - timeScaleSetCallback, - initialVisibleBarsCount, fitContent, captureElement, config, }) { + // Chart owns its index state + /** @type {Signal} */ + const indexName = signals.createPersistedSignal({ + defaultValue: /** @type {ChartableIndexName} */ ("date"), + storageKey: "chart-index", + urlKey: "i", + serialize: (v) => v, + deserialize: (s) => /** @type {ChartableIndexName} */ (s), + }); + + const index = signals.createMemo(() => + serdeChartableIndex.deserialize(indexName()), + ); + + // Range state: localStorage stores all ranges per-index, URL stores current range only + /** @typedef {{ from: number, to: number }} Range */ + const ranges = createPersistedValue({ + defaultValue: /** @type {Record} */ ({}), + storageKey: "chart-ranges", + serialize: JSON.stringify, + deserialize: JSON.parse, + }); + + const range = createPersistedValue({ + defaultValue: /** @type {Range | null} */ (null), + urlKey: "range", + serialize: (v) => (v ? `${v.from.toFixed(2)}_${v.to.toFixed(2)}` : ""), + deserialize: (s) => { + if (!s) return null; + const [from, to] = s.split("_").map(Number); + return !isNaN(from) && !isNaN(to) ? { from, to } : null; + }, + }); + + /** @returns {Range | null} */ + const getRange = () => range.value ?? ranges.value[indexName()] ?? null; + + /** @param {Range} value */ + const setRange = (value) => { + ranges.set({ ...ranges.value, [indexName()]: value }); + range.set(value); + }; + const div = window.document.createElement("div"); div.classList.add("chart"); parent.append(div); @@ -167,61 +205,65 @@ export function createChart({ ichart.panes().at(0)?.setStretchFactor(1); - /** @param {{ from: number, to: number }} range */ - const setVisibleLogicalRange = (range) => { - // Defer to next frame to ensure chart has rendered - requestAnimationFrame(() => { - ichart.timeScale().setVisibleLogicalRange(range); - }); - }; - /** @typedef {(visibleBarsCount: number) => void} ZoomChangeCallback */ - let visibleBarsCount = initialVisibleBarsCount ?? Infinity; + const initialRange = getRange(); + if (initialRange) { + ichart.timeScale().setVisibleLogicalRange(initialRange); + } + + let visibleBarsCount = initialRange + ? initialRange.to - initialRange.from + : Infinity; + /** @type {Set} */ const onZoomChange = new Set(); - /** @param {{ from: number, to: number } | null} range */ - function updateVisibleBarsCount(range) { - if (!range) return; - const count = range.to - range.from; - if (count === visibleBarsCount) return; - visibleBarsCount = count; - onZoomChange.forEach((cb) => cb(count)); - } - - ichart - .timeScale() - .subscribeVisibleLogicalRangeChange(throttle(updateVisibleBarsCount, 100)); - - signals.createEffect( - () => ({ - defaultColor: colors.default(), - offColor: colors.gray(), - borderColor: colors.border(), - }), - ({ defaultColor, offColor, borderColor }) => { - ichart.applyOptions({ - layout: { - textColor: offColor, - panes: { - separatorColor: borderColor, - }, - }, - crosshair: { - horzLine: { - color: offColor, - labelBackgroundColor: defaultColor, - }, - vertLine: { - color: offColor, - labelBackgroundColor: defaultColor, - }, - }, - }); - }, + ichart.timeScale().subscribeVisibleLogicalRangeChange( + throttle((range) => { + if (!range) return; + const count = range.to - range.from; + if (count === visibleBarsCount) return; + visibleBarsCount = count; + onZoomChange.forEach((cb) => cb(count)); + }, 100), ); + // Debounced range persistence + ichart.timeScale().subscribeVisibleLogicalRangeChange( + debounce((range) => { + if (range && range.from < range.to) { + setRange({ from: range.from, to: range.to }); + } + }, 100), + ); + + function applyColors() { + const defaultColor = colors.default(); + const offColor = colors.gray(); + const borderColor = colors.border(); + ichart.applyOptions({ + layout: { + textColor: offColor, + panes: { + separatorColor: borderColor, + }, + }, + crosshair: { + horzLine: { + color: offColor, + labelBackgroundColor: defaultColor, + }, + vertLine: { + color: offColor, + labelBackgroundColor: defaultColor, + }, + }, + }); + } + applyColors(); + const removeThemeListener = onThemeChange(applyColors); + signals.createEffect(index, (index) => { const minBarSpacing = index === "monthindex" @@ -328,29 +370,38 @@ export function createChart({ paneIndex, position: "sw", createChild(pane) { + /** @type {"lin" | "log"} */ const defaultValue = unit.id === "usd" && seriesType !== "Baseline" ? "log" : "lin"; - const selected = signals.createPersistedSignal({ + + const persisted = createPersistedValue({ defaultValue, storageKey: `${id}-scale-${paneIndex}`, urlKey: paneIndex === 0 ? "price_scale" : "unit_scale", serialize: (v) => v, deserialize: (s) => /** @type {"lin" | "log"} */ (s), }); + + /** @param {"lin" | "log"} value */ + const applyScale = (value) => { + try { + pane.priceScale("right").applyOptions({ + mode: value === "lin" ? 0 : 1, + }); + } catch {} + }; + + // Apply initial value + applyScale(persisted.value); + const field = createChoiceField({ choices: /** @type {const} */ (["lin", "log"]), id: stringToId(`${id} ${paneIndex} ${unit}`), - defaultValue, - selected, - signals, - }); - - signals.createEffect(selected, (selected) => { - try { - pane.priceScale("right").applyOptions({ - mode: selected === "lin" ? 0 : 1, - }); - } catch {} + initialValue: persisted.value, + onChange(value) { + persisted.set(value); + applyScale(value); + }, }); return field; @@ -378,6 +429,7 @@ export function createChart({ * @param {(data: any[]) => void} args.setData * @param {(data: any) => void} args.update * @param {() => void} args.onRemove + * @param {() => void} [args.onDataLoaded] */ function addSeries({ metric, @@ -398,6 +450,7 @@ export function createChart({ setData, update, onRemove, + onDataLoaded, }) { return signals.createRoot((dispose) => { const key = stringToId(name); @@ -433,7 +486,7 @@ export function createChart({ seriesByKey.get(key)?.forEach((s) => { value ? s.show() : s.hide(); }); - document.querySelectorAll(`[data-series="${key}"]`).forEach((el) => { + document.querySelectorAll(`[data-series="${id}"]`).forEach((el) => { if (el instanceof HTMLInputElement && el.type === "checkbox") { el.checked = value; } @@ -614,20 +667,27 @@ export function createChart({ lastTime = /** @type {number} */ (data.at(-1)?.time) ?? -Infinity; - if (fitContent) { + // Restore saved range or use defaults + const savedRange = getRange(); + if (savedRange) { + ichart.timeScale().setVisibleLogicalRange({ + from: savedRange.from, + to: savedRange.to, + }); + } else if (fitContent) { ichart.timeScale().fitContent(); + } else if ( + index === "quarterindex" || + index === "semesterindex" || + index === "yearindex" || + index === "decadeindex" + ) { + ichart + .timeScale() + .setVisibleLogicalRange({ from: -1, to: data.length }); } - - timeScaleSetCallback?.(() => { - if ( - index === "quarterindex" || - index === "semesterindex" || - index === "yearindex" || - index === "decadeindex" - ) { - setVisibleLogicalRange({ from: -1, to: data.length }); - } - }); + // Delay until chart has applied the range + requestAnimationFrame(() => onDataLoaded?.()); } else { // Incremental update: only process new data points for (let i = startIdx; i < length; i++) { @@ -651,9 +711,17 @@ export function createChart({ signals.createEffect(data, (data) => { setData(data); hasData = true; - if (fitContent) { + const savedRange = getRange(); + if (savedRange) { + ichart.timeScale().setVisibleLogicalRange({ + from: savedRange.from, + to: savedRange.to, + }); + } else if (fitContent) { ichart.timeScale().fitContent(); } + // Delay until chart has applied the range + requestAnimationFrame(() => onDataLoaded?.()); }); } } @@ -669,23 +737,14 @@ export function createChart({ } const chart = { + index, + indexName, + legendTop, legendBottom, addFieldsetIfNeeded, - setVisibleLogicalRange, - - /** - * @param {(range: { from: number, to: number } | null) => void} callback - * @param {number} [wait=500] - */ - onVisibleLogicalRangeChange(callback, wait = 500) { - ichart - .timeScale() - .subscribeVisibleLogicalRangeChange(throttle(callback, wait)); - }, - /** * @param {Object} args * @param {string} args.name @@ -721,10 +780,7 @@ export function createChart({ ichart.addSeries( /** @type {SeriesDefinition<'Candlestick'>} */ (CandlestickSeries), { - upColor: upColor(), - downColor: downColor(), - wickUpColor: upColor(), - wickDownColor: downColor(), + visible: false, borderVisible: false, ...options, }, @@ -737,7 +793,7 @@ export function createChart({ ichart.addSeries( /** @type {SeriesDefinition<'Line'>} */ (LineSeries), { - color: colors.default(), + visible: false, lineWidth, priceLineVisible: true, }, @@ -748,6 +804,7 @@ export function createChart({ let active = defaultActive !== false; let highlighted = true; let showLine = visibleBarsCount > 500; + let dataLoaded = false; function update() { candlestickISeries.applyOptions({ @@ -764,15 +821,17 @@ export function createChart({ color: colors.default.highlight(highlighted), }); } - update(); /** @type {ZoomChangeCallback} */ function handleZoom(count) { + if (!dataLoaded) return; // Ignore zoom changes until data is ready const newShowLine = count > 500; + if (newShowLine === showLine) return; showLine = newShowLine; update(); } onZoomChange.add(handleZoom); + const removeSeriesThemeListener = onThemeChange(update); const series = addSeries({ colors: [upColor, downColor], @@ -820,9 +879,14 @@ export function createChart({ getData: () => candlestickISeries.data(), onRemove: () => { onZoomChange.delete(handleZoom); + removeSeriesThemeListener(); ichart.removeSeries(candlestickISeries); ichart.removeSeries(lineISeries); }, + onDataLoaded: () => { + dataLoaded = true; + update(); + }, }); return series; }, @@ -876,6 +940,7 @@ export function createChart({ }); } update(); + const removeSeriesThemeListener = onThemeChange(update); const series = addSeries({ colors: isDualColor ? [positiveColor, negativeColor] : [positiveColor], @@ -925,7 +990,10 @@ export function createChart({ }, update: (data) => iseries.update(data), getData: () => iseries.data(), - onRemove: () => ichart.removeSeries(iseries), + onRemove: () => { + removeSeriesThemeListener(); + ichart.removeSeries(iseries); + }, }); return series; }, @@ -979,6 +1047,7 @@ export function createChart({ }); } update(); + const removeSeriesThemeListener = onThemeChange(update); const series = addSeries({ colors: [color], @@ -1014,7 +1083,10 @@ export function createChart({ setData: (data) => iseries.setData(data), update: (data) => iseries.update(data), getData: () => iseries.data(), - onRemove: () => ichart.removeSeries(iseries), + onRemove: () => { + removeSeriesThemeListener(); + ichart.removeSeries(iseries); + }, }); return series; }, @@ -1081,6 +1153,7 @@ export function createChart({ iseries.applyOptions({ pointMarkersRadius: radius }); } onZoomChange.add(handleZoom); + const removeSeriesThemeListener = onThemeChange(update); const series = addSeries({ colors: [color], @@ -1118,6 +1191,7 @@ export function createChart({ getData: () => iseries.data(), onRemove: () => { onZoomChange.delete(handleZoom); + removeSeriesThemeListener(); ichart.removeSeries(iseries); }, }); @@ -1183,6 +1257,7 @@ export function createChart({ }); } update(); + const removeSeriesThemeListener = onThemeChange(update); const series = addSeries({ colors: [topColor, bottomColor], @@ -1218,10 +1293,18 @@ export function createChart({ setData: (data) => iseries.setData(data), update: (data) => iseries.update(data), getData: () => iseries.data(), - onRemove: () => ichart.removeSeries(iseries), + onRemove: () => { + removeSeriesThemeListener(); + ichart.removeSeries(iseries); + }, }); return series; }, + + destroy() { + removeThemeListener(); + ichart.remove(); + }, }; config?.forEach(({ unit, blueprints }, paneIndex) => { diff --git a/website/scripts/chart/state.js b/website/scripts/chart/state.js deleted file mode 100644 index 44b80cea7..000000000 --- a/website/scripts/chart/state.js +++ /dev/null @@ -1,59 +0,0 @@ -import { readParam, writeParam } from "../utils/url.js"; -import { readStored, writeToStorage } from "../utils/storage.js"; - -/** - * @typedef {{ from: number | null, to: number | null }} Range - */ - -const RANGES_KEY = "chart-ranges"; -const RANGE_SEP = "_"; - -/** - * @param {Signals} signals - */ -export function createChartState(signals) { - const index = signals.createPersistedSignal({ - storageKey: "chart-index", - urlKey: "index", - defaultValue: /** @type {ChartableIndexName} */ ("date"), - serialize: (v) => v, - deserialize: (s) => /** @type {ChartableIndexName} */ (s), - }); - - // Ranges stored per-index in localStorage only - /** @type {Record} */ - let ranges = {}; - try { - const stored = readStored(RANGES_KEY); - if (stored) ranges = JSON.parse(stored); - } catch {} - - // Initialize from URL if present - const urlRange = readParam("range"); - if (urlRange) { - const [from, to] = urlRange.split(RANGE_SEP).map(Number); - if (!isNaN(from) && !isNaN(to)) { - ranges[index()] = { from, to }; - writeToStorage(RANGES_KEY, JSON.stringify(ranges)); - } - } - - return { - index, - /** @returns {Range} */ - range: () => ranges[index()] ?? { from: null, to: null }, - /** @param {Range} value */ - setRange(value) { - ranges[index()] = value; - writeToStorage(RANGES_KEY, JSON.stringify(ranges)); - if (value.from !== null && value.to !== null) { - // Round to 2 decimals for cleaner URLs - const f = Math.floor(value.from * 100) / 100; - const t = Math.floor(value.to * 100) / 100; - writeParam("range", `${f}${RANGE_SEP}${t}`); - } else { - writeParam("range", null); - } - }, - }; -} diff --git a/website/scripts/entry.js b/website/scripts/entry.js index 2badf2fc4..6c0be0f62 100644 --- a/website/scripts/entry.js +++ b/website/scripts/entry.js @@ -21,7 +21,7 @@ * * @import { UnitObject as Unit } from "./utils/units.js" * - * @import { ChartableIndexName } from "./panes/chart.js"; + * @import { ChartableIndexName } from "./utils/serde.js"; */ // import uFuzzy = require("./modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.d.ts"); diff --git a/website/scripts/main.js b/website/scripts/main.js index 626958f3d..9b4df9583 100644 --- a/website/scripts/main.js +++ b/website/scripts/main.js @@ -1,4 +1,3 @@ -import { createColors } from "./chart/colors.js"; import { webSockets } from "./utils/ws.js"; import * as formatters from "./utils/format.js"; import { onFirstIntersection, getElementById, isHidden } from "./utils/dom.js"; @@ -14,7 +13,6 @@ import { init as initSimulation } from "./panes/_simulation.js"; import { next } from "./utils/timing.js"; import { replaceHistory } from "./utils/url.js"; import { removeStored, writeToStorage } from "./utils/storage.js"; -import { dark } from "./utils/theme.js"; import { asideElement, asideLabelElement, @@ -148,10 +146,7 @@ signals.createRoot(() => { // } // const lastHeight = createLastHeightResource(); - const colors = createColors(dark); - const options = initOptions({ - colors, signals, brk, qrcode, @@ -223,7 +218,6 @@ signals.createRoot(() => { if (firstTimeLoadingChart) { signals.runWithOwner(owner, () => initChart({ - colors, option: /** @type {Accessor} */ (chartOption), brk, }), @@ -249,11 +243,7 @@ signals.createRoot(() => { simOption.set(option); if (firstTimeLoadingSimulation) { - signals.runWithOwner(owner, () => - initSimulation({ - colors, - }), - ); + signals.runWithOwner(owner, () => initSimulation()); } firstTimeLoadingSimulation = false; diff --git a/website/scripts/options/context.js b/website/scripts/options/context.js index d98b47048..977310313 100644 --- a/website/scripts/options/context.js +++ b/website/scripts/options/context.js @@ -17,15 +17,15 @@ import { createPriceLines, constantLine, } from "./constants.js"; +import { colors } from "../chart/colors.js"; /** * Create a context object with all dependencies for building partial options * @param {Object} args - * @param {Colors} args.colors * @param {BrkClient} args.brk * @returns {PartialContext} */ -export function createContext({ colors, brk }) { +export function createContext({ brk }) { const constants = brk.metrics.constants; return { diff --git a/website/scripts/options/full.js b/website/scripts/options/full.js index 215917ae0..dced42d97 100644 --- a/website/scripts/options/full.js +++ b/website/scripts/options/full.js @@ -10,12 +10,11 @@ import { collect, markUsed, logUnused } from "./unused.js"; /** * @param {Object} args - * @param {Colors} args.colors * @param {Signals} args.signals * @param {BrkClient} args.brk * @param {Signal} args.qrcode */ -export function initOptions({ colors, signals, brk, qrcode }) { +export function initOptions({ signals, brk, qrcode }) { collect(brk.metrics); const LS_SELECTED_KEY = `selected_path`; @@ -33,7 +32,6 @@ export function initOptions({ colors, signals, brk, qrcode }) { const selected = signals.createSignal(/** @type {any} */ (undefined)); const partialOptions = createPartialOptions({ - colors, brk, }); diff --git a/website/scripts/options/partial.js b/website/scripts/options/partial.js index 5378bc5eb..626d40853 100644 --- a/website/scripts/options/partial.js +++ b/website/scripts/options/partial.js @@ -13,6 +13,7 @@ import { import { createMarketSection } from "./market/index.js"; import { createChainSection } from "./chain.js"; import { createCointimeSection } from "./cointime.js"; +import { colors } from "../chart/colors.js"; // Re-export types for external consumers export * from "./types.js"; @@ -20,13 +21,12 @@ export * from "./types.js"; /** * Create partial options tree * @param {Object} args - * @param {Colors} args.colors * @param {BrkClient} args.brk * @returns {PartialOptionsTree} */ -export function createPartialOptions({ colors, brk }) { +export function createPartialOptions({ brk }) { // Create context with all helpers - const ctx = createContext({ colors, brk }); + const ctx = createContext({ brk }); // Build cohort data const { diff --git a/website/scripts/panes/_simulation.js b/website/scripts/panes/_simulation.js index f3c1ae62e..ebca9e567 100644 --- a/website/scripts/panes/_simulation.js +++ b/website/scripts/panes/_simulation.js @@ -21,12 +21,9 @@ import { serdeDate, serdeOptDate, serdeOptNumber } from "../utils/serde.js"; import signals from "../signals.js"; import { createChart } from "../chart/index.js"; import { resources } from "../resources.js"; +import { colors } from "../chart/colors.js"; -/** - * @param {Object} args - * @param {Colors} args.colors - */ -export function init({ colors }) { +export function init() { /** * @typedef {Object} Frequency * @property {string} name diff --git a/website/scripts/panes/chart.js b/website/scripts/panes/chart.js index 6814e76e5..47a900392 100644 --- a/website/scripts/panes/chart.js +++ b/website/scripts/panes/chart.js @@ -1,69 +1,40 @@ -import { createShadow, createChoiceField, createHeader } from "../utils/dom.js"; +import { + createShadow, + createReactiveChoiceField, + createHeader, +} from "../utils/dom.js"; import { chartElement } from "../utils/elements.js"; import { serdeChartableIndex } from "../utils/serde.js"; import { Unit } from "../utils/units.js"; import signals from "../signals.js"; import { createChart } from "../chart/index.js"; -import { createChartState } from "../chart/state.js"; import { webSockets } from "../utils/ws.js"; -import { debounce } from "../utils/timing.js"; const keyPrefix = "chart"; const ONE_BTC_IN_SATS = 100_000_000; -/** - * @typedef {"timestamp" | "date" | "week" | "month" | "quarter" | "semester" | "year" | "decade" } ChartableIndexName - */ - /** * @param {Object} args - * @param {Colors} args.colors * @param {Accessor} args.option * @param {BrkClient} args.brk */ -export function init({ colors, option, brk }) { +export function init({ option, brk }) { chartElement.append(createShadow("left")); chartElement.append(createShadow("right")); const { headerElement, headingElement } = createHeader(); chartElement.append(headerElement); - const state = createChartState(signals); - const { fieldset, index } = createIndexSelector(option, state); - - const { from, to } = state.range(); - const chart = createChart({ parent: chartElement, signals, - colors, id: "charts", brk, - index, - initialVisibleBarsCount: from !== null && to !== null ? to - from : null, captureElement: chartElement, - timeScaleSetCallback: (unknownTimeScaleCallback) => { - const { from, to } = state.range(); - if (from !== null && to !== null) { - chart.setVisibleLogicalRange({ from, to }); - } else { - unknownTimeScaleCallback(); - } - }, - }); - - // Sync chart → state.range on user pan/zoom - // Debounce to avoid rapid URL updates while panning - const debouncedSetRange = debounce( - (/** @type {{ from: number, to: number }} */ range) => - state.setRange(range), - 500, - ); - chart.onVisibleLogicalRangeChange((t) => { - if (!t || t.from >= t.to) return; - debouncedSetRange({ from: t.from, to: t.to }); }); + // Create index selector using chart's index state + const fieldset = createIndexSelector(option, chart); chartElement.append(fieldset); const unitChoices = /** @type {const} */ ([Unit.usd, Unit.sats]); @@ -76,7 +47,7 @@ export function init({ colors, option, brk }) { deserialize: (s) => /** @type {Unit} */ (unitChoices.find((u) => u.id === s) ?? Unit.usd), }); - const topUnitField = createChoiceField({ + const topUnitField = createReactiveChoiceField({ defaultValue: Unit.usd, choices: unitChoices, toKey: (u) => u.id, @@ -203,7 +174,7 @@ export function init({ colors, option, brk }) { deserialize: (s) => bottomUnits.find((u) => u.id === s) ?? bottomUnits[0], }); - const field = createChoiceField({ + const field = createReactiveChoiceField({ defaultValue: bottomUnits[0], choices: bottomUnits, toKey: (u) => u.id, @@ -343,7 +314,7 @@ export function init({ colors, option, brk }) { // Price series + top pane blueprints: combined effect on index + topUnit signals.createScopedEffect( - () => ({ idx: index(), unit: topUnit() }), + () => ({ idx: chart.index(), unit: topUnit() }), ({ idx, unit }) => { // Create price series /** @type {AnySeries | undefined} */ @@ -402,7 +373,7 @@ export function init({ colors, option, brk }) { // Bottom pane blueprints: combined effect on index + bottomUnit if (bottomUnit) { signals.createScopedEffect( - () => ({ idx: index(), unit: bottomUnit() }), + () => ({ idx: chart.index(), unit: bottomUnit() }), ({ idx, unit }) => { createSeriesFromBlueprints({ blueprints: option.bottom, @@ -421,9 +392,9 @@ export function init({ colors, option, brk }) { /** * @param {Accessor} option - * @param {ReturnType} state + * @param {Chart} chart */ -function createIndexSelector(option, state) { +function createIndexSelector(option, chart) { const choices_ = /** @satisfies {ChartableIndexName[]} */ ([ "timestamp", "date", @@ -452,21 +423,18 @@ function createIndexSelector(option, state) { .flatMap((blueprint) => blueprint.metric.indexes()), ); - const serializedIndexes = [...rawIndexes].flatMap((index) => { - const c = serdeChartableIndex.serialize(index); - return c ? [c] : []; - }); - return /** @type {any} */ ( - choices_.filter((choice) => serializedIndexes.includes(choice)) + choices_.filter((choice) => + rawIndexes.has(serdeChartableIndex.deserialize(choice)), + ) ); }); /** @type {ChartableIndexName} */ const defaultIndex = "date"; - const field = createChoiceField({ + const field = createReactiveChoiceField({ defaultValue: defaultIndex, - selected: state.index, + selected: chart.indexName, choices, id: "index", signals, @@ -482,10 +450,5 @@ function createIndexSelector(option, state) { fieldset.append(field); fieldset.dataset.size = "sm"; - // Convert short name to internal name - const index = signals.createMemo(() => - serdeChartableIndex.deserialize(state.index()), - ); - - return { fieldset, index }; + return fieldset; } diff --git a/website/scripts/signals.js b/website/scripts/signals.js index 9762a027f..e5cac6061 100644 --- a/website/scripts/signals.js +++ b/website/scripts/signals.js @@ -23,11 +23,9 @@ import { runWithOwner, onCleanup, } from "./modules/solidjs-signals/0.6.3/dist/prod.js"; -import { debounce } from "./utils/timing.js"; -import { writeParam, readParam } from "./utils/url.js"; -import { readStored, writeToStorage } from "./utils/storage.js"; +import { createPersistedValue } from "./utils/persisted.js"; -let effectCount = 0; +// let effectCount = 0; const signals = { createSolidSignal: /** @type {typeof CreateSignal} */ (createSignal), @@ -45,13 +43,13 @@ const signals = { if (dispose) { dispose(); dispose = null; - console.log("effectCount = ", --effectCount); + // console.log("effectCount = ", --effectCount); } } // @ts-ignore createEffect(compute, (v, oldV) => { - console.log("effectCount = ", ++effectCount); + // console.log("effectCount = ", ++effectCount); cleanup(); signals.createRoot((_dispose) => { dispose = _dispose; @@ -74,7 +72,10 @@ const signals = { * @returns {Signal} */ createSignal(initialValue, options) { - const [get, set] = this.createSolidSignal(/** @type {any} */ (initialValue), options); + const [get, set] = this.createSolidSignal( + /** @type {any} */ (initialValue), + options, + ); // @ts-ignore get.set = set; @@ -104,42 +105,24 @@ const signals = { deserialize, saveDefaultValue = false, }) { - const defaultSerialized = serialize(defaultValue); + const persisted = createPersistedValue({ + defaultValue, + storageKey, + urlKey, + serialize, + deserialize, + saveDefaultValue, + }); - // Read: URL > localStorage > default - let serialized = urlKey ? readParam(urlKey) : null; - if (serialized === null) { - serialized = readStored(storageKey); - } - const initialValue = serialized !== null ? deserialize(serialized) : defaultValue; - - const signal = this.createSignal(initialValue); - - /** @param {T} value */ - const write = (value) => { - const s = serialize(value); - const isDefault = s === defaultSerialized; - - if (!isDefault || saveDefaultValue) { - writeToStorage(storageKey, s); - } else { - writeToStorage(storageKey, null); - } - - if (urlKey) { - writeParam(urlKey, !isDefault || saveDefaultValue ? s : null); - } - }; - - const debouncedWrite = debounce(write, 250); + const signal = this.createSignal(persisted.value); + // Sync signal changes to persisted storage let firstRun = true; this.createEffect(signal, (value) => { if (firstRun) { - write(value); firstRun = false; } else { - debouncedWrite(value); + persisted.set(value); } }); diff --git a/website/scripts/utils/dom.js b/website/scripts/utils/dom.js index 4bcf8671f..579752fb1 100644 --- a/website/scripts/utils/dom.js +++ b/website/scripts/utils/dom.js @@ -225,7 +225,7 @@ export function importStyle(href) { * @param {(choice: T) => string} [args.toLabel] - Extract display label (defaults to identity for strings) * @param {"radio" | "select"} [args.type] - Render as radio buttons or select dropdown */ -export function createChoiceField({ +export function createReactiveChoiceField({ id, choices: unsortedChoices, defaultValue, @@ -257,7 +257,8 @@ export function createChoiceField({ }); /** @param {string} key */ - const fromKey = (key) => choices().find((c) => toKey(c) === key) ?? defaultValue; + const fromKey = (key) => + choices().find((c) => toKey(c) === key) ?? defaultValue; const field = window.document.createElement("div"); field.classList.add("field"); @@ -354,6 +355,85 @@ export function createChoiceField({ return field; } +/** + * @template T + * @param {Object} args + * @param {T} args.initialValue + * @param {string} [args.id] + * @param {readonly T[]} args.choices + * @param {(value: T) => void} [args.onChange] + * @param {(choice: T) => string} [args.toKey] + * @param {(choice: T) => string} [args.toLabel] + * @param {"radio" | "select"} [args.type] + */ +export function createChoiceField({ + id, + choices, + initialValue, + onChange, + toKey = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c), + toLabel = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c), + type = "radio", +}) { + const field = window.document.createElement("div"); + field.classList.add("field"); + + const div = window.document.createElement("div"); + field.append(div); + + const initialKey = toKey(initialValue); + + /** @param {string} key */ + const fromKey = (key) => + choices.find((c) => toKey(c) === key) ?? initialValue; + + if (type === "select") { + const select = window.document.createElement("select"); + select.id = id ?? ""; + select.name = id ?? ""; + + choices.forEach((choice) => { + const option = window.document.createElement("option"); + option.value = toKey(choice); + option.textContent = toLabel(choice); + if (toKey(choice) === initialKey) { + option.selected = true; + } + select.append(option); + }); + + select.addEventListener("change", () => { + onChange?.(fromKey(select.value)); + }); + + div.append(select); + } else { + const fieldId = id ?? ""; + choices.forEach((choice) => { + const choiceKey = toKey(choice); + const choiceLabel = toLabel(choice); + const { label } = createLabeledInput({ + inputId: `${fieldId}-${choiceKey.toLowerCase()}`, + inputName: fieldId, + inputValue: choiceKey, + inputChecked: choiceKey === initialKey, + type: "radio", + }); + + const text = window.document.createTextNode(choiceLabel); + label.append(text); + div.append(label); + }); + + field.addEventListener("change", (event) => { + // @ts-ignore + onChange?.(fromKey(event.target.value)); + }); + } + + return field; +} + /** * @param {string} [title] * @param {1 | 2 | 3} [level] diff --git a/website/scripts/utils/persisted.js b/website/scripts/utils/persisted.js new file mode 100644 index 000000000..7f90147b2 --- /dev/null +++ b/website/scripts/utils/persisted.js @@ -0,0 +1,72 @@ +import { readParam, writeParam } from "./url.js"; +import { readStored, writeToStorage } from "./storage.js"; +import { debounce } from "./timing.js"; + +/** + * @template T + * @param {Object} args + * @param {T} args.defaultValue + * @param {string} [args.storageKey] + * @param {string} [args.urlKey] + * @param {(v: T) => string} args.serialize + * @param {(s: string) => T} args.deserialize + * @param {boolean} [args.saveDefaultValue] + */ +export function createPersistedValue({ + defaultValue, + storageKey, + urlKey, + serialize, + deserialize, + saveDefaultValue = false, +}) { + const defaultSerialized = serialize(defaultValue); + + // Read: URL > localStorage > default + let serialized = urlKey ? readParam(urlKey) : null; + if (serialized === null && storageKey) { + serialized = readStored(storageKey); + } + let value = serialized !== null ? deserialize(serialized) : defaultValue; + + /** @param {T} v */ + const write = (v) => { + const s = serialize(v); + const isDefault = s === defaultSerialized; + + if (storageKey) { + if (!isDefault || saveDefaultValue) { + writeToStorage(storageKey, s); + } else { + writeToStorage(storageKey, null); + } + } + + if (urlKey) { + writeParam(urlKey, !isDefault || saveDefaultValue ? s : null); + } + }; + + const debouncedWrite = debounce(write, 250); + + // Write initial value + write(value); + + return { + get value() { + return value; + }, + /** @param {T} v */ + set(v) { + value = v; + debouncedWrite(v); + }, + /** @param {T} v */ + setImmediate(v) { + value = v; + write(v); + }, + }; +} + +/** @typedef {ReturnType} PersistedValue */ diff --git a/website/scripts/utils/serde.js b/website/scripts/utils/serde.js index 3a88fdb60..bbc82fdd7 100644 --- a/website/scripts/utils/serde.js +++ b/website/scripts/utils/serde.js @@ -110,6 +110,10 @@ export const serdeBool = { }, }; +/** + * @typedef {"timestamp" | "date" | "week" | "month" | "quarter" | "semester" | "year" | "decade"} ChartableIndexName + */ + export const serdeChartableIndex = { /** * @param {IndexName} v diff --git a/website/scripts/utils/theme.js b/website/scripts/utils/theme.js index ea28a60bb..824fcd90e 100644 --- a/website/scripts/utils/theme.js +++ b/website/scripts/utils/theme.js @@ -1,4 +1,3 @@ -import signals from "../signals.js"; import { readStored, removeStored, writeToStorage } from "./storage.js"; const preferredColorSchemeMatchMedia = window.matchMedia( @@ -7,7 +6,24 @@ const preferredColorSchemeMatchMedia = window.matchMedia( const stored = readStored("theme"); const initial = stored ? stored === "dark" : preferredColorSchemeMatchMedia.matches; -export const dark = signals.createSignal(initial); +export let dark = initial; + +/** @type {Set<() => void>} */ +const callbacks = new Set(); + +/** @param {() => void} callback */ +export function onChange(callback) { + callbacks.add(callback); + return () => callbacks.delete(callback); +} + +/** @param {boolean} value */ +export function setDark(value) { + if (dark === value) return; + dark = value; + apply(value); + callbacks.forEach((cb) => cb()); +} /** @param {boolean} isDark */ function apply(isDark) { @@ -17,15 +33,13 @@ apply(initial); preferredColorSchemeMatchMedia.addEventListener("change", ({ matches }) => { if (!readStored("theme")) { - dark.set(matches); - apply(matches); + setDark(matches); } }); function invert() { - const newValue = !dark(); - dark.set(newValue); - apply(newValue); + const newValue = !dark; + setDark(newValue); if (newValue === preferredColorSchemeMatchMedia.matches) { removeStored("theme"); } else {