diff --git a/Cargo.lock b/Cargo.lock index 27a69fa0e..24ad0f751 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -573,6 +573,7 @@ dependencies = [ "brk_indexer", "brk_logger", "brk_types", + "memmap2", "plotters", "tracing", "vecdb", diff --git a/crates/brk_computer/src/distribution/vecs.rs b/crates/brk_computer/src/distribution/vecs.rs index bbba53b49..3694f7620 100644 --- a/crates/brk_computer/src/distribution/vecs.rs +++ b/crates/brk_computer/src/distribution/vecs.rs @@ -252,8 +252,7 @@ impl Vecs { starting_indexes.dateindex = indexes .height .dateindex - .read_once(starting_height.decremented().unwrap())? - .into(); + .read_once(starting_height.decremented().unwrap())?; } } diff --git a/crates/brk_playground/Cargo.toml b/crates/brk_playground/Cargo.toml index ed19b8837..dd2371431 100644 --- a/crates/brk_playground/Cargo.toml +++ b/crates/brk_playground/Cargo.toml @@ -15,6 +15,7 @@ brk_fetcher = { workspace = true } brk_indexer = { workspace = true } brk_logger = { workspace = true } brk_types = { workspace = true } +memmap2 = "0.9" plotters = "0.3" tracing = { workspace = true } vecdb = { workspace = true } diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index 53462729f..a36623ef9 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -839,9 +839,11 @@ class BrkError extends Error { /** * @template T * @typedef {Object} MetricData + * @property {number} version - Data version number * @property {number} total - Total number of data points * @property {number} start - Start index (inclusive) * @property {number} end - End index (exclusive) + * @property {string} stamp - Last update timestamp (ISO 8601) * @property {T[]} data - The metric data */ /** @typedef {MetricData} AnyMetricData */ diff --git a/website/scripts/chart/index.js b/website/scripts/chart/index.js index 6203a7ca3..9570e6593 100644 --- a/website/scripts/chart/index.js +++ b/website/scripts/chart/index.js @@ -6,27 +6,19 @@ import { BaselineSeries, // } from "../modules/lightweight-charts/5.1.0/dist/lightweight-charts.standalone.development.mjs"; } from "../modules/lightweight-charts/5.1.0/dist/lightweight-charts.standalone.production.mjs"; +import { createMinMaxMarkers } from "./markers.js"; +import { createLegend } from "./legend.js"; const createChart = /** @type {CreateChart} */ (_createChart); -import { - createChoiceField, - createLabeledInput, - createSpanName, -} from "../utils/dom.js"; +import { createChoiceField } from "../utils/dom.js"; import { createOklchToRGBA } from "./oklch.js"; import { throttle } from "../utils/timing.js"; import { serdeBool } from "../utils/serde.js"; -import { stringToId } from "../utils/format.js"; +import { stringToId, numberToShortUSFormat } from "../utils/format.js"; import { style } from "../utils/elements.js"; import { resources } from "../resources.js"; /** - * @typedef {Object} Valued - * @property {number} value - * - * @typedef {Object} Indexed - * @property {number} index - * * @typedef {_ISeriesApi} ISeries * @typedef {_ISeriesApi<'Candlestick'>} CandlestickISeries * @typedef {_ISeriesApi<'Histogram'>} HistogramISeries @@ -43,6 +35,8 @@ import { resources } from "../resources.js"; * @template T * @typedef {Object} Series * @property {string} id + * @property {() => ISeries} inner + * @property {number} paneIndex * @property {Signal} active * @property {Signal} hasData * @property {Signal} url @@ -83,6 +77,7 @@ const lineWidth = /** @type {any} */ (1.5); * @param {BrkClient} args.brk * @param {Accessor} args.index * @param {((unknownTimeScaleCallback: VoidFunction) => void)} [args.timeScaleSetCallback] + * @param {number | null} [args.initialVisibleBarsCount] * @param {true} [args.fitContent] * @param {{unit: Unit; blueprints: AnySeriesBlueprint[]}[]} [args.config] */ @@ -94,6 +89,7 @@ export function createChartElement({ index, brk, timeScaleSetCallback, + initialVisibleBarsCount, fitContent, config, }) { @@ -131,6 +127,7 @@ export function createChartElement({ }, timeScale: { borderVisible: false, + enableConflation: true, ...(fitContent ? { minBarSpacing: 0.001, @@ -160,13 +157,52 @@ export function createChartElement({ ichart.panes().at(0)?.setStretchFactor(1); - const visibleBarsCount = signals.createSignal(0); - ichart.timeScale().subscribeVisibleLogicalRangeChange((range) => { - if (range) { - visibleBarsCount.set(range.to - range.from); - } + /** @param {{ from: number, to: number }} range */ + const setVisibleLogicalRange = (range) => { + // Defer to next frame to ensure chart has rendered + requestAnimationFrame(() => { + ichart.timeScale().setVisibleLogicalRange(range); + }); + }; + + const seriesList = signals.createSignal(/** @type {Set} */ (new Set()), { equals: false }); + const seriesCount = signals.createMemo(() => seriesList().size); + const markers = createMinMaxMarkers({ + chart: ichart, + seriesList, + colors, + formatValue: numberToShortUSFormat, }); + const visibleBarsCount = signals.createSignal( + initialVisibleBarsCount ?? Infinity, + ); + /** @type {() => 0 | 1 | 2 | 3} 0: <=200, 1: <=500, 2: <=1000, 3: >1000 */ + const visibleBarsCountBucket = signals.createMemo(() => { + const count = visibleBarsCount(); + return count > 1000 ? 3 : count > 500 ? 2 : count > 200 ? 1 : 0; + }); + const shouldShowLine = signals.createMemo( + () => visibleBarsCountBucket() >= 2, + ); + const shouldUpdateMarkers = signals.createMemo( + () => visibleBarsCount() * seriesCount() <= 5000, + ); + + signals.createEffect(shouldUpdateMarkers, (should) => { + if (should) markers.update(); + else markers.clear(); + }); + + ichart.timeScale().subscribeVisibleLogicalRangeChange( + throttle((range) => { + if (range) { + visibleBarsCount.set(range.to - range.from); + if (shouldUpdateMarkers()) markers.update(); + } + }, 100), + ); + signals.createEffect( () => ({ defaultColor: colors.default(), @@ -330,6 +366,7 @@ export function createChartElement({ * @param {number} args.order * @param {Color[]} args.colors * @param {LCSeriesType} args.seriesType + * @param {() => ISeries} args.inner * @param {AnyMetricPattern} [args.metric] * @param {Accessor} [args.data] * @param {number} args.paneIndex @@ -343,6 +380,7 @@ export function createChartElement({ * @param {() => void} args.onRemove */ function addSeries({ + inner, metric, name, unit, @@ -384,6 +422,8 @@ export function createChartElement({ active, hasData, id, + inner, + paneIndex, url: signals.createSignal(/** @type {string | null} */ (null)), getOptions, applyOptions, @@ -395,11 +435,16 @@ export function createChartElement({ if (_valuesResource) { activeResources.delete(_valuesResource); } + seriesList().delete(series); + seriesList.set(seriesList()); }, }; + seriesList().add(series); + seriesList.set(seriesList()); + if (metric) { - signals.createEffect(index, (index) => { + signals.createScopedEffect(index, (index) => { // Get timestamp metric from tree based on index type // timestampMonotonic has height only, timestamp has date-based indexes /** @type {AnyMetricPattern} */ @@ -425,7 +470,7 @@ export function createChartElement({ return `${base}${valuesResource.path}`; }); - signals.createEffect(active, (active) => { + signals.createScopedEffect(active, (active) => { if (active) { timeResource.fetch(); valuesResource.fetch(); @@ -433,19 +478,61 @@ export function createChartElement({ const timeRange = timeResource.range(); const valuesRange = valuesResource.range(); - signals.createEffect( - () => ({ - _indexes: timeRange.response()?.data, - values: valuesRange.response()?.data, - }), - ({ _indexes, values }) => { - if (!_indexes?.length || !values?.length) return; + 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}`; + }); + signals.createEffect(valuesCacheKey, (cacheKey) => { + if (!cacheKey) return; + const _indexes = timeRange.response()?.data; + const values = valuesRange.response()?.data; + if (!_indexes?.length || !values?.length) return; - const indexes = /** @type {number[]} */ (_indexes); + const indexes = /** @type {number[]} */ (_indexes); + const length = Math.min(indexes.length, values.length); - let 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 + } - // TODO: Don't create new Array if data already present, update instead + /** + * @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 }) ); @@ -454,84 +541,56 @@ export function createChartElement({ let timeOffset = 0; for (let i = 0; i < length; i++) { - const time = /** @type {Time} */ (indexes[i]); + const time = indexes[i]; const sameTime = prevTime === time; if (sameTime) { timeOffset += 1; } - const v = values[i]; const offsetedI = i - timeOffset; - if (v === null) { - data[offsetedI] = { - time, - value: NaN, - }; - } else if (typeof v === "number") { - data[offsetedI] = { - time, - value: v, - }; - } else { - // if (sameTime) { - // console.log(data[offsetedI]); - // } - if (!Array.isArray(v) || v.length !== 4) - throw new Error(`Expected OHLC tuple, got: ${v}`); - let [open, high, low, close] = v; - data[offsetedI] = { - time, - // @ts-ignore - open: sameTime ? data[offsetedI].open : open, - high: sameTime - ? // @ts-ignore - Math.max(data[offsetedI].high, high) - : high, - low: sameTime - ? // @ts-ignore - Math.min(data[offsetedI].low, low) - : low, - close, - }; + 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; - if (!hasData()) { - setData(data); - hasData.set(true); - lastTime = - /** @type {number} */ (data.at(-1)?.time) ?? -Infinity; + setData(data); + hasData.set(true); + if (shouldUpdateMarkers()) markers.scheduleUpdate(); + lastTime = + /** @type {number} */ (data.at(-1)?.time) ?? -Infinity; - if (fitContent) { - ichart.timeScale().fitContent(); - } - - timeScaleSetCallback?.(() => { - if ( - index === "quarterindex" || - index === "semesterindex" || - index === "yearindex" || - index === "decadeindex" - ) { - ichart.timeScale().setVisibleLogicalRange({ - from: -1, - to: data.length, - }); - } - }); - } else { - for (let i = 0; i < data.length; i++) { - const time = /** @type {number} */ (data[i].time); - if (time >= lastTime) { - update(data[i]); - lastTime = time; - } - } + if (fitContent) { + ichart.timeScale().fitContent(); } - }, - ); + + timeScaleSetCallback?.(() => { + if ( + index === "quarterindex" || + index === "semesterindex" || + index === "yearindex" || + index === "decadeindex" + ) { + setVisibleLogicalRange({ from: -1, to: data.length }); + } + }); + } 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); + } + } + }); } else { activeResources.delete(valuesResource); } @@ -541,6 +600,7 @@ export function createChartElement({ signals.createEffect(data, (data) => { setData(data); hasData.set(true); + if (shouldUpdateMarkers()) markers.scheduleUpdate(); if (fitContent) { ichart.timeScale().fitContent(); @@ -566,12 +626,23 @@ export function createChartElement({ } const chart = { - inner: ichart, 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 @@ -601,6 +672,7 @@ export function createChartElement({ const defaultRed = inverse ? colors.green : colors.red; const upColor = customColors?.[0] ?? defaultGreen; const downColor = customColors?.[1] ?? defaultRed; + let showLine = shouldShowLine(); /** @type {CandlestickISeries} */ const candlestickISeries = /** @type {any} */ ( @@ -612,7 +684,7 @@ export function createChartElement({ wickUpColor: upColor(), wickDownColor: downColor(), borderVisible: false, - visible: defaultActive !== false, + visible: false, ...options, }, paneIndex, @@ -633,9 +705,8 @@ export function createChartElement({ ) ); - let showLine = false; - - return addSeries({ + const series = addSeries({ + inner: () => (showLine ? lineISeries : candlestickISeries), colors: [upColor, downColor], name, order, @@ -649,14 +720,22 @@ export function createChartElement({ candlestickISeries.setSeriesOrder(order); lineISeries.setSeriesOrder(order); signals.createEffect( - () => ({ count: visibleBarsCount(), active: active() }), - ({ count, active }) => { - showLine = count > 500; + () => ({ + shouldShow: shouldShowLine(), + active: active(), + barsCount: visibleBarsCount(), + }), + ({ shouldShow, active, barsCount }) => { + if (barsCount === Infinity) return; + const wasLine = showLine; + showLine = shouldShow; candlestickISeries.applyOptions({ visible: active && !showLine }); lineISeries.applyOptions({ visible: active && showLine, priceLineVisible: active && showLine, }); + if (wasLine !== showLine && shouldUpdateMarkers()) + markers.scheduleUpdate(); }, ); }, @@ -681,6 +760,7 @@ export function createChartElement({ ichart.removeSeries(lineISeries); }, }); + return series; }, /** * @param {Object} args @@ -723,7 +803,8 @@ export function createChartElement({ ) ); - return addSeries({ + const series = addSeries({ + inner: () => iseries, colors: isDualColor ? [positiveColor, negativeColor] : [positiveColor], name, order, @@ -760,6 +841,7 @@ export function createChartElement({ applyOptions: (options) => iseries.applyOptions(options), onRemove: () => ichart.removeSeries(iseries), }); + return series; }, /** * @param {Object} args @@ -801,7 +883,8 @@ export function createChartElement({ ) ); - return addSeries({ + const series = addSeries({ + inner: () => iseries, colors: [color], name, order, @@ -824,6 +907,7 @@ export function createChartElement({ applyOptions: (options) => iseries.applyOptions(options), onRemove: () => ichart.removeSeries(iseries), }); + return series; }, /** * @param {Object} args @@ -867,7 +951,8 @@ export function createChartElement({ ) ); - return addSeries({ + const series = addSeries({ + inner: () => iseries, colors: [color], name, order, @@ -882,8 +967,8 @@ export function createChartElement({ signals.createEffect(active, (active) => iseries.applyOptions({ visible: active }), ); - signals.createEffect(visibleBarsCount, (count) => { - const radius = count > 1000 ? 1 : count > 200 ? 1.5 : 2; + signals.createEffect(visibleBarsCountBucket, (bucket) => { + const radius = bucket === 3 ? 1 : bucket >= 1 ? 1.5 : 2; iseries.applyOptions({ pointMarkersRadius: radius }); }); }, @@ -894,6 +979,7 @@ export function createChartElement({ applyOptions: (options) => iseries.applyOptions(options), onRemove: () => ichart.removeSeries(iseries), }); + return series; }, /** * @param {Object} args @@ -942,7 +1028,8 @@ export function createChartElement({ ) ); - return addSeries({ + const series = addSeries({ + inner: () => iseries, colors: [ () => options?.topLineColor ?? colors.green(), () => options?.bottomLineColor ?? colors.red(), @@ -968,6 +1055,7 @@ export function createChartElement({ applyOptions: (options) => iseries.applyOptions(options), onRemove: () => ichart.removeSeries(iseries), }); + return series; }, }; @@ -1028,213 +1116,6 @@ export function createChartElement({ return chart; } -/** - * @param {Signals} signals - */ -function createLegend(signals) { - const element = window.document.createElement("legend"); - - const hovered = signals.createSignal(/** @type {AnySeries | null} */ (null)); - - /** @type {HTMLElement[]} */ - const legends = []; - - return { - element, - /** - * @param {Object} args - * @param {AnySeries} args.series - * @param {string} args.name - * @param {number} args.order - * @param {Color[]} args.colors - */ - addOrReplace({ series, name, colors, order }) { - const div = window.document.createElement("div"); - - const prev = legends[order]; - if (prev) { - prev.replaceWith(div); - } else { - const elementAtOrder = Array.from(element.children).at(order); - if (elementAtOrder) { - elementAtOrder.before(div); - } else { - element.append(div); - } - } - legends[order] = div; - - const { input, label } = createLabeledInput({ - inputId: stringToId(`legend-${series.id}`), - inputName: stringToId(`selected-${series.id}`), - inputValue: "value", - title: "Click to toggle", - inputChecked: series.active(), - onClick: () => { - series.active.set(input.checked); - }, - type: "checkbox", - }); - - const spanMain = window.document.createElement("span"); - spanMain.classList.add("main"); - label.append(spanMain); - - const spanName = createSpanName(name); - spanMain.append(spanName); - - div.append(label); - label.addEventListener("mouseover", () => { - const h = hovered(); - if (!h || h !== series) { - hovered.set(series); - } - }); - label.addEventListener("mouseleave", () => { - hovered.set(null); - }); - - function shouldHighlight() { - const h = hovered(); - return !h || h === series; - } - - /** - * @param {string} color - */ - function tameColor(color) { - return `${color.slice(0, -1)} / 50%)`; - } - - const spanColors = window.document.createElement("span"); - spanColors.classList.add("colors"); - spanMain.prepend(spanColors); - colors.forEach((color) => { - const spanColor = window.document.createElement("span"); - spanColors.append(spanColor); - - signals.createEffect( - () => ({ - color: color(), - shouldHighlight: shouldHighlight(), - }), - ({ color, shouldHighlight }) => { - if (shouldHighlight) { - spanColor.style.backgroundColor = color; - } else { - spanColor.style.backgroundColor = tameColor(color); - } - }, - ); - }); - - const initialColors = /** @type {Record} */ ({}); - const darkenedColors = /** @type {Record} */ ({}); - - const seriesOptions = series.getOptions(); - if (!seriesOptions) return; - - Object.entries(seriesOptions).forEach(([k, v]) => { - if (k.toLowerCase().includes("color") && typeof v === "string") { - if (!v.startsWith("oklch")) return; - initialColors[k] = v; - darkenedColors[k] = tameColor(v); - } else if (k === "lastValueVisible" && v) { - initialColors[k] = true; - darkenedColors[k] = false; - } - }); - - signals.createEffect(shouldHighlight, (shouldHighlight) => { - if (shouldHighlight) { - series.applyOptions(initialColors); - } else { - series.applyOptions(darkenedColors); - } - }); - - const anchor = window.document.createElement("a"); - - signals.createEffect(series.url, (url) => { - if (url) { - anchor.href = url; - anchor.target = "_blank"; - anchor.rel = "noopener noreferrer"; - anchor.title = "Click to view data"; - div.append(anchor); - } - }); - }, - /** - * @param {number} start - */ - removeFrom(start) { - // disposeFrom(start); - legends.splice(start).forEach((child) => child.remove()); - }, - }; -} - -/** - * @param {number} value - * @param {0 | 2} [digits] - */ -function numberToShortUSFormat(value, digits) { - const absoluteValue = Math.abs(value); - - if (isNaN(value)) { - return ""; - } else if (absoluteValue < 10) { - return numberToUSFormat(value, Math.min(3, digits || 10)); - } else if (absoluteValue < 1_000) { - return numberToUSFormat(value, Math.min(2, digits || 10)); - } else if (absoluteValue < 10_000) { - return numberToUSFormat(value, Math.min(1, digits || 10)); - } else if (absoluteValue < 1_000_000) { - return numberToUSFormat(value, 0); - } else if (absoluteValue >= 1_000_000_000_000_000_000_000) { - return "Inf."; - } - - const log = Math.floor(Math.log10(absoluteValue) - 6); - - const suffices = ["M", "B", "T", "P", "E", "Z"]; - const letterIndex = Math.floor(log / 3); - const letter = suffices[letterIndex]; - - const modulused = log % 3; - - if (modulused === 0) { - return `${numberToUSFormat( - value / (1_000_000 * 1_000 ** letterIndex), - 3, - )}${letter}`; - } else if (modulused === 1) { - return `${numberToUSFormat( - value / (1_000_000 * 1_000 ** letterIndex), - 2, - )}${letter}`; - } else { - return `${numberToUSFormat( - value / (1_000_000 * 1_000 ** letterIndex), - 1, - )}${letter}`; - } -} - -/** - * @param {number} value - * @param {number} [digits] - * @param {Intl.NumberFormatOptions} [options] - */ -function numberToUSFormat(value, digits, options) { - return value.toLocaleString("en-us", { - ...options, - minimumFractionDigits: digits, - maximumFractionDigits: digits, - }); -} - /** * @typedef {typeof createChartElement} CreateChartElement * @typedef {ReturnType} Chart diff --git a/website/scripts/chart/legend.js b/website/scripts/chart/legend.js new file mode 100644 index 000000000..1a09a12f2 --- /dev/null +++ b/website/scripts/chart/legend.js @@ -0,0 +1,141 @@ +import { createLabeledInput, createSpanName } from "../utils/dom.js"; +import { stringToId } from "../utils/format.js"; + +/** @param {string} color */ +const tameColor = (color) => `${color.slice(0, -1)} / 50%)`; + +/** + * @param {Signals} signals + */ +export function createLegend(signals) { + const element = window.document.createElement("legend"); + + const hovered = signals.createSignal(/** @type {AnySeries | null} */ (null)); + + /** @type {HTMLElement[]} */ + const legends = []; + + return { + element, + /** + * @param {Object} args + * @param {AnySeries} args.series + * @param {string} args.name + * @param {number} args.order + * @param {Color[]} args.colors + */ + addOrReplace({ series, name, colors, order }) { + const div = window.document.createElement("div"); + + const prev = legends[order]; + if (prev) { + prev.replaceWith(div); + } else { + const elementAtOrder = Array.from(element.children).at(order); + if (elementAtOrder) { + elementAtOrder.before(div); + } else { + element.append(div); + } + } + legends[order] = div; + + const { input, label } = createLabeledInput({ + inputId: stringToId(`legend-${series.id}`), + inputName: stringToId(`selected-${series.id}`), + inputValue: "value", + title: "Click to toggle", + inputChecked: series.active(), + onClick: () => { + series.active.set(input.checked); + }, + type: "checkbox", + }); + + const spanMain = window.document.createElement("span"); + spanMain.classList.add("main"); + label.append(spanMain); + + const spanName = createSpanName(name); + spanMain.append(spanName); + + div.append(label); + label.addEventListener("mouseover", () => { + const h = hovered(); + if (!h || h !== series) { + hovered.set(series); + } + }); + label.addEventListener("mouseleave", () => { + hovered.set(null); + }); + + const shouldHighlight = () => !hovered() || hovered() === series; + + const spanColors = window.document.createElement("span"); + spanColors.classList.add("colors"); + spanMain.prepend(spanColors); + colors.forEach((color) => { + const spanColor = window.document.createElement("span"); + spanColors.append(spanColor); + + signals.createEffect( + () => ({ + color: color(), + shouldHighlight: shouldHighlight(), + }), + ({ color, shouldHighlight }) => { + if (shouldHighlight) { + spanColor.style.backgroundColor = color; + } else { + spanColor.style.backgroundColor = tameColor(color); + } + }, + ); + }); + + const initialColors = /** @type {Record} */ ({}); + const darkenedColors = /** @type {Record} */ ({}); + + const seriesOptions = series.getOptions(); + if (!seriesOptions) return; + + Object.entries(seriesOptions).forEach(([k, v]) => { + if (k.toLowerCase().includes("color") && typeof v === "string") { + if (!v.startsWith("oklch")) return; + initialColors[k] = v; + darkenedColors[k] = tameColor(v); + } else if (k === "lastValueVisible" && v) { + initialColors[k] = true; + darkenedColors[k] = false; + } + }); + + signals.createEffect(shouldHighlight, (shouldHighlight) => { + if (shouldHighlight) { + series.applyOptions(initialColors); + } else { + series.applyOptions(darkenedColors); + } + }); + + const anchor = window.document.createElement("a"); + + signals.createEffect(series.url, (url) => { + if (url) { + anchor.href = url; + anchor.target = "_blank"; + anchor.rel = "noopener noreferrer"; + anchor.title = "Click to view data"; + div.append(anchor); + } + }); + }, + /** + * @param {number} start + */ + removeFrom(start) { + legends.splice(start).forEach((child) => child.remove()); + }, + }; +} diff --git a/website/scripts/chart/markers.js b/website/scripts/chart/markers.js new file mode 100644 index 000000000..b516f55c7 --- /dev/null +++ b/website/scripts/chart/markers.js @@ -0,0 +1,143 @@ +import { createSeriesMarkers } from "../modules/lightweight-charts/5.1.0/dist/lightweight-charts.standalone.production.mjs"; +import { throttle } from "../utils/timing.js"; + +/** + * @param {Object} args + * @param {IChartApi} args.chart + * @param {Accessor>} args.seriesList + * @param {Colors} args.colors + * @param {(value: number) => string} args.formatValue + */ +export function createMinMaxMarkers({ chart, seriesList, colors, formatValue }) { + /** @type {WeakMap} */ + const pluginCache = new WeakMap(); + + /** @param {ISeries} iseries */ + function getOrCreatePlugin(iseries) { + let plugin = pluginCache.get(iseries); + if (!plugin) { + plugin = createSeriesMarkers(iseries, [], { autoScale: false }); + pluginCache.set(iseries, plugin); + } + return plugin; + } + + /** @type {Set} */ + const prevMarkerSeries = new Set(); + + function update() { + const timeScale = chart.timeScale(); + const width = timeScale.width(); + const range = timeScale.getVisibleRange(); + if (!range) return; + + const tLeft = timeScale.coordinateToTime(30); + const tRight = timeScale.coordinateToTime(width - 30); + const t0 = /** @type {number} */ (tLeft ?? range.from); + const t1 = /** @type {number} */ (tRight ?? range.to); + const color = colors.gray(); + + /** @type {Map} */ + const byPane = new Map(); + + for (const series of seriesList()) { + if (!series.active() || !series.hasData()) continue; + + const data = series.getData(); + const len = data.length; + if (!len) continue; + + // Binary search for start + let lo = 0, hi = len; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (/** @type {number} */ (data[mid].time) < t0) lo = mid + 1; + else hi = mid; + } + if (lo >= len) continue; + + const paneIndex = series.paneIndex; + const iseries = series.inner(); + let pane = byPane.get(paneIndex); + if (!pane) { + pane = { + minV: Infinity, + minT: /** @type {Time} */ (0), + minS: iseries, + maxV: -Infinity, + maxT: /** @type {Time} */ (0), + maxS: iseries, + }; + byPane.set(paneIndex, pane); + } + + for (let i = lo; i < len; i++) { + const pt = data[i]; + if (/** @type {number} */ (pt.time) > t1) break; + const v = pt.low ?? pt.value; + const h = pt.high ?? pt.value; + if (v && v < pane.minV) { + pane.minV = v; + pane.minT = pt.time; + pane.minS = iseries; + } + if (h && h > pane.maxV) { + pane.maxV = h; + pane.maxT = pt.time; + pane.maxS = iseries; + } + } + } + + // Set new markers + const used = new Set(); + for (const { minV, minT, minS, maxV, maxT, maxS } of byPane.values()) { + if (!Number.isFinite(minV) || !Number.isFinite(maxV) || minT === maxT) + continue; + + const minM = /** @type {TimeSeriesMarker} */ ({ + time: minT, + position: "belowBar", + shape: "arrowUp", + color, + size: 0, + text: formatValue(minV), + }); + const maxM = /** @type {TimeSeriesMarker} */ ({ + time: maxT, + position: "aboveBar", + shape: "arrowDown", + color, + size: 0, + text: formatValue(maxV), + }); + + used.add(minS); + used.add(maxS); + if (minS === maxS) { + getOrCreatePlugin(minS).setMarkers([minM, maxM]); + } else { + getOrCreatePlugin(minS).setMarkers([minM]); + getOrCreatePlugin(maxS).setMarkers([maxM]); + } + } + + // Clear stale + for (const s of prevMarkerSeries) { + if (!used.has(s)) getOrCreatePlugin(s).setMarkers([]); + } + prevMarkerSeries.clear(); + for (const s of used) prevMarkerSeries.add(s); + } + + function clear() { + for (const s of prevMarkerSeries) getOrCreatePlugin(s).setMarkers([]); + prevMarkerSeries.clear(); + } + + return { + update, + scheduleUpdate: throttle(update, 100), + clear, + }; +} diff --git a/website/scripts/chart/state.js b/website/scripts/chart/state.js new file mode 100644 index 000000000..53a287bac --- /dev/null +++ b/website/scripts/chart/state.js @@ -0,0 +1,96 @@ +import { + readParam, + readNumberParam, + writeParam, +} from "../utils/url.js"; + +/** + * @typedef {{ from: number | null, to: number | null }} Range + */ + +const INDEX_KEY = "chart-index"; +const RANGES_KEY = "chart-ranges"; + +/** + * @param {Signals} signals + */ +export function createChartState(signals) { + /** @type {Record} */ + let ranges = {}; + try { + const stored = localStorage.getItem(RANGES_KEY); + if (stored) ranges = JSON.parse(stored); + } catch {} + + const saveRanges = () => { + try { + localStorage.setItem(RANGES_KEY, JSON.stringify(ranges)); + } catch {} + }; + + // Read index: URL > localStorage > default + /** @type {ChartableIndexName} */ + const defaultIndex = "date"; + const urlIndex = readParam("index"); + /** @type {ChartableIndexName} */ + let initialIndex = defaultIndex; + if (urlIndex) { + initialIndex = /** @type {ChartableIndexName} */ (urlIndex); + } else { + try { + const stored = localStorage.getItem(INDEX_KEY); + if (stored) initialIndex = /** @type {ChartableIndexName} */ (stored); + } catch {} + } + + // Read range: URL > localStorage (per index) + const urlFrom = readNumberParam("from"); + const urlTo = readNumberParam("to"); + const storedRange = ranges[initialIndex] ?? { from: null, to: null }; + const initialRange = { + from: urlFrom ?? storedRange.from, + to: urlTo ?? storedRange.to, + }; + // Save URL range to localStorage if present + if (urlFrom !== null || urlTo !== null) { + ranges[initialIndex] = initialRange; + saveRanges(); + } + + const index = signals.createSignal(/** @type {ChartableIndexName} */ (initialIndex)); + const currentRange = signals.createSignal(initialRange); + + // Save index changes to localStorage + URL + signals.createEffect(index, (value) => { + try { + localStorage.setItem(INDEX_KEY, value); + } catch {} + writeParam("index", value !== defaultIndex ? value : null); + }); + + // When index changes, switch to that index's saved range + signals.createEffect(index, (i) => { + const range = ranges[i] ?? { from: null, to: null }; + currentRange.set(range); + // Update URL with new range + writeParam("from", range.from !== null ? String(range.from) : null); + writeParam("to", range.to !== null ? String(range.to) : null); + }); + + return { + index, + /** @type {Accessor} */ + range: currentRange, + /** + * @param {Range} value + */ + setRange(value) { + const i = index(); + ranges[i] = value; + currentRange.set(value); + saveRanges(); + writeParam("from", value.from !== null ? String(value.from) : null); + writeParam("to", value.to !== null ? String(value.to) : null); + }, + }; +} diff --git a/website/scripts/entry.js b/website/scripts/entry.js index 9a0d8a733..1364975bd 100644 --- a/website/scripts/entry.js +++ b/website/scripts/entry.js @@ -1,7 +1,7 @@ /** * @import * as _ from "./modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.d.ts" * - * @import { IChartApi, ISeriesApi as _ISeriesApi, SeriesDefinition, SingleValueData as _SingleValueData, CandlestickData as _CandlestickData, BaselineData as _BaselineData, HistogramData as _HistogramData, SeriesType as LCSeriesType, IPaneApi, LineSeriesPartialOptions as _LineSeriesPartialOptions, HistogramSeriesPartialOptions as _HistogramSeriesPartialOptions, BaselineSeriesPartialOptions as _BaselineSeriesPartialOptions, CandlestickSeriesPartialOptions as _CandlestickSeriesPartialOptions, WhitespaceData, DeepPartial, ChartOptions, Time, LineData as _LineData, createChart as CreateChart, LineStyle } from './modules/lightweight-charts/5.1.0/dist/typings.js' + * @import { IChartApi, ISeriesApi as _ISeriesApi, SeriesDefinition, SingleValueData as _SingleValueData, CandlestickData as _CandlestickData, BaselineData as _BaselineData, HistogramData as _HistogramData, SeriesType as LCSeriesType, IPaneApi, LineSeriesPartialOptions as _LineSeriesPartialOptions, HistogramSeriesPartialOptions as _HistogramSeriesPartialOptions, BaselineSeriesPartialOptions as _BaselineSeriesPartialOptions, CandlestickSeriesPartialOptions as _CandlestickSeriesPartialOptions, WhitespaceData, DeepPartial, ChartOptions, Time, LineData as _LineData, createChart as CreateChart, LineStyle, createSeriesMarkers as CreateSeriesMarkers, SeriesMarker, ISeriesMarkersPluginApi } from './modules/lightweight-charts/5.1.0/dist/typings.js' * * @import { Signal, Signals, Accessor } from "./signals.js"; * @@ -10,7 +10,7 @@ * * @import { Resources, MetricResource } from './resources.js' * - * @import { Valued, SingleValueData, CandlestickData, Series, AnySeries, ISeries, HistogramData, LineData, BaselineData, LineSeriesPartialOptions, BaselineSeriesPartialOptions, HistogramSeriesPartialOptions, CandlestickSeriesPartialOptions, CreateChartElement, Chart, Legend } from "./chart/index.js" + * @import { SingleValueData, CandlestickData, Series, AnySeries, ISeries, HistogramData, LineData, BaselineData, LineSeriesPartialOptions, BaselineSeriesPartialOptions, HistogramSeriesPartialOptions, CandlestickSeriesPartialOptions, CreateChartElement, Chart, Legend } from "./chart/index.js" * * @import { Color, ColorName, Colors } from "./utils/colors.js" * @@ -30,6 +30,10 @@ /** * @typedef {[number, number, number, number]} OHLCTuple * + * Lightweight Charts markers + * @typedef {ISeriesMarkersPluginApi