import { createChart as untypedLcCreateChart, CandlestickSeries, HistogramSeries, LineSeries, BaselineSeries, } from "../modules/lightweight-charts/5.1.0/dist/lightweight-charts.standalone.production.mjs"; import { createLegend, createSeriesLegend } from "./legend.js"; import { capture } from "./capture.js"; import { colors } from "../utils/colors.js"; import { createRadios, createSelect, getElementById } from "../utils/dom.js"; import { createPersistedValue } from "../utils/persisted.js"; import { onChange as onThemeChange } from "../utils/theme.js"; import { throttle, debounce } from "../utils/timing.js"; import { serdeBool, INDEX_FROM_LABEL } from "../utils/serde.js"; import { stringToId, numberToShortUSFormat } from "../utils/format.js"; import { style } from "../utils/elements.js"; import { Unit } from "../utils/units.js"; /** * @typedef {_ISeriesApi} ISeries * @typedef {_ISeriesApi<'Candlestick'>} CandlestickISeries * @typedef {_ISeriesApi<'Histogram'>} HistogramISeries * @typedef {_ISeriesApi<'Line'>} LineISeries * @typedef {_ISeriesApi<'Baseline'>} BaselineISeries * * @typedef {_LineSeriesPartialOptions} LineSeriesPartialOptions * @typedef {_HistogramSeriesPartialOptions} HistogramSeriesPartialOptions * @typedef {_BaselineSeriesPartialOptions} BaselineSeriesPartialOptions * @typedef {_CandlestickSeriesPartialOptions} CandlestickSeriesPartialOptions */ /** * @template T * @typedef {Object} Series * @property {string} id * @property {number} paneIndex * @property {PersistedValue} active * @property {(value: boolean) => void} setActive * @property {(order: number) => void} setOrder * @property {() => void} highlight * @property {() => void} tame * @property {() => void} refresh * @property {number} generation * @property {() => boolean} hasData * @property {() => void} [fetch] * @property {string | null} url * @property {() => readonly T[]} getData * @property {(data: T) => void} update * @property {VoidFunction} remove */ /** * @typedef {SingleValueData | CandlestickData | LineData | BaselineData | HistogramData | WhitespaceData} AnyChartData * @typedef {Series} AnySeries */ /** * @typedef {_SingleValueData} SingleValueData * @typedef {_CandlestickData} CandlestickData * @typedef {_LineData} LineData * @typedef {_BaselineData} BaselineData * @typedef {_HistogramData} HistogramData * * @typedef {Object} Legend * @property {HTMLLegendElement} element * @property {function(HTMLElement): void} setPrefix * @property {function({ series: AnySeries, name: string, order: number, colors: Color[] }): void} addOrReplace * @property {function(number): void} removeFrom */ const lineWidth = /** @type {1} */ (/** @type {unknown} */ (1.5)); const MAX_SIZE = 10_000; /** @typedef {{ label: string, index: IndexLabel, from: number }} RangePreset */ /** @returns {RangePreset[]} */ function getRangePresets() { const now = new Date(); const y = now.getUTCFullYear(); const m = now.getUTCMonth(); const d = now.getUTCDate(); /** @param {number} months @param {number} [days] */ const ago = (months, days = 0) => Math.floor(Date.UTC(y, m - months, d - days) / 1000); /** @type {RangePreset[]} */ const presets = [ { label: "1w", index: /** @type {IndexLabel} */ ("30mn"), from: ago(0, 7) }, { label: "1m", index: /** @type {IndexLabel} */ ("1h"), from: ago(1) }, { label: "3m", index: /** @type {IndexLabel} */ ("4h"), from: ago(3) }, { label: "6m", index: /** @type {IndexLabel} */ ("12h"), from: ago(6) }, { label: "1y", index: /** @type {IndexLabel} */ ("1d"), from: ago(12) }, { label: "4y", index: /** @type {IndexLabel} */ ("3d"), from: ago(48) }, { label: "8y", index: /** @type {IndexLabel} */ ("1w"), from: ago(96) }, ]; const ytdFrom = Math.floor(Date.UTC(y, 0, 1) / 1000); const ri = presets.findIndex((e) => e.from <= ytdFrom); const insertAt = ri === -1 ? presets.length : ri; presets.splice(insertAt, 0, { label: "ytd", index: presets[Math.min(insertAt, presets.length - 1)].index, from: ytdFrom, }); presets.push({ label: "all", index: /** @type {IndexLabel} */ ("1w"), from: -Infinity, }); return presets; } /** * @param {Object} args * @param {HTMLElement} args.parent * @param {BrkClient} args.brk * @param {true} [args.fitContent] */ export function createChart({ parent, brk, fitContent }) { const baseUrl = brk.baseUrl.replace(/\/$/, ""); /** @type {string} */ let storageId = ""; /** @param {ChartableIndex} idx */ const getTimeEndpoint = (idx) => idx === "height" ? brk.series.indexes.timestamp.monotonic.by[idx] : brk.series.indexes.timestamp.resolutions.by[idx]; const index = { /** @type {Set<(index: ChartableIndex) => void>} */ onChange: new Set(), get() { return INDEX_FROM_LABEL[index.name.value]; }, name: createPersistedValue({ defaultValue: /** @type {IndexLabel} */ ("1d"), storageKey: "chart-index", urlKey: "i", serialize: (v) => v, deserialize: (s) => /** @type {IndexLabel} */ (s in INDEX_FROM_LABEL ? s : "1d"), onChange: () => { range.set(null); index.onChange.forEach((cb) => cb(index.get())); }, }), }; // Generation counter - incremented on any context change (index, blueprints, unit) // Used to detect and ignore stale operations (in-flight fetches, etc.) let generation = 0; const time = { /** @type {SeriesData | null} */ data: null, /** @type {Set<(data: SeriesData) => void>} */ callbacks: new Set(), /** @type {ReturnType | null} */ endpoint: null, /** @param {ChartableIndex} idx */ setIndex(idx) { this.data = null; this.callbacks = new Set(); this.endpoint = getTimeEndpoint(idx); }, fetch() { const endpoint = this.endpoint; if (!endpoint) return; const currentGen = generation; const cached = cache.get(endpoint.path); if (cached) { this.data = cached; } endpoint.slice(-MAX_SIZE).fetch((/** @type {AnySeriesData} */ result) => { if (currentGen !== generation) return; cache.set(endpoint.path, result); this.data = result; this.callbacks.forEach((cb) => cb(result)); }); }, }; // Memory cache for instant index switching /** @type {Map} */ const cache = new Map(); // 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: "r", 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[index.name.value] ?? null; /** @param {Range} value */ const setRange = (value) => { ranges.set({ ...ranges.value, [index.name.value]: value }); range.set(value); }; const legends = [createSeriesLegend(), createSeriesLegend()]; const root = document.createElement("div"); root.classList.add("chart"); parent.append(root); const chartEl = document.createElement("div"); root.append(chartEl); const ichart = /** @type {CreateLCChart} */ (untypedLcCreateChart)( chartEl, /** @satisfies {DeepPartial} */ ({ autoSize: true, layout: { fontFamily: style.fontFamily, background: { color: "transparent" }, attributionLogo: false, panes: { enableResize: false, }, }, rightPriceScale: { borderVisible: false, }, timeScale: { borderVisible: false, enableConflation: true, ...(fitContent ? { minBarSpacing: 0.001, } : {}), }, localization: { priceFormatter: numberToShortUSFormat, locale: "en-us", }, crosshair: { mode: 3, }, ...(fitContent ? { handleScale: false, handleScroll: false, } : {}), }), ); // Takes a bit more space sometimes but it's better UX than having the scale being resized on option change ichart.priceScale("right").applyOptions({ minimumWidth: 80, }); ichart.panes().at(0)?.setStretchFactor(1); /** @typedef {(visibleBarsCount: number) => void} ZoomChangeCallback */ const initialRange = getRange(); if (initialRange) { ichart.timeScale().setVisibleLogicalRange(initialRange); } // Flag to prevent range persistence until first data load completes // This prevents the URL range from being overwritten during chart initialization let initialLoadComplete = false; let visibleBarsCount = initialRange ? initialRange.to - initialRange.from : Infinity; /** @param {number} count */ const getDotsRadius = (count) => count > 1000 ? 1 : count > 200 ? 1.5 : count > 100 ? 2 : 3; /** @type {Set} */ const onZoomChange = new Set(); const debouncedSetRange = debounce((/** @type {Range | null} */ range) => { if (!initialLoadComplete) return; if (range && range.from < range.to) { setRange({ from: range.from, to: range.to }); } }, 100); index.onChange.add(() => debouncedSetRange.cancel()); const throttledZoom = throttle((/** @type {Range} */ range) => { if (!initialLoadComplete) return; const count = range.to - range.from; if (count === visibleBarsCount) return; visibleBarsCount = count; onZoomChange.forEach((cb) => cb(count)); }, 100); ichart.timeScale().subscribeVisibleLogicalRangeChange((range) => { if (!range) return; throttledZoom(range); debouncedSetRange(range); }); function applyColors() { const defaultColor = colors.default(); const offColor = colors.gray(); const borderColor = colors.border(); const offBorderColor = colors.offBorder(); ichart.applyOptions({ layout: { textColor: offColor, panes: { separatorColor: borderColor, }, }, crosshair: { horzLine: { color: offColor, labelBackgroundColor: defaultColor, }, vertLine: { color: offColor, labelBackgroundColor: defaultColor, }, }, grid: { horzLines: { color: offBorderColor, }, vertLines: { color: offBorderColor, }, }, }); } applyColors(); const removeThemeListener = onThemeChange(applyColors); /** @type {Partial>} */ const minBarSpacingByIndex = { month1: 1, month3: 2, month6: 3, year1: 6, year10: 60, }; /** @param {ChartableIndex} index */ function applyIndexSettings(index) { const minBarSpacing = minBarSpacingByIndex[index] ?? 0.5; ichart.applyOptions({ timeScale: { timeVisible: index === "height" || index === "epoch" || index === "halving" || index.startsWith("minute") || index.startsWith("hour"), ...(!fitContent ? { minBarSpacing, } : {}), }, }); } applyIndexSettings(index.get()); index.onChange.add(applyIndexSettings); // Periodic refresh of active series data const refreshInterval = setInterval(() => serieses.refreshAll(), 30_000); const onVisibilityChange = () => { if (!document.hidden) serieses.refreshAll(); }; document.addEventListener("visibilitychange", onVisibilityChange); if (fitContent) { new ResizeObserver(() => ichart.timeScale().fitContent()).observe(chartEl); } const panes = { initialized: false, /** * @param {number} paneIndex * @param {(pane: IPaneApi