diff --git a/websites/kibo.money/index.html b/websites/kibo.money/index.html index 32814b3cc..2feb4a0ae 100644 --- a/websites/kibo.money/index.html +++ b/websites/kibo.money/index.html @@ -521,9 +521,10 @@ gap: 0.25rem; > span.colors { + margin-top: 0.25rem; display: flex; - width: 0.75rem; - height: 0.75rem; + width: 0.625rem; + height: 0.625rem; flex-direction: column; overflow: hidden; border-radius: 9999px; @@ -966,9 +967,13 @@ flex-direction: column; min-height: 0; z-index: 20; - margin-bottom: 2rem; + flex: 1; + margin-top: 2rem; + margin-bottom: 1.5rem; > legend { + text-transform: lowercase; + flex-shrink: 0; display: flex; align-items: center; gap: 1.5rem; @@ -976,7 +981,7 @@ margin-right: var(--negative-main-padding); padding-left: var(--main-padding); padding-right: var(--main-padding); - padding-bottom: 1.25rem; + padding-bottom: 1.5rem; overflow-x: auto; min-width: 0; @@ -1015,13 +1020,16 @@ } > a { - padding: 0.375rem; - margin: -0.375rem; + padding-left: 0.375rem; + padding-right: 0.375rem; + margin-left: -0.375rem; + margin-right: -0.375rem; } } } .lightweight-chart { + min-height: 0; height: 100%; margin-right: var(--negative-main-padding); } diff --git a/websites/kibo.money/packages/lightweight-charts/types.d.ts b/websites/kibo.money/packages/lightweight-charts/types.d.ts index 015e59f26..086f112aa 100644 --- a/websites/kibo.money/packages/lightweight-charts/types.d.ts +++ b/websites/kibo.money/packages/lightweight-charts/types.d.ts @@ -64,7 +64,7 @@ interface BaseSeries { visible: Accessor; } interface SingleSeries extends BaseSeries { - iseries: ISeriesApi; + iseries: ISeriesApi; dataset: Accessor<(SingleValueData | CandlestickData)[] | null>; } interface SplitSeries extends BaseSeries { @@ -108,7 +108,7 @@ interface Marker { weight: number; time: Time; value: number; - seriesChunk: ISeriesApi; + seriesChunk: ISeriesApi; } interface HoveredLegend { diff --git a/websites/kibo.money/packages/lightweight-charts/wrapper.js b/websites/kibo.money/packages/lightweight-charts/wrapper.js index 93f3f9e50..7bbe90ad8 100644 --- a/websites/kibo.money/packages/lightweight-charts/wrapper.js +++ b/websites/kibo.money/packages/lightweight-charts/wrapper.js @@ -1,8 +1,10 @@ // @ts-check -/** @import {SeriesDefinition} from './v5.0.5/types' */ +/** @import {ISeriesApi, SeriesDefinition} from './v5.0.5/types' */ export default import("./v5.0.5/script.js").then((lc) => { + const oklchToRGBA = createOklchToRGBA(); + /** * @param {Object} args * @param {HTMLElement} args.element @@ -63,13 +65,6 @@ export default import("./v5.0.5/script.js").then((lc) => { borderColor: colors.border(), }), ({ defaultColor, offColor, borderColor }) => { - console.log( - defaultColor, - offColor, - borderColor, - `rgba(${oklchToRGBA(borderColor).join(", ")})`, - ); - chart.applyOptions({ layout: { textColor: offColor, @@ -102,36 +97,25 @@ export default import("./v5.0.5/script.js").then((lc) => { return chart; } - /** - * @type {DeepPartial} - */ - const defaultSeriesOptions = { - // @ts-ignore - lineWidth: 1.5, - // priceLineVisible: false, - // baseLineVisible: false, - // baseLineColor: "", - }; - /** * @param {Object} args * @param {string} args.id * @param {HTMLElement} args.parent * @param {Signals} args.signals * @param {Colors} args.colors - * @param {"static" | "scrollable"} args.kind * @param {Utilities} args.utils * @param {VecsResources} args.vecsResources * @param {Owner | null} [args.owner] + * @param {true} [args.fitContentOnResize] */ function createChartElement({ parent, signals, colors, - kind, utils, vecsResources, owner: _owner, + fitContentOnResize, }) { let owner = _owner || signals.getOwner(); @@ -141,6 +125,8 @@ export default import("./v5.0.5/script.js").then((lc) => { const legend = createLegend({ parent: div, + utils, + signals, }); const chartDiv = window.document.createElement("div"); @@ -150,89 +136,109 @@ export default import("./v5.0.5/script.js").then((lc) => { let ichart = /** @type {IChartApi | null} */ (null); let timeScaleSet = false; - if (kind === "static") { + if (fitContentOnResize) { new ResizeObserver(() => ichart?.timeScale().fitContent()).observe( chartDiv, ); } /** @type {Index} */ - let index = 0; + let vecIndex = 0; // Default value, overwritten let timeResource = /** @type {VecResource| null} */ (null); + let timeScaleSetCallback = /** @type {VoidFunction | null} */ (null); + /** - * @param {ISeriesApi} series + * @param {ISeriesApi} series * @param {VecResource} valuesResource */ function createSetDataEffect(series, valuesResource) { - signals.createEffect( - () => [timeResource?.fetched(), valuesResource.fetched()], - ([indexes, _ohlcs]) => { - if (!ichart) throw Error("IChart should be initialized"); + signals.runWithOwner(owner, () => + signals.createEffect( + () => [timeResource?.fetched(), valuesResource.fetched()], + ([indexes, _ohlcs]) => { + if (!ichart) throw Error("IChart should be initialized"); - if (!indexes || !_ohlcs) return; - const ohlcs = /** @type {OHLCTuple[]} */ (_ohlcs); - let length = Math.min(indexes.length, ohlcs.length); - const data = new Array(length); - let prevTime = null; - let offset = 0; - for (let i = 0; i < length; i++) { - const time = indexes[i]; - if (prevTime && prevTime === time) { - offset += 1; + if (!indexes || !_ohlcs) return; + const ohlcs = /** @type {OHLCTuple[]} */ (_ohlcs); + let length = Math.min(indexes.length, ohlcs.length); + const data = new Array(length); + let prevTime = null; + let offset = 0; + for (let i = 0; i < length; i++) { + const time = indexes[i]; + if (prevTime && prevTime === time) { + offset += 1; + } + const v = ohlcs[i]; + if (typeof v === "number") { + data[i - offset] = { + time, + value: v, + }; + } else { + data[i - offset] = { + time, + open: v[0], + high: v[1], + low: v[2], + close: v[3], + }; + } + prevTime = time; } - const v = ohlcs[i]; - if (typeof v === "number") { - data[i - offset] = { - time, - value: v, - }; - } else { - data[i - offset] = { - time, - open: v[0], - high: v[1], - low: v[2], - close: v[3], - }; + data.length -= offset; + series.setData(data); + timeScaleSetCallback?.(); + if ( + !timeScaleSet && + (vecIndex === /** @satisfies {Yearindex} */ (15) || + vecIndex === /** @satisfies {Decadeindex} */ (16)) + ) { + ichart + .timeScale() + .setVisibleLogicalRange({ from: -1, to: data.length }); } - prevTime = time; - } - data.length -= offset; - series.setData(data); - if ( - !timeScaleSet && - (index === /** @satisfies {Yearindex} */ (15) || - index === /** @satisfies {Decadeindex} */ (16)) - ) { - ichart - .timeScale() - .setVisibleLogicalRange({ from: -1, to: data.length }); - } - timeScaleSet = true; - }, + timeScaleSet = true; + }, + ), ); } + const activeResources = /** @type {VecResource[]} */ ([]); + + ichart?.subscribeCrosshairMove( + utils.debounce(() => { + activeResources.forEach((v) => { + v.fetch(); + }); + }), + ); + return { + inner: () => ichart, /** - * @param {Index} _index + * @param {Object} args + * @param {Index} args.index + * @param {VoidFunction} [args.timeScaleSetCallback] */ - create(_index) { - index = _index; + create({ index: _index, timeScaleSetCallback: _timeScaleSetCallback }) { + vecIndex = _index; + timeScaleSetCallback = _timeScaleSetCallback || null; + if (ichart) throw Error("IChart shouldn't be initialized"); timeResource = vecsResources.getOrCreate( - index, - index === /** @satisfies {Height} */ (2) + vecIndex, + vecIndex === /** @satisfies {Height} */ (2) ? "fixed-timestamp" : "timestamp", ); timeResource.fetch(); ichart = createLightweightChart({ - index, + index: vecIndex, element: chartDiv, signals, colors, @@ -240,14 +246,18 @@ export default import("./v5.0.5/script.js").then((lc) => { }); }, /** - * @param {VecId} id - * @param {number} [paneNumber] + * @param {Object} args + * @param {VecId} args.vecId + * @param {string} args.name + * @param {number} [args.paneNumber] + * @param {boolean} [args.defaultActive] */ - addCandlestickSeries(id, paneNumber) { + addCandlestickSeries({ vecId, name, paneNumber, defaultActive }) { if (!ichart || !timeResource) throw Error("Chart not fully set"); - const valuesResource = vecsResources.getOrCreate(index, id); + const valuesResource = vecsResources.getOrCreate(vecIndex, vecId); valuesResource.fetch(); + activeResources.push(valuesResource); const green = colors.green(); const red = colors.red(); @@ -259,32 +269,61 @@ export default import("./v5.0.5/script.js").then((lc) => { wickUpColor: green, wickDownColor: red, borderVisible: false, + visible: defaultActive !== false, }, paneNumber, ); + legend.add({ + series, + name, + id: vecId, + defaultActive, + colors: [colors.green, colors.red], + url: valuesResource.url, + }); + createSetDataEffect(series, valuesResource); return series; }, /** - * @param {VecId} id - * @param {number} [paneNumber] + * @param {Object} args + * @param {VecId} args.vecId + * @param {string} args.name + * @param {Color} [args.color] + * @param {number} [args.paneNumber] + * @param {boolean} [args.defaultActive] */ - addLineSeries(id, paneNumber) { + addLineSeries({ vecId, name, color, paneNumber, defaultActive }) { if (!ichart || !timeResource) throw Error("Chart not fully set"); - const valuesResource = vecsResources.getOrCreate(index, id); + const valuesResource = vecsResources.getOrCreate(vecIndex, vecId); valuesResource.fetch(); + activeResources.push(valuesResource); + + color ||= colors.orange; const series = ichart.addSeries( /** @type {SeriesDefinition<'Line'>} */ (lc.LineSeries), { lineWidth: /** @type {any} */ (1.5), + visible: defaultActive !== false, + priceLineVisible: false, + color: color(), }, paneNumber, ); + legend.add({ + series, + colors: [color], + id: vecId, + name, + defaultActive, + url: valuesResource.url, + }); + createSetDataEffect(series, valuesResource); return series; @@ -299,6 +338,7 @@ export default import("./v5.0.5/script.js").then((lc) => { ichart?.remove(); ichart = null; timeScaleSet = false; + activeResources.length = 0; legend.reset(); }, }; @@ -313,19 +353,152 @@ export default import("./v5.0.5/script.js").then((lc) => { /** * @param {Object} args * @param {Element} args.parent + * @param {Signals} args.signals + * @param {Utilities} args.utils */ -function createLegend({ parent }) { +function createLegend({ parent, signals, utils }) { const legendElement = window.document.createElement("legend"); parent.append(legendElement); + const hovered = signals.createSignal( + /** @type {ISeriesApi | null} */ (null), + ); + return { + /** + * @param {Object} args + * @param {ISeriesApi} args.series + * @param {string} args.id + * @param {string} args.name + * @param {Color[]} args.colors + * @param {boolean} [args.defaultActive] + * @param {string} [args.url] + */ + add({ series, id, name, colors, defaultActive, url }) { + const div = window.document.createElement("div"); + + legendElement.append(div); + + const nameId = utils.stringToId(name); + + const active = signals.createSignal(defaultActive ?? true, { + save: { + keyPrefix: id, + key: nameId, + ...utils.serde.boolean, + }, + }); + + signals.createEffect(active, (active) => { + series.applyOptions({ + visible: active, + }); + }); + + const { input, label } = utils.dom.createLabeledInput({ + inputId: utils.stringToId(`legend-${id}-${nameId}`), + inputName: utils.stringToId(`selected-${id}-${nameId}`), + inputValue: "value", + labelTitle: "Click to toggle", + inputChecked: active(), + onClick: () => { + active.set(input.checked); + }, + type: "checkbox", + }); + + const spanMain = window.document.createElement("span"); + spanMain.classList.add("main"); + label.append(spanMain); + + const spanName = utils.dom.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.options(); + 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); + } + }); + + if (url) { + const anchor = window.document.createElement("a"); + anchor.href = url; + anchor.target = "_blank"; + anchor.rel = "noopener noreferrer"; + div.append(anchor); + } + }, reset() { legendElement.innerHTML = ""; }, }; } -const oklchToRGBA = (() => { +function createOklchToRGBA() { { /** * @@ -401,7 +574,13 @@ const oklchToRGBA = (() => { return function (oklch) { oklch = oklch.replace("oklch(", ""); oklch = oklch.replace(")", ""); - const lch = oklch.split(" ").map((v, i) => { + let splitOklch = oklch.split(" / "); + let alpha = 1; + if (splitOklch.length === 2) { + alpha = Number(splitOklch.pop()?.replace("%", "")) / 100; + } + splitOklch = oklch.split(" "); + const lch = splitOklch.map((v, i) => { if (!i && v.includes("%")) { return Number(v.replace("%", "")) / 100; } else { @@ -415,7 +594,7 @@ const oklchToRGBA = (() => { ).map((v) => { return Math.max(Math.min(Math.round(v * 255), 255), 0); }); - return [...rgb, 1]; + return [...rgb, alpha]; }; } -})(); +} diff --git a/websites/kibo.money/packages/solid-signals/wrapper.js b/websites/kibo.money/packages/solid-signals/wrapper.js index c7153a4a9..351cce69c 100644 --- a/websites/kibo.money/packages/solid-signals/wrapper.js +++ b/websites/kibo.money/packages/solid-signals/wrapper.js @@ -33,7 +33,7 @@ const importSignals = import("./2024-11-02/script.js").then((_signals) => { /** * @template T * @param {T} initialValue - * @param {SignalOptions & {save?: {keyPrefix: string; key: string; serialize: (v: NonNullable) => string; deserialize: (v: string) => NonNullable}}} [options] + * @param {SignalOptions & {save?: {keyPrefix: string; key: string; serialize: (v: T) => string; deserialize: (v: string) => T; serializeParam?: boolean}}} [options] * @returns {Signal} */ createSignal(initialValue, options) { @@ -55,7 +55,11 @@ const importSignals = import("./2024-11-02/script.js").then((_signals) => { const storageKey = `${save.keyPrefix}-${paramKey}`; let serialized = /** @type {string | null} */ (null); - serialized = new URLSearchParams(window.location.search).get(paramKey); + if (options.save.serializeParam !== false) { + serialized = new URLSearchParams(window.location.search).get( + paramKey, + ); + } if (serialized === null) { serialized = localStorage.getItem(storageKey); diff --git a/websites/kibo.money/scripts/chart.js b/websites/kibo.money/scripts/chart.js index 94e83182d..adf80af60 100644 --- a/websites/kibo.money/scripts/chart.js +++ b/websites/kibo.money/scripts/chart.js @@ -36,29 +36,58 @@ export function init({ signals, colors, id: "chart", - kind: "scrollable", utils, vecsResources, }); - const index = createIndexSelector({ elements, signals, utils }); + const index_ = createIndexSelector({ elements, signals, utils }); - // const vecs = signals.createSignal( - // /** @type {Set} */ (new Set()), - // { - // equals: false, - // }, - // ); + let firstRun = true; signals.createEffect(selected, (option) => { titleElement.innerHTML = option.title; - signals.createEffect(index, (index) => { + signals.createEffect(index_, (index) => { + utils.url.writeParam("index", String(index)); + chart.reset({ owner: signals.getOwner() }); - chart.create(index); + const TIMERANGE_LS_KEY = `chart-timerange-${index}`; - const candles = chart.addCandlestickSeries("ohlc"); + 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, + }, + }); + chart.create({ + index, + timeScaleSetCallback: () => { + const from_ = from(); + const to_ = to(); + if (from_ !== null && to_ !== null) { + chart.inner()?.timeScale().setVisibleLogicalRange({ + from: from_, + to: to_, + }); + } + }, + }); + + const candles = chart.addCandlestickSeries({ + vecId: "ohlc", + name: "Price", + }); signals.createEffect(webSockets.kraken1dCandle.latest, (latest) => { if (!latest) return; const last = /** @type { CandlestickData | undefined} */ ( @@ -69,128 +98,35 @@ export function init({ }); [ - { blueprints: option.top, paneIndex: 0 }, - { blueprints: option.bottom, paneIndex: 1 }, - ].forEach(({ blueprints, paneIndex }) => { + { blueprints: option.top, paneNumber: 0 }, + { blueprints: option.bottom, paneNumber: 1 }, + ].forEach(({ blueprints, paneNumber }) => { blueprints?.forEach((blueprint) => { if (vecIdToIndexes[blueprint.key].includes(index)) { - const series = chart.addLineSeries(blueprint.key, paneIndex); - series.applyOptions({ - visible: blueprint.defaultActive !== false, - color: blueprint.color?.(), + chart.addLineSeries({ + vecId: blueprint.key, + color: blueprint.color, + name: blueprint.title, + defaultActive: blueprint.defaultActive, + paneNumber, }); } }); }); + + chart + .inner() + ?.timeScale() + .subscribeVisibleLogicalRangeChange( + utils.debounce((t) => { + from.set(t.from); + to.set(t.to); + }), + ); + + firstRun = false; }); }); - - // function createFetchChunksOfVisibleDatasetsEffect() { - // signals.createEffect( - // () => ({ - // ids: chart.visibleDatasetIds(), - // activeDatasets: activeDatasets(), - // }), - // ({ ids, activeDatasets }) => { - // const datasets = Array.from(activeDatasets); - - // if (ids.length === 0 || datasets.length === 0) return; - - // for (let i = 0; i < ids.length; i++) { - // const id = ids[i]; - // for (let j = 0; j < datasets.length; j++) { - // datasets[j].fetch(id); - // } - // } - // }, - // ); - // } - // createFetchChunksOfVisibleDatasetsEffect(); - - // /** - // * @param {ChartOption} option - // */ - // function applyChartOption(option) { - // chart.visibleTimeRange.set(chart.getInitialVisibleTimeRange()); - - // activeDatasets.set((s) => { - // s.clear(); - // return s; - // }); - - // const chartsBlueprints = [option.top || [], option.bottom].flatMap( - // (list) => (list ? [list] : []), - // ); - - // chartsBlueprints.map((seriesBlueprints, paneIndex) => { - // const chartPane = chart.createPane({ - // paneIndex, - // unit: paneIndex ? option.unit : "US Dollars", - // }); - - // if (!paneIndex) { - // /** @type {AnyDatasetPath} */ - // const datasetPath = `${scale}-to-price`; - - // const dataset = datasets.getOrCreate(scale, datasetPath); - - // // Don't trigger reactivity by design - // activeDatasets().add(dataset); - - // const priceSeries = chartPane.createSplitSeries({ - // blueprint: { - // datasetPath, - // title: "BTC Price", - // type: "Candlestick", - // }, - // dataset, - // id: option.id, - // index: -1, - // }); - - // signals.createEffect(webSockets.kraken1dCandle.latest, (latest) => { - // if (!latest) return; - - // const index = utils.chunkIdToIndex(scale, latest.year); - - // priceSeries.forEach((splitSeries) => { - // const series = splitSeries.chunks.at(index); - // if (series) { - // signals.createEffect(series, (series) => { - // series?.update(latest); - // }); - // } - // }); - // }); - // } - - // [...seriesBlueprints].reverse().forEach((blueprint, index) => { - // const dataset = datasets.getOrCreate(scale, blueprint.datasetPath); - - // // Don't trigger reactivity by design - // activeDatasets().add(dataset); - - // chartPane.createSplitSeries({ - // index, - // blueprint, - // id: option.id, - // dataset, - // }); - // }); - - // activeDatasets.set((s) => s); - - // return chart; - // }); - // } - - // function createApplyChartOptionEffect() { - // signals.createEffect(selected, (option) => { - // chart.reset({ scale: option.scale, owner: signals.getOwner() }); - // applyChartOption(option); - // }); - // } - // createApplyChartOptionEffect(); } /** diff --git a/websites/kibo.money/scripts/main.js b/websites/kibo.money/scripts/main.js index 25c89b34a..e8a83fbbb 100644 --- a/websites/kibo.money/scripts/main.js +++ b/websites/kibo.money/scripts/main.js @@ -866,6 +866,20 @@ function createUtils() { return Number(v); }, }, + optNumber: { + /** + * @param {number | null} v + */ + serialize(v) { + return v !== null ? String(v) : ""; + }, + /** + * @param {string} v + */ + deserialize(v) { + return v ? Number(v) : null; + }, + }, date: { /** * @param {Date} v diff --git a/websites/kibo.money/scripts/options.js b/websites/kibo.money/scripts/options.js index c631aad78..3c801b96b 100644 --- a/websites/kibo.money/scripts/options.js +++ b/websites/kibo.money/scripts/options.js @@ -4946,21 +4946,9 @@ function createPartialOptions(colors) { color: colors.orange, }, { - key: "block-interval-max", - title: "Max", - color: colors.pink, - defaultActive: false, - }, - { - key: "block-interval-min", - title: "Min", - color: colors.green, - defaultActive: false, - }, - { - key: "block-interval-90p", - title: "90p", - color: colors.rose, + key: "block-interval-median", + title: "Median", + color: colors.amber, defaultActive: false, }, { @@ -4969,24 +4957,36 @@ function createPartialOptions(colors) { color: colors.red, defaultActive: false, }, - { - key: "block-interval-median", - title: "Median", - color: colors.amber, - defaultActive: false, - }, { key: "block-interval-25p", title: "25p", color: colors.yellow, defaultActive: false, }, + { + key: "block-interval-90p", + title: "90p", + color: colors.rose, + defaultActive: false, + }, { key: "block-interval-10p", title: "10p", color: colors.lime, defaultActive: false, }, + { + key: "block-interval-max", + title: "Max", + color: colors.pink, + defaultActive: false, + }, + { + key: "block-interval-min", + title: "Min", + color: colors.green, + defaultActive: false, + }, ], }, ],