diff --git a/website/scripts/chart/index.js b/website/scripts/chart/index.js index 2eed0c328..fa2e18e4a 100644 --- a/website/scripts/chart/index.js +++ b/website/scripts/chart/index.js @@ -18,7 +18,6 @@ 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"; /** * @typedef {_ISeriesApi} ISeries @@ -39,7 +38,7 @@ import { resources } from "../resources.js"; * @property {string} key * @property {string} id * @property {number} paneIndex - * @property {Signal} active + * @property {PersistedValue} active * @property {(value: boolean) => void} setActive * @property {() => void} show * @property {() => void} hide @@ -47,6 +46,7 @@ import { resources } from "../resources.js"; * @property {() => void} highlight * @property {() => void} tame * @property {() => boolean} hasData + * @property {() => void} [fetch] * @property {string | null} url * @property {() => readonly T[]} getData * @property {(data: T) => void} update @@ -121,7 +121,7 @@ export function createChart({ const range = createPersistedValue({ defaultValue: /** @type {Range | null} */ (null), - urlKey: "range", + urlKey: "r", serialize: (v) => (v ? `${v.from.toFixed(2)}_${v.to.toFixed(2)}` : ""), deserialize: (s) => { if (!s) return null; @@ -143,9 +143,9 @@ export function createChart({ div.classList.add("chart"); parent.append(div); - // Registry for shared legend signals (same name = linked across panes) - /** @type {Map>} */ - const sharedActiveSignals = new Map(); + // Registry for shared active states (same name = linked across panes) + /** @type {Map>} */ + const sharedActiveStates = new Map(); // Registry for linked series (same key = linked across panes) /** @type {Map>} */ @@ -298,16 +298,14 @@ export function createChart({ applyIndexSettings(index()); onIndexChange.add(applyIndexSettings); - const activeResources = /** @type {Set>} */ ( - new Set() - ); - ichart.subscribeCrosshairMove( - throttle(() => { - activeResources.forEach((v) => { - v.fetch(); + // Periodic refresh of active series data + setInterval(() => { + seriesByKey.forEach((set) => { + set.forEach((s) => { + if (s.active.value) s.fetch?.(); }); - }, 10_000), - ); + }); + }, 30_000); if (fitContent) { new ResizeObserver(() => ichart.timeScale().fitContent()).observe(chartDiv); @@ -460,32 +458,33 @@ export function createChart({ const key = stringToId(name); const id = `${key}-${paneIndex}`; - // Reuse existing signal if same name (links legends across panes) - let active = sharedActiveSignals.get(key); - if (!active) { - active = signals.createPersistedSignal({ + // Reuse existing state if same name (links legends across panes) + const existingActive = sharedActiveStates.get(key); + const active = + existingActive ?? + createPersistedValue({ defaultValue: defaultActive ?? true, storageKey: id, urlKey: key, ...serdeBool, }); - sharedActiveSignals.set(key, active); - } + if (!existingActive) sharedActiveStates.set(key, active); setOrder(-order); - active() ? show() : hide(); + active.value ? show() : hide(); let hasData = false; let lastTime = -Infinity; - /** @type {MetricResource | undefined} */ - let _valuesResource; + /** @type {VoidFunction | null} */ + let _fetch = null; /** @type {AnySeries} */ const series = { active, setActive(value) { + const wasActive = active.value; active.set(value); seriesByKey.get(key)?.forEach((s) => { value ? s.show() : s.hide(); @@ -495,6 +494,7 @@ export function createChart({ el.checked = value; } }); + if (value && !wasActive) _fetch?.(); }, setOrder, show, @@ -502,6 +502,7 @@ export function createChart({ highlight, tame, hasData: () => hasData, + fetch: () => _fetch?.(), key, id, paneIndex, @@ -512,9 +513,6 @@ export function createChart({ dispose(); onRemove(); seriesByKey.get(key)?.delete(series); - if (_valuesResource) { - activeResources.delete(_valuesResource); - } }, }; @@ -527,206 +525,167 @@ export function createChart({ keySet.add(series); if (metric) { - /** @type {VoidFunction | null} */ - let disposeIndexEffect = null; - /** @param {ChartableIndex} idx */ function setupIndexEffect(idx) { - if (disposeIndexEffect) { - disposeIndexEffect(); - disposeIndexEffect = null; - } // Reset data state for new index hasData = false; lastTime = -Infinity; - signals.createRoot((_dispose) => { - disposeIndexEffect = _dispose; + _fetch = null; - // Get timestamp metric from tree based on index type - // timestampMonotonic has height only, timestamp has date-based indexes - /** @type {AnyMetricPattern} */ - const timeMetric = - idx === "height" - ? brk.metrics.blocks.time.timestampMonotonic - : brk.metrics.blocks.time.timestamp; - const valuesMetric = /** @type {AnyMetricPattern} */ (metric); - const timeNode = timeMetric.by[idx]; - const valuesNode = valuesMetric.by[idx]; - // Gracefully skip - series may be about to be removed by option change - // TODO: Revisit after the signals are completely gone - if (!timeNode || !valuesNode) return; + // Get timestamp metric from tree based on index type + const timeMetric = + idx === "height" + ? brk.metrics.blocks.time.timestampMonotonic + : brk.metrics.blocks.time.timestamp; + const valuesMetric = /** @type {AnyMetricPattern} */ (metric); + const _timeEndpoint = timeMetric.get(idx); + if (!_timeEndpoint) throw "Expect time endpoint"; + const timeEndpoint = _timeEndpoint; + const valuesEndpoint = valuesMetric.by[idx]; + // Gracefully skip - series may be about to be removed by option change + if (!timeEndpoint || !valuesEndpoint) return; - const timeResource = resources.useMetricEndpoint(timeNode); - const valuesResource = resources.useMetricEndpoint(valuesNode); - _valuesResource = valuesResource; + series.url = `${ + brk.baseUrl.endsWith("/") ? brk.baseUrl.slice(0, -1) : brk.baseUrl + }${valuesEndpoint.path}`; - series.url = `${ - brk.baseUrl.endsWith("/") ? brk.baseUrl.slice(0, -1) : brk.baseUrl - }${valuesResource.path}`; - - (paneIndex ? legendBottom : legendTop).addOrReplace({ - series, - name, - colors, - order, - }); - - // Create memo outside active check (cheap, just checks data existence) - const timeRange = timeResource.range(); - const valuesRange = valuesResource.range(); - const valuesCacheKey = signals.createMemo(() => { - const res = valuesRange.response(); - if (!res?.data?.length) return null; - if (!timeRange.response()?.data?.length) return null; - return `${res.version}|${res.stamp}|${res.total}|${res.start}|${res.end}`; - }); - - // Combined effect for active + data processing (flat, uses prev comparison) - signals.createEffect( - () => ({ isActive: active?.(), cacheKey: valuesCacheKey() }), - (curr, prev) => { - const becameActive = curr.isActive && (!prev || !prev.isActive); - const becameInactive = !curr.isActive && prev?.isActive; - - if (becameInactive) { - activeResources.delete(valuesResource); - return; - } - - if (!curr.isActive) return; - - if (becameActive) { - timeResource.fetch(); - valuesResource.fetch(); - activeResources.add(valuesResource); - } - - // Process data only if cacheKey changed - if (!curr.cacheKey || curr.cacheKey === prev?.cacheKey) return; - - const _indexes = timeRange.response()?.data; - const values = valuesRange.response()?.data; - if (!_indexes?.length || !values?.length) return; - - const indexes = /** @type {number[]} */ (_indexes); - const length = Math.min(indexes.length, values.length); - - // Find start index for processing - let startIdx = 0; - if (hasData) { - // Binary search to find first index where time >= lastTime - let lo = 0; - let hi = length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - if (indexes[mid] < lastTime) { - lo = mid + 1; - } else { - hi = mid; - } - } - startIdx = lo; - if (startIdx >= length) return; // No new data - } - - /** - * @param {number} i - * @param {(number | null | [number, number, number, number])[]} vals - * @returns {LineData | CandlestickData} - */ - function buildDataPoint(i, vals) { - const time = /** @type {Time} */ (indexes[i]); - const v = vals[i]; - if (v === null) { - return { time, value: NaN }; - } else if (typeof v === "number") { - return { time, value: v }; - } else { - if (!Array.isArray(v) || v.length !== 4) - throw new Error(`Expected OHLC tuple, got: ${v}`); - const [open, high, low, close] = v; - return { time, open, high, low, close }; - } - } - - if (!hasData) { - // Initial load: build full array - const data = /** @type {LineData[] | CandlestickData[]} */ ( - Array.from({ length }) - ); - - let prevTime = null; - let timeOffset = 0; - - for (let i = 0; i < length; i++) { - const time = indexes[i]; - const sameTime = prevTime === time; - if (sameTime) { - timeOffset += 1; - } - const offsetedI = i - timeOffset; - const point = buildDataPoint(i, values); - if (sameTime && "open" in point) { - const prev = /** @type {CandlestickData} */ ( - data[offsetedI] - ); - point.open = prev.open; - point.high = Math.max(prev.high, point.high); - point.low = Math.min(prev.low, point.low); - } - data[offsetedI] = point; - prevTime = time; - } - - data.length -= timeOffset; - - setData(data); - hasData = true; - lastTime = - /** @type {number} */ (data.at(-1)?.time) ?? -Infinity; - - // 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 ( - idx === "quarterindex" || - idx === "semesterindex" || - idx === "yearindex" || - idx === "decadeindex" - ) { - ichart - .timeScale() - .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++) { - const point = buildDataPoint(i, values); - update(point); - lastTime = /** @type {number} */ (point.time); - } - } - }, - ); + (paneIndex ? legendBottom : legendTop).addOrReplace({ + series, + name, + colors, + order, }); + + /** + * @param {number[]} indexes + * @param {(number | null | [number, number, number, number])[]} values + */ + function processData(indexes, values) { + const length = Math.min(indexes.length, values.length); + + // Find start index for processing + let startIdx = 0; + if (hasData) { + // Binary search to find first index where time >= lastTime + let lo = 0; + let hi = length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (indexes[mid] < lastTime) { + lo = mid + 1; + } else { + hi = mid; + } + } + startIdx = lo; + if (startIdx >= length) return; // No new data + } + + /** + * @param {number} i + * @returns {LineData | CandlestickData} + */ + function buildDataPoint(i) { + const time = /** @type {Time} */ (indexes[i]); + const v = values[i]; + if (v === null) { + return { time, value: NaN }; + } else if (typeof v === "number") { + return { time, value: v }; + } else { + if (!Array.isArray(v) || v.length !== 4) + throw new Error(`Expected OHLC tuple, got: ${v}`); + const [open, high, low, close] = v; + return { time, open, high, low, close }; + } + } + + if (!hasData) { + // Initial load: build full array + const data = /** @type {LineData[] | CandlestickData[]} */ ( + Array.from({ length }) + ); + + let prevTime = null; + let timeOffset = 0; + + for (let i = 0; i < length; i++) { + const time = indexes[i]; + const sameTime = prevTime === time; + if (sameTime) { + timeOffset += 1; + } + const offsetedI = i - timeOffset; + const point = buildDataPoint(i); + if (sameTime && "open" in point) { + const prev = /** @type {CandlestickData} */ (data[offsetedI]); + point.open = prev.open; + point.high = Math.max(prev.high, point.high); + point.low = Math.min(prev.low, point.low); + } + data[offsetedI] = point; + prevTime = time; + } + + data.length -= timeOffset; + + setData(data); + hasData = true; + lastTime = /** @type {number} */ (data.at(-1)?.time) ?? -Infinity; + + // 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 ( + idx === "quarterindex" || + idx === "semesterindex" || + idx === "yearindex" || + idx === "decadeindex" + ) { + ichart + .timeScale() + .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++) { + const point = buildDataPoint(i); + update(point); + lastTime = /** @type {number} */ (point.time); + } + } + } + + async function fetchAndProcess() { + const [timeResult, valuesResult] = await Promise.all([ + timeEndpoint.slice(-10000).fetch(), + valuesEndpoint?.slice(-10000).fetch(), + ]); + if (timeResult?.data?.length && valuesResult?.data?.length) { + processData(timeResult.data, valuesResult.data); + } + } + + _fetch = fetchAndProcess; + + // Initial fetch if active + if (active.value) { + fetchAndProcess(); + } } setupIndexEffect(index()); - onIndexChange.add(setupIndexEffect); - signals.onCleanup(() => { - onIndexChange.delete(setupIndexEffect); - if (disposeIndexEffect) { - disposeIndexEffect(); - } - }); + // Series don't subscribe to onIndexChange - panes recreates them on index change + // onIndexChange.add(setupIndexEffect); + // _cleanup = () => onIndexChange.delete(setupIndexEffect); } else { (paneIndex ? legendBottom : legendTop).addOrReplace({ series, @@ -767,6 +726,7 @@ export function createChart({ const chart = { index, indexName, + onIndexChange, legendTop, legendBottom, diff --git a/website/scripts/chart/legend.js b/website/scripts/chart/legend.js index b8203fa9e..d9c9611e8 100644 --- a/website/scripts/chart/legend.js +++ b/website/scripts/chart/legend.js @@ -55,7 +55,7 @@ export function createLegend() { inputName: stringToId(`selected-${series.id}`), inputValue: "value", title: "Click to toggle", - inputChecked: series.active(), + inputChecked: series.active.value, onClick: () => { series.setActive(input.checked); }, diff --git a/website/scripts/entry.js b/website/scripts/entry.js index 6c0be0f62..e337cb194 100644 --- a/website/scripts/entry.js +++ b/website/scripts/entry.js @@ -10,6 +10,8 @@ * * @import { Resources, MetricResource } from './resources.js' * + * @import { PersistedValue } from './utils/persisted.js' + * * @import { SingleValueData, CandlestickData, Series, AnySeries, ISeries, HistogramData, LineData, BaselineData, LineSeriesPartialOptions, BaselineSeriesPartialOptions, HistogramSeriesPartialOptions, CandlestickSeriesPartialOptions, Chart, Legend } from "./chart/index.js" * * @import { Color, ColorName, Colors } from "./chart/colors.js" diff --git a/website/scripts/panes/chart.js b/website/scripts/panes/chart.js index 4479b67aa..99c39acaa 100644 --- a/website/scripts/panes/chart.js +++ b/website/scripts/panes/chart.js @@ -38,12 +38,16 @@ export function init({ option, brk }) { const fieldset = createIndexSelector(option, chart); chartElement.append(fieldset); + // Bridge chart's index changes into signals system + const indexVersion = signals.createSignal(0); + chart.onIndexChange.add(() => indexVersion.set(indexVersion() + 1)); + const unitChoices = /** @type {const} */ ([Unit.usd, Unit.sats]); /** @type {Signal} */ const topUnit = signals.createPersistedSignal({ defaultValue: /** @type {Unit} */ (Unit.usd), storageKey: `${keyPrefix}-price`, - urlKey: "price", + urlKey: "u1", serialize: (u) => u.id, deserialize: (s) => /** @type {Unit} */ (unitChoices.find((u) => u.id === s) ?? Unit.usd), @@ -170,7 +174,7 @@ export function init({ option, brk }) { bottomUnit = signals.createPersistedSignal({ defaultValue: bottomUnits[0], storageKey: `${keyPrefix}-unit-${unitGroupKey}`, - urlKey: "unit", + urlKey: "u2", serialize: (u) => u.id, deserialize: (s) => bottomUnits.find((u) => u.id === s) ?? bottomUnits[0], @@ -313,10 +317,13 @@ export function init({ option, brk }) { }); } - // Price series + top pane blueprints: combined effect on index + topUnit + // Price series + top pane blueprints: react to topUnit and index changes signals.createScopedEffect( - () => ({ idx: chart.index(), unit: topUnit() }), - ({ idx, unit }) => { + () => ({ unit: topUnit(), _: indexVersion() }), + ({ unit }) => { + // Remove old series BEFORE creating new one + seriesListTop[0]?.remove(); + // Create price series /** @type {AnySeries | undefined} */ let series; @@ -343,7 +350,6 @@ export function init({ option, brk }) { } if (!series) throw Error("Unreachable"); - seriesListTop[0]?.remove(); seriesListTop[0] = series; // Live price update effect @@ -354,7 +360,7 @@ export function init({ option, brk }) { }), ({ latest, hasData }) => { if (!series || !latest || !hasData) return; - printLatest({ series, unit, index: idx }); + printLatest({ series, unit, index: chart.index() }); }, ); @@ -363,7 +369,7 @@ export function init({ option, brk }) { blueprints: option.top, paneIndex: 0, unit, - idx, + idx: chart.index(), seriesList: seriesListTop, orderStart: 1, legend: chart.legendTop, @@ -371,16 +377,16 @@ export function init({ option, brk }) { }, ); - // Bottom pane blueprints: combined effect on index + bottomUnit + // Bottom pane blueprints: react to bottomUnit and index changes if (bottomUnit) { signals.createScopedEffect( - () => ({ idx: chart.index(), unit: bottomUnit() }), - ({ idx, unit }) => { + () => ({ unit: bottomUnit(), _: indexVersion() }), + ({ unit }) => { createSeriesFromBlueprints({ blueprints: option.bottom, paneIndex: 1, unit, - idx, + idx: chart.index(), seriesList: seriesListBottom, orderStart: 0, legend: chart.legendBottom, diff --git a/website/scripts/utils/persisted.js b/website/scripts/utils/persisted.js index 915104b8f..369c0fc58 100644 --- a/website/scripts/utils/persisted.js +++ b/website/scripts/utils/persisted.js @@ -73,4 +73,7 @@ export function createPersistedValue({ }; } -/** @typedef {ReturnType} PersistedValue */ +/** + * @template T + * @typedef {ReturnType>} PersistedValue + */