diff --git a/website/scripts/chart/index.js b/website/scripts/chart/index.js index ea42f667a..1f01ee2e8 100644 --- a/website/scripts/chart/index.js +++ b/website/scripts/chart/index.js @@ -77,7 +77,6 @@ const lineWidth = /** @type {any} */ (1.5); * @param {BrkClient} args.brk * @param {true} [args.fitContent] * @param {HTMLElement} [args.captureElement] - * @param {{unit: Unit; blueprints: AnyFetchedSeriesBlueprint[]}[]} [args.config] */ export function createChart({ parent, @@ -85,7 +84,6 @@ export function createChart({ brk, fitContent, captureElement, - config, }) { const baseUrl = brk.baseUrl.replace(/\/$/, ""); @@ -173,6 +171,9 @@ export function createChart({ fontFamily: style.fontFamily, background: { color: "transparent" }, attributionLogo: false, + panes: { + enableResize: false, + }, }, grid: { vertLines: { visible: false }, @@ -465,10 +466,8 @@ export function createChart({ /** * @param {Object} args * @param {string} args.name - * @param {Unit} args.unit * @param {number} args.order * @param {Color[]} args.colors - * @param {LCSeriesType} args.seriesType * @param {AnyMetricPattern} args.metric * @param {number} args.paneIndex * @param {boolean} [args.defaultActive] @@ -481,14 +480,11 @@ export function createChart({ * @param {(data: any[]) => void} args.setData * @param {(data: any) => void} args.update * @param {() => void} args.onRemove - * @param {() => void} [args.onDataLoaded] */ create({ metric, name, - unit, order, - seriesType, paneIndex, defaultActive, colors, @@ -501,7 +497,6 @@ export function createChart({ setData, update, onRemove, - onDataLoaded, }) { const key = stringToId(name); const id = `${key}-${paneIndex}`; @@ -689,7 +684,7 @@ export function createChart({ .setVisibleLogicalRange({ from: -1, to: data.length }); } // Delay until chart has applied the range - requestAnimationFrame(() => onDataLoaded?.()); + requestAnimationFrame(() => blueprints.onDataLoaded?.()); } else { // Incremental update: only process new data points for (let i = startIdx; i < length; i++) { @@ -723,42 +718,30 @@ export function createChart({ // index.onChange.add(setupIndexEffect); // _cleanup = () => index.onChange.delete(setupIndexEffect); - addPriceScaleSelectorIfNeeded({ - paneIndex, - seriesType, - unit, - }); - return series; }, /** * @param {Object} args * @param {string} args.name - * @param {Unit} args.unit * @param {number} args.order * @param {AnyMetricPattern} args.metric * @param {number} [args.paneIndex] * @param {[Color, Color]} [args.colors] - [upColor, downColor] for legend * @param {boolean} [args.defaultActive] - * @param {boolean} [args.inverse] * @param {CandlestickSeriesPartialOptions} [args.options] */ addCandlestick({ metric, name, - unit, order, paneIndex = 0, colors: customColors, defaultActive, - inverse, options, }) { - const defaultGreen = inverse ? colors.red : colors.green; - const defaultRed = inverse ? colors.green : colors.red; - const upColor = customColors?.[0] ?? defaultGreen; - const downColor = customColors?.[1] ?? defaultRed; + const upColor = customColors?.[0] ?? colors.green; + const downColor = customColors?.[1] ?? colors.red; /** @type {CandlestickISeries} */ const candlestickISeries = /** @type {any} */ ( @@ -788,8 +771,10 @@ export function createChart({ let active = defaultActive !== false; let highlighted = true; - let showLine = visibleBarsCount > 500; - let dataLoaded = false; + + /** @param {number} barCount */ + const shouldShowLine = (barCount) => barCount > 500; + let showLine = shouldShowLine(visibleBarsCount); function update() { candlestickISeries.applyOptions({ @@ -809,8 +794,8 @@ export function createChart({ /** @type {ZoomChangeCallback} */ function handleZoom(count) { - if (!dataLoaded) return; // Ignore zoom changes until data is ready - const newShowLine = count > 500; + if (!series.hasData()) return; // Ignore zoom changes until data is ready + const newShowLine = shouldShowLine(count); if (newShowLine === showLine) return; showLine = newShowLine; update(); @@ -823,8 +808,6 @@ export function createChart({ name, order, paneIndex, - seriesType: "Candlestick", - unit, defaultActive, metric, setOrder(order) { @@ -855,6 +838,13 @@ export function createChart({ candlestickISeries.setData(data); const lineData = data.map((d) => ({ time: d.time, value: d.close })); lineISeries.setData(lineData); + requestAnimationFrame(() => { + const range = ichart.timeScale().getVisibleLogicalRange(); + if (range) { + showLine = shouldShowLine(range.to - range.from); + } + update(); + }); }, update: (data) => { candlestickISeries.update(data); @@ -867,10 +857,6 @@ export function createChart({ ichart.removeSeries(candlestickISeries); ichart.removeSeries(lineISeries); }, - onDataLoaded: () => { - dataLoaded = true; - update(); - }, }); panes.register(paneIndex, series, [candlestickISeries, lineISeries]); @@ -880,7 +866,6 @@ export function createChart({ /** * @param {Object} args * @param {string} args.name - * @param {Unit} args.unit * @param {number} args.order * @param {AnyMetricPattern} args.metric * @param {Color | [Color, Color]} [args.color] - Single color or [positive, negative] colors @@ -891,7 +876,6 @@ export function createChart({ addHistogram({ metric, name, - unit, color = [colors.green, colors.red], order, paneIndex = 0, @@ -932,8 +916,6 @@ export function createChart({ name, order, paneIndex, - seriesType: "Bar", - unit, defaultActive, metric, setOrder: (order) => iseries.setSeriesOrder(order), @@ -987,10 +969,9 @@ export function createChart({ /** * @param {Object} args * @param {string} args.name - * @param {Unit} args.unit * @param {number} args.order * @param {AnyMetricPattern} args.metric - * @param {Color} [args.color] + * @param {Color} args.color * @param {number} [args.paneIndex] * @param {boolean} [args.defaultActive] * @param {LineSeriesPartialOptions} [args.options] @@ -998,16 +979,12 @@ export function createChart({ addLine({ metric, name, - unit, order, - color: _color, + color, paneIndex = 0, defaultActive, options, }) { - const color = - _color ?? (unit.id === "usd" ? colors.green : colors.orange); - /** @type {LineISeries} */ const iseries = /** @type {any} */ ( ichart.addSeries( @@ -1039,8 +1016,6 @@ export function createChart({ name, order, paneIndex, - seriesType: "Line", - unit, defaultActive, metric, setOrder: (order) => iseries.setSeriesOrder(order), @@ -1080,10 +1055,9 @@ export function createChart({ /** * @param {Object} args * @param {string} args.name - * @param {Unit} args.unit * @param {number} args.order * @param {AnyMetricPattern} args.metric - * @param {Color} [args.color] + * @param {Color} args.color * @param {number} [args.paneIndex] * @param {boolean} [args.defaultActive] * @param {LineSeriesPartialOptions} [args.options] @@ -1091,16 +1065,12 @@ export function createChart({ addDots({ metric, name, - unit, order, - color: _color, + color, paneIndex = 0, defaultActive, options, }) { - const color = - _color ?? (unit.id === "usd" ? colors.green : colors.orange); - /** @type {LineISeries} */ const iseries = /** @type {any} */ ( ichart.addSeries( @@ -1145,8 +1115,6 @@ export function createChart({ name, order, paneIndex, - seriesType: "Line", - unit, defaultActive, metric, setOrder: (order) => iseries.setSeriesOrder(order), @@ -1187,7 +1155,6 @@ export function createChart({ /** * @param {Object} args * @param {string} args.name - * @param {Unit} args.unit * @param {number} args.order * @param {AnyMetricPattern} args.metric * @param {number} [args.paneIndex] @@ -1199,7 +1166,6 @@ export function createChart({ addBaseline({ metric, name, - unit, order, paneIndex: _paneIndex, defaultActive, @@ -1249,8 +1215,6 @@ export function createChart({ name, order, paneIndex, - seriesType: "Baseline", - unit, defaultActive, metric, setOrder: (order) => iseries.setSeriesOrder(order), @@ -1290,21 +1254,16 @@ export function createChart({ }; /** - * @param {Object} args - * @param {Unit} args.unit - * @param {LCSeriesType} args.seriesType - * @param {number} args.paneIndex + * @param {number} paneIndex + * @param {Unit} unit */ - function addPriceScaleSelectorIfNeeded({ unit, paneIndex, seriesType }) { + function applyScaleForUnit(paneIndex, unit) { const id = `${chartId}-scale`; - - /** @type {"lin" | "log"} */ - const defaultValue = - unit.id === "usd" && seriesType !== "Baseline" ? "log" : "lin"; + const defaultValue = unit.id === "usd" ? "log" : "lin"; const persisted = createPersistedValue({ - defaultValue, - storageKey: `${id}-scale-${paneIndex}`, + defaultValue: /** @type {"lin" | "log"} */ (defaultValue), + storageKey: `${id}-${paneIndex}-${unit.id}`, urlKey: paneIndex === 0 ? "price_scale" : "unit_scale", serialize: (v) => v, deserialize: (s) => /** @type {"lin" | "log"} */ (s), @@ -1312,15 +1271,19 @@ export function createChart({ /** @param {"lin" | "log"} value */ const applyScale = (value) => { - try { - const pane = ichart.panes().at(paneIndex); - pane?.priceScale("right").applyOptions({ - mode: value === "lin" ? 0 : 1, - }); - } catch {} + panes.whenReady(paneIndex, () => { + try { + ichart + .panes() + .at(paneIndex) + ?.priceScale("right") + .applyOptions({ + mode: value === "lin" ? 0 : 1, + }); + } catch {} + }); }; - // Apply scale immediately applyScale(persisted.value); fieldsets.addIfNeeded({ @@ -1330,7 +1293,7 @@ export function createChart({ createChild() { const field = createChoiceField({ choices: /** @type {const} */ (["lin", "log"]), - id: stringToId(`${id} ${paneIndex} ${unit}`), + id: stringToId(`${id} ${paneIndex}`), initialValue: persisted.value, onChange(value) { persisted.set(value); @@ -1343,11 +1306,206 @@ export function createChart({ }); } + const blueprints = { + /** @type {{ map: Map, series: AnySeries[], unit: Unit | null, legend: Legend }[]} */ + panes: [ + { map: new Map(), series: [], unit: null, legend: legends.top }, + { map: new Map(), series: [], unit: null, legend: legends.bottom }, + ], + + /** @type {VoidFunction | undefined} */ + onDataLoaded: undefined, + + /** @param {number} paneIndex */ + rebuildPane(paneIndex) { + const pane = this.panes[paneIndex]; + const { map, series, unit, legend } = pane; + + if (!unit) { + series.forEach((s) => s.remove()); + pane.series = []; + legend.removeFrom(0); + return; + } + + const idx = index.get(); + legend.removeFrom(0); + + // Store old series to remove AFTER adding new ones + // This prevents pane collapse which loses scale settings + const oldSeries = [...series]; + pane.series = []; + + map.get(unit)?.forEach((blueprint, order) => { + const options = blueprint.options; + const indexes = Object.keys(blueprint.metric.by); + + const defaultColor = unit.id === "usd" ? colors.green : colors.orange; + + if (indexes.includes(idx)) { + switch (blueprint.type) { + case "Baseline": { + pane.series.push( + serieses.addBaseline({ + metric: blueprint.metric, + name: blueprint.title, + defaultActive: blueprint.defaultActive, + paneIndex, + options: { + ...options, + topLineColor: + blueprint.color?.() ?? blueprint.colors?.[0](), + bottomLineColor: + blueprint.color?.() ?? blueprint.colors?.[1](), + }, + order, + }), + ); + break; + } + case "Histogram": { + pane.series.push( + serieses.addHistogram({ + metric: blueprint.metric, + name: blueprint.title, + color: blueprint.color, + defaultActive: blueprint.defaultActive, + paneIndex, + options, + order, + }), + ); + break; + } + case "Candlestick": { + pane.series.push( + serieses.addCandlestick({ + metric: blueprint.metric, + name: blueprint.title, + colors: blueprint.colors, + defaultActive: blueprint.defaultActive, + paneIndex, + options, + order, + }), + ); + break; + } + case "Dots": { + pane.series.push( + serieses.addDots({ + metric: blueprint.metric, + color: blueprint.color ?? defaultColor, + name: blueprint.title, + defaultActive: blueprint.defaultActive, + paneIndex, + options, + order, + }), + ); + break; + } + case "Line": + case undefined: + pane.series.push( + serieses.addLine({ + metric: blueprint.metric, + color: blueprint.color ?? defaultColor, + name: blueprint.title, + defaultActive: blueprint.defaultActive, + paneIndex, + options, + order, + }), + ); + } + } + }); + + // Remove old series AFTER adding new ones to prevent pane collapse + oldSeries.forEach((s) => s.remove()); + + // Ensure other pane's series are in their correct pane before applying scale + // (they may have been collapsed when this pane was empty) + const otherPaneIndex = paneIndex === 0 ? 1 : 0; + panes.moveTo(otherPaneIndex, otherPaneIndex); + + // Apply scale after series are created and panes are properly separated + applyScaleForUnit(paneIndex, unit); + }, + + rebuild() { + this.rebuildPane(0); + this.rebuildPane(1); + }, + }; + + // Rebuild when index changes + index.onChange.add(() => blueprints.rebuild()); + const chart = { index, - legends, - serieses, - addFieldsetIfNeeded: fieldsets.addIfNeeded.bind(fieldsets), + + /** + * @param {Object} args + * @param {Map} args.top + * @param {Map} args.bottom + * @param {VoidFunction} [args.onDataLoaded] + */ + setBlueprints({ top, bottom, onDataLoaded }) { + blueprints.panes[0].map = top; + blueprints.panes[1].map = bottom; + blueprints.onDataLoaded = onDataLoaded; + + // Set up unit selectors for each pane + [top, bottom].forEach((map, paneIndex) => { + const units = Array.from(map.keys()); + if (!units.length) { + blueprints.panes[paneIndex].unit = null; + return; + } + + const defaultUnit = units[0]; + const persistedUnit = createPersistedValue({ + defaultValue: /** @type {string} */ (defaultUnit.id), + urlKey: paneIndex === 0 ? "u0" : "u1", + serialize: (v) => v, + deserialize: (s) => s, + }); + + // Find unit matching persisted value, or use default + const initialUnit = + units.find((u) => u.id === persistedUnit.value) ?? defaultUnit; + blueprints.panes[paneIndex].unit = initialUnit; + + fieldsets.addIfNeeded({ + id: `${chartId}-unit`, + paneIndex, + position: "nw", + createChild() { + return createChoiceField({ + choices: units, + id: `pane-${paneIndex}-unit`, + initialValue: blueprints.panes[paneIndex].unit ?? defaultUnit, + toKey: (u) => u.id, + toLabel: (u) => u.name, + type: "select", + sorted: true, + onChange(unit) { + persistedUnit.set(unit.id); + blueprints.panes[paneIndex].unit = unit; + blueprints.rebuildPane(paneIndex); + }, + }); + }, + }); + }); + + blueprints.rebuild(); + + return blueprints; + }, + destroy() { removeThemeListener(); clearInterval(refreshInterval); @@ -1355,31 +1513,6 @@ export function createChart({ }, }; - config?.forEach(({ unit, blueprints }, paneIndex) => { - blueprints.forEach((blueprint, order) => { - const common = { - metric: blueprint.metric, - name: blueprint.title, - unit, - defaultActive: blueprint.defaultActive, - paneIndex, - options: blueprint.options, - order, - }; - if (blueprint.type === "Candlestick") { - serieses.addCandlestick({ ...common, colors: blueprint.colors }); - } else if (blueprint.type === "Baseline") { - serieses.addBaseline(common); - } else if (blueprint.type === "Histogram") { - serieses.addHistogram({ ...common, color: blueprint.color }); - } else if (blueprint.type === "Dots") { - serieses.addDots({ ...common, color: blueprint.color }); - } else { - serieses.addLine({ ...common, color: blueprint.color }); - } - }); - }); - if (captureElement && canCapture) { const domain = document.createElement("p"); domain.innerText = window.location.host; diff --git a/website/scripts/panes/chart.js b/website/scripts/panes/chart.js index 205a662d8..a7a3bb9e5 100644 --- a/website/scripts/panes/chart.js +++ b/website/scripts/panes/chart.js @@ -1,6 +1,5 @@ import { createShadow, - createReactiveChoiceField, createChoiceField, createHeader, } from "../utils/dom.js"; @@ -9,9 +8,9 @@ import { serdeChartableIndex } from "../utils/serde.js"; import { Unit } from "../utils/units.js"; import signals from "../signals.js"; import { createChart } from "../chart/index.js"; +import { colors } from "../chart/colors.js"; import { webSockets } from "../utils/ws.js"; -const keyPrefix = "chart"; const ONE_BTC_IN_SATS = 100_000_000; /** @@ -37,361 +36,78 @@ 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.index.onChange.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: "u1", - serialize: (u) => u.id, - deserialize: (s) => - /** @type {Unit} */ (unitChoices.find((u) => u.id === s) ?? Unit.usd), - }); - const topUnitField = createReactiveChoiceField({ - defaultValue: Unit.usd, - choices: unitChoices, - toKey: (u) => u.id, - toLabel: (u) => u.name, - selected: topUnit, - signals, - sorted: true, - type: "select", - }); - - chart.addFieldsetIfNeeded({ - id: "charts-unit-0", - paneIndex: 0, - position: "nw", - createChild() { - return topUnitField; - }, - }); - - const seriesListTop = /** @type {AnySeries[]} */ ([]); - const seriesListBottom = /** @type {AnySeries[]} */ ([]); - /** - * @param {Object} params - * @param {AnySeries} params.series - * @param {Unit} params.unit - * @param {IndexName} params.index + * Build top blueprints with price series prepended for each unit + * @param {Map} optionTop + * @returns {Map} */ - function printLatest({ series, unit, index }) { - const _latest = webSockets.kraken1dCandle.latest(); + function buildTopBlueprints(optionTop) { + /** @type {Map} */ + const result = new Map(); - if (!_latest) return; + // USD price + option blueprints + /** @type {FetchedCandlestickSeriesBlueprint} */ + const usdPrice = { + type: "Candlestick", + title: "Price", + metric: brk.metrics.price.usd.ohlc, + }; + result.set(Unit.usd, [usdPrice, ...(optionTop.get(Unit.usd) ?? [])]); - const latest = { ..._latest }; + // Sats price + option blueprints + /** @type {FetchedCandlestickSeriesBlueprint} */ + const satsPrice = { + type: "Candlestick", + title: "Price", + metric: brk.metrics.price.sats.ohlc, + colors: [colors.red, colors.green], + }; + result.set(Unit.sats, [satsPrice, ...(optionTop.get(Unit.sats) ?? [])]); - if (unit === Unit.sats) { - latest.open = Math.floor(ONE_BTC_IN_SATS / latest.open); - latest.high = Math.floor(ONE_BTC_IN_SATS / latest.high); - latest.low = Math.floor(ONE_BTC_IN_SATS / latest.low); - latest.close = Math.floor(ONE_BTC_IN_SATS / latest.close); - } - - const last_ = series.getData().at(-1); - if (!last_) return; - const last = { ...last_ }; - - if ("close" in last) { - last.close = latest.close; - } - if ("value" in last) { - last.value = latest.close; - } - const date = new Date(/** @type {number} */ (latest.time) * 1000); - - switch (index) { - case "height": - case "difficultyepoch": - case "halvingepoch": { - if ("close" in last) { - last.low = Math.min(last.low, latest.close); - last.high = Math.max(last.high, latest.close); - } - series.update(last); - break; - } - default: { - if (index === "weekindex") { - date.setUTCDate(date.getUTCDate() - ((date.getUTCDay() + 6) % 7)); - } else if (index === "monthindex") { - date.setUTCDate(1); - } else if (index === "quarterindex") { - const month = date.getUTCMonth(); - date.setUTCMonth(month - (month % 3), 1); - } else if (index === "semesterindex") { - const month = date.getUTCMonth(); - date.setUTCMonth(month - (month % 6), 1); - } else if (index === "yearindex") { - date.setUTCMonth(0, 1); - } else if (index === "decadeindex") { - date.setUTCFullYear( - Math.floor(date.getUTCFullYear() / 10) * 10, - 0, - 1, - ); - } else if (index !== "dateindex") { - throw Error("Unsupported"); - } - - const time = date.valueOf() / 1000; - - if (time === last.time) { - if ("close" in last) { - last.low = Math.min(last.low, latest.low); - last.high = Math.max(last.high, latest.high); - } - series.update(last); - } else { - last.time = time; - series.update(last); - } - } - } + return result; } - signals.createScopedEffect(option, (option) => { - headingElement.innerHTML = option.title; + /** @type {ReturnType | null} */ + let blueprints = null; - const bottomUnits = Array.from(option.bottom.keys()); + function updatePriceWithLatest() { + const latest = webSockets.kraken1dCandle.latest(); + if (!latest || !blueprints) return; - /** @type {Signal | undefined} */ - let bottomUnit; + const priceSeries = blueprints.panes[0].series[0]; + const unit = blueprints.panes[0].unit; + if (!priceSeries?.hasData() || !unit) return; - if (bottomUnits.length) { - // Storage key based on unit group (sorted unit IDs) so each group remembers its selection - const unitGroupKey = bottomUnits - .map((u) => u.id) - .sort() - .join("-"); - bottomUnit = signals.createPersistedSignal({ - defaultValue: bottomUnits[0], - storageKey: `${keyPrefix}-unit-${unitGroupKey}`, - urlKey: "u2", - serialize: (u) => u.id, - deserialize: (s) => - bottomUnits.find((u) => u.id === s) ?? bottomUnits[0], - }); - const field = createReactiveChoiceField({ - defaultValue: bottomUnits[0], - choices: bottomUnits, - toKey: (u) => u.id, - toLabel: (u) => u.name, - selected: bottomUnit, - signals, - sorted: true, - type: "select", - }); - chart.addFieldsetIfNeeded({ - id: "charts-unit-1", - paneIndex: 1, - position: "nw", - createChild() { - return field; - }, - }); - } else { - // Clean up bottom pane when new option has no bottom series - seriesListBottom.forEach((series) => series.remove()); - seriesListBottom.length = 0; - chart.legends.bottom.removeFrom(0); - } - - /** - * @param {Object} args - * @param {Map} args.blueprints - * @param {number} args.paneIndex - * @param {Unit} args.unit - * @param {IndexName} args.idx - * @param {AnySeries[]} args.seriesList - * @param {number} args.orderStart - * @param {Legend} args.legend - */ - function createSeriesFromBlueprints({ - blueprints, - paneIndex, - unit, - idx, - seriesList, - orderStart, - legend, - }) { - legend.removeFrom(orderStart); - seriesList.splice(orderStart).forEach((series) => series.remove()); - - blueprints.get(unit)?.forEach((blueprint, order) => { - order += orderStart; - const options = blueprint.options; - const indexes = Object.keys(blueprint.metric.by); - - if (indexes.includes(idx)) { - switch (blueprint.type) { - case "Baseline": { - seriesList.push( - chart.serieses.addBaseline({ - metric: blueprint.metric, - name: blueprint.title, - unit, - defaultActive: blueprint.defaultActive, - paneIndex, - options: { - ...options, - topLineColor: - blueprint.color?.() ?? blueprint.colors?.[0](), - bottomLineColor: - blueprint.color?.() ?? blueprint.colors?.[1](), - }, - order, - }), - ); - break; - } - case "Histogram": { - seriesList.push( - chart.serieses.addHistogram({ - metric: blueprint.metric, - name: blueprint.title, - unit, - color: blueprint.color, - defaultActive: blueprint.defaultActive, - paneIndex, - options, - order, - }), - ); - break; - } - case "Candlestick": { - seriesList.push( - chart.serieses.addCandlestick({ - metric: blueprint.metric, - name: blueprint.title, - unit, - colors: blueprint.colors, - defaultActive: blueprint.defaultActive, - paneIndex, - options, - order, - }), - ); - break; - } - case "Dots": { - seriesList.push( - chart.serieses.addDots({ - metric: blueprint.metric, - color: blueprint.color, - name: blueprint.title, - unit, - defaultActive: blueprint.defaultActive, - paneIndex, - options, - order, - }), - ); - break; - } - case "Line": - case undefined: - seriesList.push( - chart.serieses.addLine({ - metric: blueprint.metric, - color: blueprint.color, - name: blueprint.title, - unit, - defaultActive: blueprint.defaultActive, - paneIndex, - options, - order, - }), - ); - } - } - }); - } - - // Price series + top pane blueprints: react to topUnit and index changes - signals.createScopedEffect( - () => ({ unit: topUnit(), _: indexVersion() }), - ({ unit }) => { - // Create price series BEFORE removing old one to prevent pane collapse - /** @type {AnySeries | undefined} */ - let series; - switch (unit) { - case Unit.usd: { - series = chart.serieses.addCandlestick({ - metric: brk.metrics.price.usd.ohlc, - name: "Price", - unit, - order: 0, - }); - break; - } - case Unit.sats: { - series = chart.serieses.addCandlestick({ - metric: brk.metrics.price.sats.ohlc, - name: "Price", - unit, - inverse: true, - order: 0, - }); - break; - } - } - if (!series) throw Error("Unreachable"); - - seriesListTop[0]?.remove(); - seriesListTop[0] = series; - - // Live price update effect - signals.createEffect( - () => ({ - latest: webSockets.kraken1dCandle.latest(), - hasData: series.hasData(), - }), - ({ latest, hasData }) => { - if (!series || !latest || !hasData) return; - printLatest({ series, unit, index: chart.index.get() }); - }, - ); - - // Top pane blueprint series - createSeriesFromBlueprints({ - blueprints: option.top, - paneIndex: 0, - unit, - idx: chart.index.get(), - seriesList: seriesListTop, - orderStart: 1, - legend: chart.legends.top, - }); - }, + const last = /** @type {CandlestickData | undefined} */ ( + priceSeries.getData().at(-1) ); + if (!last) return; - // Bottom pane blueprints: react to bottomUnit and index changes - if (bottomUnit) { - signals.createScopedEffect( - () => ({ unit: bottomUnit(), _: indexVersion() }), - ({ unit }) => { - createSeriesFromBlueprints({ - blueprints: option.bottom, - paneIndex: 1, - unit, - idx: chart.index.get(), - seriesList: seriesListBottom, - orderStart: 0, - legend: chart.legends.bottom, - }); - }, - ); - } + // Convert to sats if needed + const close = + unit === Unit.sats + ? Math.floor(ONE_BTC_IN_SATS / latest.close) + : latest.close; + + priceSeries.update({ ...last, close }); + } + + // When option changes, update heading and rebuild blueprints + signals.createEffect(option, (opt) => { + headingElement.innerHTML = opt.title; + + blueprints = chart.setBlueprints({ + top: buildTopBlueprints(opt.top), + bottom: opt.bottom, + onDataLoaded: updatePriceWithLatest, + }); }); + + // Live price update listener + signals.createEffect( + () => webSockets.kraken1dCandle.latest(), + updatePriceWithLatest, + ); } /** diff --git a/website/scripts/utils/dom.js b/website/scripts/utils/dom.js index 579752fb1..3fbfed9dd 100644 --- a/website/scripts/utils/dom.js +++ b/website/scripts/utils/dom.js @@ -365,16 +365,22 @@ export function createReactiveChoiceField({ * @param {(choice: T) => string} [args.toKey] * @param {(choice: T) => string} [args.toLabel] * @param {"radio" | "select"} [args.type] + * @param {boolean} [args.sorted] */ export function createChoiceField({ id, - choices, + choices: unsortedChoices, initialValue, onChange, + sorted, toKey = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c), toLabel = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c), type = "radio", }) { + const choices = sorted + ? unsortedChoices.toSorted((a, b) => toLabel(a).localeCompare(toLabel(b))) + : unsortedChoices; + const field = window.document.createElement("div"); field.classList.add("field"); @@ -387,7 +393,11 @@ export function createChoiceField({ const fromKey = (key) => choices.find((c) => toKey(c) === key) ?? initialValue; - if (type === "select") { + if (choices.length === 1) { + const span = window.document.createElement("span"); + span.textContent = toLabel(choices[0]); + div.append(span); + } else if (type === "select") { const select = window.document.createElement("select"); select.id = id ?? ""; select.name = id ?? ""; @@ -407,6 +417,13 @@ export function createChoiceField({ }); div.append(select); + + const remaining = choices.length - 1; + if (remaining > 0) { + const small = window.document.createElement("small"); + small.textContent = ` +${remaining}`; + field.append(small); + } } else { const fieldId = id ?? ""; choices.forEach((choice) => {