import { createChart as untypedLcCreateChart, CandlestickSeries, HistogramSeries, LineSeries, 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 { 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 { 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"; /** * @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} key * @property {string} id * @property {number} paneIndex * @property {PersistedValue} active * @property {(value: boolean) => void} setActive * @property {() => void} show * @property {() => void} hide * @property {(order: number) => void} setOrder * @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 * @property {VoidFunction} remove */ /** * @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({ series: AnySeries, name: string, order: number, colors: Color[] }): void} addOrReplace * @property {function(number): void} removeFrom */ const lineWidth = /** @type {any} */ (1.5); /** * @param {Object} args * @param {string} args.id * @param {HTMLElement} args.parent * @param {BrkClient} args.brk * @param {true} [args.fitContent] * @param {HTMLElement} [args.captureElement] * @param {{unit: Unit; blueprints: AnyFetchedSeriesBlueprint[]}[]} [args.config] */ export function createChart({ parent, id: chartId, brk, fitContent, captureElement, config, }) { // Chart owns its index state /** @type {Set<(index: ChartableIndex) => void>} */ const onIndexChange = new Set(); const index = () => serdeChartableIndex.deserialize(indexName.value); const indexName = createPersistedValue({ defaultValue: /** @type {ChartableIndexName} */ ("date"), storageKey: "chart-index", urlKey: "i", serialize: (v) => v, deserialize: (s) => /** @type {ChartableIndexName} */ (s), onChange: () => { // Reset URL range so getRange() falls back to per-index saved range range.set(null); onIndexChange.forEach((cb) => cb(index())); }, }); // 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[indexName.value] ?? null; /** @param {Range} value */ const setRange = (value) => { ranges.set({ ...ranges.value, [indexName.value]: value }); range.set(value); }; const div = window.document.createElement("div"); div.classList.add("chart"); parent.append(div); // 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>} */ const seriesByKey = new Map(); // Track series by their home pane for pane collapse management /** @type {Map>} */ const seriesByHomePane = new Map(); let pendingVisibilityCheck = false; /** * Register series with its home pane for collapse management * @param {number} paneIndex * @param {AnySeries} series * @param {ISeries[]} iseries */ function registerSeriesPane(paneIndex, series, iseries) { let paneMap = seriesByHomePane.get(paneIndex); if (!paneMap) { paneMap = new Map(); seriesByHomePane.set(paneIndex, paneMap); } paneMap.set(series, iseries); // Create fieldsets when pane becomes active (first series or after restore) if (!panesWithFieldsets.has(paneIndex)) { setTimeout(() => createPaneFieldsets(paneIndex), paneIndex ? 50 : 0); } // Defer visibility check until after all series are registered if (!pendingVisibilityCheck) { pendingVisibilityCheck = true; requestAnimationFrame(() => { pendingVisibilityCheck = false; updatePaneVisibility(0); updatePaneVisibility(1); }); } } /** * Check if pane should collapse (all series hidden) and move series accordingly * @param {number} homePane */ function updatePaneVisibility(homePane) { const paneMap = seriesByHomePane.get(homePane); if (!paneMap || paneMap.size === 0) return; const allHidden = [...paneMap.keys()].every((s) => !s.active.value); if (homePane === 0) { // For pane 0: manage pane 1 series based on visibility of both panes const pane1Map = seriesByHomePane.get(1); if (!pane1Map || pane1Map.size === 0) return; const pane1AllHidden = [...pane1Map.keys()].every((s) => !s.active.value); // Determine what fieldsets should show on physical pane 0 // and whether pane 1 should exist if (allHidden && !pane1AllHidden) { // Pane 0 hidden, pane 1 visible: show pane 1 content/fieldsets on pane 0 for (const iseries of pane1Map.values()) { for (const is of iseries) { if (is.getPane().paneIndex() !== 0) { is.moveToPane(0); } } } panesWithFieldsets.delete(0); panesWithFieldsets.delete(1); setTimeout(() => createPaneFieldsets(1, 0), 50); } else if (!allHidden && !pane1AllHidden) { // Both visible: pane 0 on pane 0, pane 1 on pane 1 for (const iseries of pane1Map.values()) { for (const is of iseries) { if (is.getPane().paneIndex() === 0) { is.moveToPane(1); } } } panesWithFieldsets.delete(0); panesWithFieldsets.delete(1); setTimeout(() => { createPaneFieldsets(0); createPaneFieldsets(1); }, 50); } else if (!allHidden && pane1AllHidden) { // Pane 0 visible, pane 1 hidden: show pane 0 fieldsets, pane 1 collapsed for (const iseries of pane1Map.values()) { for (const is of iseries) { if (is.getPane().paneIndex() !== 0) { is.moveToPane(0); } } } panesWithFieldsets.delete(0); panesWithFieldsets.delete(1); setTimeout(() => createPaneFieldsets(0), 50); } // If both hidden: leave as-is, show pane 0 fieldsets (already there) } else { // For pane 1: move series to pane 0 when hidden, back when visible const pane0Map = seriesByHomePane.get(0); const pane0AllHidden = pane0Map ? [...pane0Map.keys()].every((s) => !s.active.value) : true; if (allHidden) { // Pane 1 hidden: move to pane 0 for (const iseries of paneMap.values()) { for (const is of iseries) { if (is.getPane().paneIndex() !== 0) { is.moveToPane(0); } } } panesWithFieldsets.delete(homePane); // Update pane 0 fieldsets based on what's visible if (pane0AllHidden) { // Both hidden: keep pane 0 fieldsets } else { // Pane 0 visible: show pane 0 fieldsets panesWithFieldsets.delete(0); setTimeout(() => createPaneFieldsets(0), 50); } } else { // Pane 1 visible: move back to pane 1 if pane 0 is also visible if (!pane0AllHidden) { for (const iseries of paneMap.values()) { for (const is of iseries) { if (is.getPane().paneIndex() === 0) { is.moveToPane(homePane); } } } panesWithFieldsets.delete(0); panesWithFieldsets.delete(homePane); setTimeout(() => { createPaneFieldsets(0); createPaneFieldsets(homePane); }, 50); } else { // Pane 0 hidden, pane 1 visible: show pane 1 fieldsets on pane 0 panesWithFieldsets.delete(0); panesWithFieldsets.delete(homePane); setTimeout(() => createPaneFieldsets(homePane, 0), 50); } } } } const legendTop = createLegend(); div.append(legendTop.element); const chartDiv = window.document.createElement("div"); chartDiv.classList.add("lightweight-chart"); div.append(chartDiv); const legendBottom = createLegend(); div.append(legendBottom.element); const ichart = lcCreateChart( chartDiv, /** @satisfies {DeepPartial} */ ({ autoSize: true, layout: { fontFamily: style.fontFamily, background: { color: "transparent" }, attributionLogo: false, }, grid: { vertLines: { visible: false }, horzLines: { visible: false }, }, rightPriceScale: { borderVisible: false, }, timeScale: { borderVisible: false, enableConflation: true, // conflationThresholdFactor: 8, ...(fitContent ? { minBarSpacing: 0.001, } : {}), }, localization: { priceFormatter: numberToShortUSFormat, locale: "en-us", }, crosshair: { mode: 3, }, ...(fitContent ? { handleScale: false, handleScroll: false, } : {}), // ..._options, }), ); // 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); } let visibleBarsCount = initialRange ? initialRange.to - initialRange.from : Infinity; /** @type {Set} */ const onZoomChange = new Set(); 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); /** @param {ChartableIndex} index */ function applyIndexSettings(index) { const minBarSpacing = index === "monthindex" ? 1 : index === "quarterindex" ? 2 : index === "semesterindex" ? 3 : index === "yearindex" ? 6 : index === "decadeindex" ? 60 : 0.5; ichart.applyOptions({ timeScale: { timeVisible: index === "height", ...(!fitContent ? { minBarSpacing, } : {}), }, }); } applyIndexSettings(index()); onIndexChange.add(applyIndexSettings); // Periodic refresh of active series data setInterval(() => { seriesByKey.forEach((set) => { set.forEach((s) => { if (s.active.value) s.fetch?.(); }); }); }, 30_000); if (fitContent) { new ResizeObserver(() => ichart.timeScale().fitContent()).observe(chartDiv); } /** * @typedef {Object} FieldsetConfig * @property {string} id * @property {"nw" | "ne" | "se" | "sw"} position * @property {(pane: IPaneApi