diff --git a/.zed/settings.json b/.zed/settings.json index 30b1b1a4b..0ec3f89ec 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -16,7 +16,7 @@ "**/solidjs-signals/*/dist/prod.js", "uFuzzy.mjs", "lightweight-charts.standalone.production.mjs", - "scripts/packages", + // "scripts/packages", "dist" ] } diff --git a/docs/TODO.md b/docs/TODO.md index 21c893e16..3bf607596 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,14 +1,13 @@ # TODO - __CRATES__ + - _BUNDLER_ - _CLI_ - launch - if first, test read/write speed, add warning if too low (<2gb/s) - check available disk space - pull latest version and notify if out of date - add custom path support for config.toml - - maybe add bitcoind download and launch support - - via: https://github.com/rust-bitcoin/corepc/blob/master/node - _COMPUTER_ - **add rollback of states (in stateful)** - add support for per index computation @@ -39,6 +38,8 @@ - https://checkonchain.com - https://researchbitcoin.net/exciting-update-coming-to-the-bitcoin-lab/ - https://mempool.space/research + - _ERROR_ + - _FETCHER_ - _INDEXER_ - parse only the needed block number instead the last 100 blocks - maybe using https://developer.bitcoin.org/reference/rpc/getblockhash.html @@ -65,9 +66,8 @@ - example: from -10,000 count 10, won’t work if underlying vec isn’t 10k or more long - _LOGGER_ - remove colors from file + - _MCP_ - _PARSER_ - - Stateless - - if less than X (10 maybe ?) get block using rpc instead of parsing the block files - _SERVER_ - api - copy mempool's rest api @@ -93,7 +93,6 @@ - _STORE_ - save height and version in one file - _STRUCTS_ - - remove `checked_sub` trait ? (checked with the `dev` profile) - __DOCS__ - _README_ - add a comparison table with alternatives @@ -102,7 +101,6 @@ - add faq - __WEBSITES__ - _PACKAGES_ - - move packages from `bitview` to `/packages` or `/websites/packages` or else - move the fetching logic from `bitview` website to an independent `brk` package which could be published to npm - https://www.npmjs.com/package/@mempool/mempool.js - auto publish with github actions @@ -145,9 +143,8 @@ - by having a global state - font: - https://fonts.google.com/specimen/Space+Mono - - keep as many files as possible [under 14kb](https://endtimes.dev/why-your-website-should-be-under-14kb-in-size/) - - No classes: https://news.ycombinator.com/item?id=45287155 + - [No classes](https://news.ycombinator.com/item?id=45287155) - __GLOBAL__ - check `TODO`s in codebase - rename `output` to `txout` or `vout`, `input` to `txin` or `vin` diff --git a/websites/bitview/scripts/chart.js b/websites/bitview/scripts/chart.js index 7a563745a..b0f8733cc 100644 --- a/websites/bitview/scripts/chart.js +++ b/websites/bitview/scripts/chart.js @@ -1,585 +1,1153 @@ // @ts-check -const keyPrefix = "chart"; -const ONE_BTC_IN_SATS = 100_000_000; -const AUTO = "auto"; -const LINE = "line"; -const CANDLE = "candle"; +/** @import { IChartApi, ISeriesApi as _ISeriesApi, SeriesDefinition, SingleValueData as _SingleValueData, CandlestickData as _CandlestickData, BaselineData as _BaselineData, HistogramData as _HistogramData, SeriesType, IPaneApi, LineSeriesPartialOptions as _LineSeriesPartialOptions, HistogramSeriesPartialOptions as _HistogramSeriesPartialOptions, BaselineSeriesPartialOptions as _BaselineSeriesPartialOptions, CandlestickSeriesPartialOptions as _CandlestickSeriesPartialOptions, WhitespaceData, DeepPartial, ChartOptions, Time, LineData as _LineData } from './packages/lightweight-charts/5.0.8/dist/typings' */ /** - * @typedef {"timestamp" | "date" | "week" | "d.epoch" | "month" | "quarter" | "semester" | "year" | "decade" } SerializedChartableIndex + * @typedef {[number, number, number, number]} OHLCTuple + * + * @typedef {Object} Valued + * @property {number} value + * + * @typedef {Object} Indexed + * @property {number} index + * + * @typedef {_ISeriesApi} ISeries + * @typedef {_ISeriesApi<'Candlestick', number>} CandlestickISeries + * @typedef {_ISeriesApi<'Histogram', number>} HistogramISeries + * @typedef {_ISeriesApi<'Line', number>} LineISeries + * @typedef {_ISeriesApi<'Baseline', number>} BaselineISeries + * + * @typedef {_LineSeriesPartialOptions} LineSeriesPartialOptions + * @typedef {_HistogramSeriesPartialOptions} HistogramSeriesPartialOptions + * @typedef {_BaselineSeriesPartialOptions} BaselineSeriesPartialOptions + * @typedef {_CandlestickSeriesPartialOptions} CandlestickSeriesPartialOptions + * + * @typedef {Object} Series + * @property {ISeries} inner + * @property {string} id + * @property {Signal} active + * @property {Signal} hasData + * @property {Signal} url + * @property {VoidFunction} remove + * + * @typedef {_SingleValueData} SingleValueData + * @typedef {_CandlestickData} CandlestickData + * @typedef {_LineData} LineData + * @typedef {_BaselineData} BaselineData + * @typedef {_HistogramData} HistogramData + * + * @typedef {function({ iseries: ISeries; unit: Unit; index: Index }): void} SetDataCallback */ +import { + createChart, + CandlestickSeries, + HistogramSeries, + LineSeries, + BaselineSeries, + // } from "./5.0.8/dist/lightweight-charts.standalone.development.mjs"; +} from "./packages/lightweight-charts/5.0.8/dist/lightweight-charts.standalone.production.mjs"; + +const oklchToRGBA = createOklchToRGBA(); + +const lineWidth = /** @type {any} */ (1.5); + /** * @param {Object} args - * @param {Colors} args.colors - * @param {LightweightCharts} args.lightweightCharts - * @param {Accessor} args.option + * @param {string} args.id + * @param {HTMLElement} args.parent * @param {Signals} args.signals + * @param {Colors} args.colors * @param {Utilities} args.utils - * @param {WebSockets} args.webSockets * @param {Elements} args.elements - * @param {Env} args.env * @param {VecsResources} args.vecsResources - * @param {VecIdToIndexes} args.vecIdToIndexes - * @param {Packages} args.packages + * @param {Accessor} args.index + * @param {((unknownTimeScaleCallback: VoidFunction) => void)} [args.timeScaleSetCallback] + * @param {true} [args.fitContent] + * @param {{unit: Unit; blueprints: AnySeriesBlueprint[]}[]} [args.config] */ -export function init({ - colors, - elements, - lightweightCharts, - option, +function createChartElement({ + parent, signals, + colors, utils, - env, - webSockets, + elements, + id: chartId, + index, vecsResources, - vecIdToIndexes, - packages, + timeScaleSetCallback, + fitContent, + config, }) { - elements.charts.append(utils.dom.createShadow("left")); - elements.charts.append(utils.dom.createShadow("right")); + const div = window.document.createElement("div"); + div.classList.add("chart"); + parent.append(div); - const { headerElement, headingElement } = utils.dom.createHeader(); - elements.charts.append(headerElement); - - const { index, fieldset } = createIndexSelector({ - option, - vecIdToIndexes, - signals, + const legendTop = createLegend({ utils, - }); - - const TIMERANGE_LS_KEY = signals.createMemo( - () => `chart-timerange-${index()}`, - ); - - let firstRun = true; - - const from = signals.createSignal(/** @type {number | null} */ (null), { - save: { - ...utils.serde.optNumber, - keyPrefix: TIMERANGE_LS_KEY, - key: "from", - serializeParam: firstRun, - }, - }); - const to = signals.createSignal(/** @type {number | null} */ (null), { - save: { - ...utils.serde.optNumber, - keyPrefix: TIMERANGE_LS_KEY, - key: "to", - serializeParam: firstRun, - }, - }); - - const chart = lightweightCharts.createChartElement({ - parent: elements.charts, signals, - colors, - id: "charts", + }); + div.append(legendTop.element); + + const chartDiv = window.document.createElement("div"); + chartDiv.classList.add("lightweight-chart"); + div.append(chartDiv); + + const legendBottom = createLegend({ utils, - vecsResources, - elements, - index, - timeScaleSetCallback: (unknownTimeScaleCallback) => { - // TODO: Although it mostly works in practice, need to make it more robust, there is no guarantee that this runs in order and wait for `from` and `to` to update when `index` and thus `TIMERANGE_LS_KEY` is updated - // Need to have the right values before the update - - const from_ = from(); - const to_ = to(); - if (from_ !== null && to_ !== null) { - chart.inner.timeScale().setVisibleLogicalRange({ - from: from_, - to: to_, - }); - } else { - unknownTimeScaleCallback(); - } - }, + signals, }); + div.append(legendBottom.element); - console.log(env.ios, "canShare" in navigator); - if (!(env.ios && !("canShare" in navigator))) { - const chartBottomRightCanvas = Array.from( - chart.inner.chartElement().getElementsByTagName("tr"), - ).at(-1)?.lastChild?.firstChild?.firstChild; - if (chartBottomRightCanvas) { - const charts = elements.charts; - const domain = window.document.createElement("p"); - domain.innerText = `${window.location.host}`; - domain.id = "domain"; - const screenshotButton = window.document.createElement("button"); - screenshotButton.id = "screenshot"; - const camera = "[ ◉¯]"; - screenshotButton.innerHTML = camera; - screenshotButton.title = "Screenshot"; - chartBottomRightCanvas.replaceWith(screenshotButton); - screenshotButton.addEventListener("click", () => { - packages.modernScreenshot().then(async ({ screenshot }) => { - charts.dataset.screenshot = "true"; - charts.append(domain); - seriesTypeField.hidden = true; - try { - await screenshot({ - element: charts, - env, - name: option().path.join("-"), - title: option().title, - }); - } catch {} - charts.removeChild(domain); - seriesTypeField.hidden = false; - charts.dataset.screenshot = "false"; - }); - }); - } - } - - chart.inner.timeScale().subscribeVisibleLogicalRangeChange( - utils.throttle((t) => { - if (!t) return; - from.set(t.from); - to.set(t.to); - }, 250), - ); - - elements.charts.append(fieldset); - - const { field: seriesTypeField, selected: topSeriesType_ } = - utils.dom.createHorizontalChoiceField({ - defaultValue: CANDLE, - keyPrefix, - key: "seriestype-0", - choices: /** @type {const} */ ([AUTO, CANDLE, LINE]), - signals, - }); - - const topSeriesType = signals.createMemo(() => { - const topSeriesType = topSeriesType_(); - if (topSeriesType === AUTO) { - const t = to(); - const f = from(); - if (!t || !f) return null; - const diff = t - f; - if (diff / chart.inner.paneSize().width <= 0.5) { - return CANDLE; - } else { - return LINE; - } - } else { - return topSeriesType; - } - }); - - const { field: topUnitField, selected: topUnit } = - utils.dom.createHorizontalChoiceField({ - defaultValue: "usd", - keyPrefix, - key: "unit-0", - choices: /** @type {const} */ ([ - /** @satisfies {Unit} */ ("usd"), - /** @satisfies {Unit} */ ("sats"), - ]), - signals, - sorted: true, - }); - - chart.addFieldsetIfNeeded({ - id: "charts-unit-0", - paneIndex: 0, - position: "nw", - createChild() { - return topUnitField; - }, - }); - - const seriesListTop = /** @type {Series[]} */ ([]); - const seriesListBottom = /** @type {Series[]} */ ([]); - - /** - * @param {Object} params - * @param {ISeries} params.iseries - * @param {Unit} params.unit - * @param {Index} params.index - */ - function printLatest({ iseries, unit, index }) { - const _latest = webSockets.kraken1dCandle.latest(); - - if (!_latest) return; - - const latest = { ..._latest }; - - if (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_ = iseries.data().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(latest.time * 1000); - - switch (index) { - case /** @satisfies {Height} */ (5): - case /** @satisfies {DifficultyEpoch} */ (2): - case /** @satisfies {HalvingEpoch} */ (4): { - if ("close" in last) { - last.low = Math.min(last.low, latest.close); - last.high = Math.max(last.high, latest.close); - } - iseries.update(last); - break; - } - case /** @satisfies {DateIndex} */ (0): { - iseries.update(last); - break; - } - default: { - if (index === /** @satisfies {WeekIndex} */ (23)) { - date.setUTCDate(date.getUTCDate() - ((date.getUTCDay() + 6) % 7)); - } else if (index === /** @satisfies {MonthIndex} */ (7)) { - date.setUTCDate(1); - } else if (index === /** @satisfies {QuarterIndex} */ (19)) { - const month = date.getUTCMonth(); - date.setUTCMonth(month - (month % 3), 1); - } else if (index === /** @satisfies {SemesterIndex} */ (20)) { - const month = date.getUTCMonth(); - date.setUTCMonth(month - (month % 6), 1); - } else if (index === /** @satisfies {YearIndex} */ (24)) { - date.setUTCMonth(0, 1); - } else if (index === /** @satisfies {DecadeIndex} */ (1)) { - date.setUTCFullYear( - Math.floor(date.getUTCFullYear() / 10) * 10, - 0, - 1, - ); - } else { - 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); + /** @type {IChartApi} */ + const ichart = createChart( + chartDiv, + /** @satisfies {DeepPartial} */ ({ + autoSize: true, + layout: { + fontFamily: elements.style.fontFamily, + background: { color: "transparent" }, + attributionLogo: false, + colorSpace: "display-p3", + colorParsers: [oklchToRGBA], + }, + grid: { + vertLines: { visible: false }, + horzLines: { visible: false }, + }, + rightPriceScale: { + borderVisible: false, + }, + timeScale: { + borderVisible: false, + ...(fitContent + ? { + minBarSpacing: 0.001, + } + : {}), + }, + localization: { + priceFormatter: numberToShortUSFormat, + locale: "en-us", + }, + crosshair: { + mode: 3, + }, + ...(fitContent + ? { + handleScale: false, + handleScroll: false, } - iseries.update(last); - } else { - latest.time = time; - iseries.update(last); - } - } - } - } + : {}), + // ..._options, + }), + ); - signals.createEffect(option, (option) => { - headingElement.innerHTML = option.title; + // 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, + }); - const bottomUnits = /** @type {readonly Unit[]} */ ( - Object.keys(option.bottom) - ); - const { field: bottomUnitField, selected: bottomUnit } = - utils.dom.createHorizontalChoiceField({ - defaultValue: bottomUnits.at(0) || "", - keyPrefix, - key: "unit-1", - choices: bottomUnits, - signals, - sorted: true, - }); + ichart.panes().at(0)?.setStretchFactor(1); - if (bottomUnits.length) { - chart.addFieldsetIfNeeded({ - id: "charts-unit-1", - paneIndex: 1, - position: "nw", - createChild() { - return bottomUnitField; + signals.createEffect( + () => ({ + defaultColor: colors.default(), + offColor: colors.gray(), + borderColor: colors.border(), + }), + ({ defaultColor, offColor, borderColor }) => { + ichart.applyOptions({ + layout: { + textColor: offColor, + panes: { + separatorColor: borderColor, + }, + }, + crosshair: { + horzLine: { + color: offColor, + labelBackgroundColor: defaultColor, + }, + vertLine: { + color: offColor, + labelBackgroundColor: defaultColor, + }, }, }); - } + }, + ); - chart.addFieldsetIfNeeded({ - id: "charts-seriestype-0", - paneIndex: 0, - position: "ne", - createChild() { - return seriesTypeField; + signals.createEffect(index, (index) => { + const minBarSpacing = + index === /** @satisfies {MonthIndex} */ (7) + ? 1 + : index === /** @satisfies {QuarterIndex} */ (19) + ? 2 + : index === /** @satisfies {SemesterIndex} */ (20) + ? 3 + : index === /** @satisfies {YearIndex} */ (24) + ? 6 + : index === /** @satisfies {DecadeIndex} */ (1) + ? 60 + : 0.5; + + ichart.applyOptions({ + timeScale: { + timeVisible: + index === /** @satisfies {Height} */ (5) || + index === /** @satisfies {DifficultyEpoch} */ (2) || + index === /** @satisfies {HalvingEpoch} */ (4), + ...(!fitContent + ? { + minBarSpacing, + } + : {}), }, }); + }); - signals.createEffect(index, (index) => { - signals.createEffect( - () => ({ - topUnit: topUnit(), - topSeriesType: topSeriesType(), - }), - ({ topUnit, topSeriesType }) => { - /** @type {Series | undefined} */ - let series; + const activeResources = /** @type {Set} */ (new Set()); + ichart.subscribeCrosshairMove( + utils.throttle(() => { + activeResources.forEach((v) => { + v.fetch(); + }); + }), + ); - console.log({ topUnit, topSeriesType }); + if (fitContent) { + new ResizeObserver(() => ichart.timeScale().fitContent()).observe(chartDiv); + } - switch (topUnit) { - case "usd": { - switch (topSeriesType) { - case null: - case CANDLE: { - series = chart.addCandlestickSeries({ - vecId: "price_ohlc", - name: "Price", - unit: topUnit, - setDataCallback: printLatest, - order: 0, - }); - break; - } - case LINE: { - series = chart.addLineSeries({ - vecId: "price_close", - name: "Price", - unit: topUnit, - color: colors.default, - setDataCallback: printLatest, - options: { - priceLineVisible: true, - lastValueVisible: true, - }, - order: 0, - }); - } - } - break; - } - case "sats": { - switch (topSeriesType) { - case null: - case CANDLE: { - series = chart.addCandlestickSeries({ - vecId: "price_ohlc_in_sats", - name: "Price", - unit: topUnit, - inverse: true, - setDataCallback: printLatest, - order: 0, - }); - break; - } - case LINE: { - series = chart.addLineSeries({ - vecId: "price_close_in_sats", - name: "Price", - unit: topUnit, - color: colors.default, - setDataCallback: printLatest, - options: { - priceLineVisible: true, - lastValueVisible: true, - }, - order: 0, - }); - } - } - break; - } + /** + * @param {Object} args + * @param {string} args.id + * @param {number} args.paneIndex + * @param {"nw" | "ne" | "se" | "sw"} args.position + * @param {number} [args.timeout] + * @param {(pane: IPaneApi