diff --git a/website/index.html b/website/index.html index 3fa0717df..2e50731db 100644 --- a/website/index.html +++ b/website/index.html @@ -466,6 +466,20 @@ display: block; } + div { + &:has(> * + button[type="reset"]) { + display: flex; + gap: 0.5rem; + align-items: baseline; + + button { + color: var(--off-color); + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + } + } + } + field, h1 { text-transform: capitalize; @@ -655,6 +669,7 @@ appearance: none; background: url('data:image/svg+xml;utf-8,') 100% 50% no-repeat transparent; + flex: 1; &:focus-visible { border: 0; @@ -769,8 +784,8 @@ width: 0.375rem; height: 0.375rem; border-radius: 9999px; - align-self: center; display: inline-block; + margin-bottom: 0.125rem; margin-left: 0.25rem; } } @@ -953,14 +968,14 @@ pointer-events: none; } - .chart-list { + .chart > .panes { position: relative; display: flex; flex-direction: column; flex: 1; min-height: 0; - > .chart-wrapper { + > .pane { z-index: 20; position: relative; min-height: 0px; @@ -1012,9 +1027,9 @@ } } - > .chart-div { + > .lightweight-chart { height: 100%; - margin-right: calc(var(--negative-main-padding) - 0.5rem); + margin-right: var(--negative-main-padding); } } diff --git a/website/packages/lightweight-charts/wrapper.js b/website/packages/lightweight-charts/wrapper.js new file mode 100644 index 000000000..957906815 --- /dev/null +++ b/website/packages/lightweight-charts/wrapper.js @@ -0,0 +1,1274 @@ +// @ts-check + +/** + * @import { DeepPartial, ChartOptions, IChartApi, IHorzScaleBehavior, WhitespaceData, SingleValueData, ISeriesApi, Time, LogicalRange, SeriesType, BaselineStyleOptions, SeriesOptionsCommon, createChart as CreateClassicChart, createChartEx as CreateCustomChart } from "./v4.2.0/types" + */ + +const ids = { + from: "from", + to: "to", + chartRange: "chart-range", + /** + * @param {TimeScale} scale + */ + visibleTimeRange(scale) { + return `${ids.chartRange}-${scale}`; + }, +}; + +export default import("./v4.2.0/script.js").then((lightweightCharts) => { + const createClassicChart = /** @type {CreateClassicChart} */ ( + lightweightCharts.createChart + ); + const createCustomChart = /** @type {CreateCustomChart} */ ( + lightweightCharts.createChartEx + ); + + /** + * @class + * @implements {IHorzScaleBehavior} + */ + class HorzScaleBehaviorHeight { + options() { + return /** @type {any} */ (undefined); + } + setOptions() {} + preprocessData() {} + updateFormatter() {} + + createConverterToInternalObj() { + /** @type {(p: any) => any} */ + return (price) => price; + } + + /** @param {any} item */ + key(item) { + return item; + } + + /** @param {any} item */ + cacheKey(item) { + return item; + } + + /** @param {any} item */ + convertHorzItemToInternal(item) { + return item; + } + + /** @param {any} item */ + formatHorzItem(item) { + return item; + } + + /** @param {any} tickMark */ + formatTickmark(tickMark) { + return tickMark.time.toLocaleString("en-us"); + } + + /** @param {any} tickMarks */ + maxTickMarkWeight(tickMarks) { + return tickMarks.reduce(this.getMarkWithGreaterWeight, tickMarks[0]) + .weight; + } + + /** + * @param {any} sortedTimePoints + * @param {number} startIndex + */ + fillWeightsForPoints(sortedTimePoints, startIndex) { + for (let index = startIndex; index < sortedTimePoints.length; ++index) { + sortedTimePoints[index].timeWeight = this.computeHeightWeight( + sortedTimePoints[index].time, + ); + } + } + + /** + * @param {any} a + * @param {any} b + */ + getMarkWithGreaterWeight(a, b) { + return a.weight > b.weight ? a : b; + } + + /** @param {number} value */ + computeHeightWeight(value) { + // if (value === Math.ceil(value / 1000000) * 1000000) { + // return 12; + // } + if (value === Math.ceil(value / 100000) * 100000) { + return 11; + } + if (value === Math.ceil(value / 10000) * 10000) { + return 10; + } + if (value === Math.ceil(value / 1000) * 1000) { + return 9; + } + if (value === Math.ceil(value / 100) * 100) { + return 8; + } + if (value === Math.ceil(value / 50) * 50) { + return 7; + } + if (value === Math.ceil(value / 25) * 25) { + return 6; + } + if (value === Math.ceil(value / 10) * 10) { + return 5; + } + if (value === Math.ceil(value / 5) * 5) { + return 4; + } + if (value === Math.ceil(value)) { + return 3; + } + if (value * 2 === Math.ceil(value * 2)) { + return 1; + } + + return 0; + } + } + + /** + * @param {Object} args + * @param {TimeScale} args.scale + * @param {HTMLElement} args.element + * @param {Signals} args.signals + * @param {Colors} args.colors + * @param {Utilities} args.utils + * @param {DeepPartial} [args.options] + */ + function createLightweightChart({ + scale, + element, + signals, + colors, + utils, + options: _options = {}, + }) { + /** @satisfies {DeepPartial} */ + const options = { + autoSize: true, + layout: { + fontFamily: "Satoshi Chart", + fontSize: 13, + background: { color: "transparent" }, + attributionLogo: false, + }, + grid: { + vertLines: { visible: false }, + horzLines: { visible: false }, + }, + timeScale: { + minBarSpacing: 0.05, + shiftVisibleRangeOnNewBar: false, + allowShiftVisibleRangeOnWhitespaceReplacement: false, + }, + handleScale: { + axisDoubleClickReset: { + time: false, + }, + }, + localization: { + priceFormatter: utils.locale.numberToShortUSFormat, + locale: "en-us", + }, + ..._options, + }; + + /** @type {IChartApi} */ + let chart; + + if (scale === "date") { + chart = createClassicChart(element, options); + } else { + const horzScaleBehavior = new HorzScaleBehaviorHeight(); + // @ts-ignore + chart = createCustomChart(element, horzScaleBehavior, options); + } + + chart.priceScale("right").applyOptions({ + scaleMargins: { + top: 0.075, + bottom: 0.05, + }, + minimumWidth: 78, + }); + + signals.createEffect( + () => ({ + defaultColor: colors.default(), + offColor: colors.off(), + }), + ({ defaultColor, offColor }) => { + chart.applyOptions({ + layout: { + textColor: offColor, + }, + rightPriceScale: { + borderVisible: false, + }, + timeScale: { + borderVisible: false, + }, + crosshair: { + horzLine: { + color: defaultColor, + labelBackgroundColor: defaultColor, + }, + vertLine: { + color: defaultColor, + labelBackgroundColor: defaultColor, + }, + }, + }); + }, + ); + + return chart; + } + + /** + * @type {DeepPartial} + */ + const defaultSeriesOptions = { + // @ts-ignore + lineWidth: 1.5, + priceLineVisible: false, + baseLineVisible: false, + baseLineColor: "", + }; + + function initWhitespace() { + const whitespaceStartDate = new Date("1970-01-01"); + const whitespaceStartDateYear = whitespaceStartDate.getUTCFullYear(); + const whitespaceStartDateMonth = whitespaceStartDate.getUTCMonth(); + const whitespaceStartDateDate = whitespaceStartDate.getUTCDate(); + const whitespaceEndDate = new Date("2141-01-01"); + let whitespaceDateDataset = + /** @type {(WhitespaceData | SingleValueData)[]} */ ([]); + + /** + * @param {Object} param0 + * @param {Utilities} param0.utils + */ + function initDateWhitespace({ utils }) { + whitespaceDateDataset = new Array( + utils.getNumberOfDaysBetweenTwoDates( + whitespaceStartDate, + whitespaceEndDate, + ), + ); + // Hack to be able to scroll freely + // Setting them all to NaN is much slower + for (let i = 0; i < whitespaceDateDataset.length; i++) { + const date = new Date( + whitespaceStartDateYear, + whitespaceStartDateMonth, + whitespaceStartDateDate + i, + ); + + const time = utils.date.toString(date); + + if (i === whitespaceDateDataset.length - 1) { + whitespaceDateDataset[i] = { + time, + value: NaN, + }; + } else { + whitespaceDateDataset[i] = { + time, + }; + } + } + } + + const heightStart = -50_000; + let whitespaceHeightDataset = /** @type {WhitespaceData[]} */ ([]); + + function initHeightWhitespace() { + whitespaceHeightDataset = new Array( + (new Date().getUTCFullYear() - 2009 + 1) * 60_000, + ); + for (let i = 0; i < whitespaceHeightDataset.length; i++) { + const height = heightStart + i; + + whitespaceHeightDataset[i] = { + time: /** @type {Time} */ (height), + }; + } + } + + /** + * @param {Object} param0 + * @param {IChartApi} param0.chart + * @param {TimeScale} param0.scale + * @param {Utilities} param0.utils + * @returns {ISeriesApi<'Line'>} + */ + function setWhitespace({ chart, scale, utils }) { + const whitespace = chart.addLineSeries(); + + if (scale === "date") { + if (!whitespaceDateDataset.length) { + initDateWhitespace({ utils }); + } + + whitespace.setData(whitespaceDateDataset); + } else { + if (!whitespaceHeightDataset.length) { + initHeightWhitespace(); + } + + whitespace.setData(whitespaceHeightDataset); + + const time = whitespaceHeightDataset.length; + whitespace.update({ + time: /** @type {Time} */ (time), + value: NaN, + }); + } + + return whitespace; + } + + return { setWhitespace }; + } + const { setWhitespace } = initWhitespace(); + + /** + * @typeof {Object} PaneParameters + * @property {Unit} param.unit + * @param {TimeScale} param.scale + * @param {number} [param.chartIndex] + * @param {true} [param.whitespace] + * @param {DeepPartial} [param.options] + */ + + /** + * @param {Object} param0 + * @param {string} param0.id + * @param {HTMLElement} param0.parent + * @param {Signals} param0.signals + * @param {Colors} param0.colors + * @param {TimeScale} param0.scale + * @param {"static" | "moveable"} param0.kind + * @param {Utilities} param0.utils + * @param {CreatePaneParameters[]} [param0.config] + */ + function createChart({ + parent, + signals, + colors, + id: chartId, + kind, + scale, + config, + utils, + }) { + const div = window.document.createElement("div"); + div.classList.add("chart"); + parent.append(div); + + const legendElement = window.document.createElement("legend"); + div.append(legendElement); + + /** + * @returns {TimeRange} + */ + function getInitialVisibleTimeRange() { + const urlParams = new URLSearchParams(window.location.search); + const urlFrom = urlParams.get(ids.from); + const urlTo = urlParams.get(ids.to); + + if (urlFrom && urlTo) { + if (scale === "date" && urlFrom.includes("-") && urlTo.includes("-")) { + console.log({ + from: new Date(urlFrom).toJSON().split("T")[0], + to: new Date(urlTo).toJSON().split("T")[0], + }); + return { + from: new Date(urlFrom).toJSON().split("T")[0], + to: new Date(urlTo).toJSON().split("T")[0], + }; + } else if ( + scale === "height" && + (!urlFrom.includes("-") || !urlTo.includes("-")) + ) { + console.log({ + from: Number(urlFrom), + to: Number(urlTo), + }); + return { + from: Number(urlFrom), + to: Number(urlTo), + }; + } + } + + function getSavedTimeRange() { + return /** @type {TimeRange | null} */ ( + JSON.parse( + localStorage.getItem(ids.visibleTimeRange(scale)) || "null", + ) + ); + } + + const savedTimeRange = getSavedTimeRange(); + + console.log(savedTimeRange); + + if (savedTimeRange) { + return savedTimeRange; + } + + function getDefaultTimeRange() { + switch (scale) { + case "date": { + const defaultTo = new Date(); + const defaultFrom = new Date(); + defaultFrom.setDate(defaultFrom.getUTCDate() - 6 * 30); + + return { + from: defaultFrom.toJSON().split("T")[0], + to: defaultTo.toJSON().split("T")[0], + }; + } + case "height": { + return { + from: 850_000, + to: 900_000, + }; + } + } + } + + return getDefaultTimeRange(); + } + + const visibleTimeRange = signals.createSignal(getInitialVisibleTimeRange()); + + const visibleDatasetIds = signals.createSignal( + /** @type {number[]} */ ([]), + { + equals: false, + }, + ); + + const lastVisibleDatasetIndex = signals.createMemo(() => { + const last = visibleDatasetIds().at(-1); + return last !== undefined ? utils.chunkIdToIndex(scale, last) : undefined; + }); + + function updateVisibleDatasetIds() { + /** @type {number[]} */ + let ids = []; + + const today = new Date(); + const { from: rawFrom, to: rawTo } = visibleTimeRange(); + + if (typeof rawFrom === "string" && typeof rawTo === "string") { + const from = new Date(rawFrom).getUTCFullYear(); + const to = new Date(rawTo).getUTCFullYear(); + + ids = Array.from({ length: to - from + 1 }, (_, i) => i + from).filter( + (year) => year >= 2009 && year <= today.getUTCFullYear(), + ); + } else { + const from = Math.floor(Number(rawFrom) / consts.HEIGHT_CHUNK_SIZE); + const to = Math.floor(Number(rawTo) / consts.HEIGHT_CHUNK_SIZE); + + const length = to - from + 1; + + ids = Array.from( + { length }, + (_, i) => (from + i) * consts.HEIGHT_CHUNK_SIZE, + ); + } + + const old = visibleDatasetIds(); + + if ( + old.length !== ids.length || + old.at(0) !== ids.at(0) || + old.at(-1) !== ids.at(-1) + ) { + console.log("range:", ids); + + visibleDatasetIds.set(ids); + } + } + const debouncedUpdateVisibleDatasetIds = utils.debounce( + updateVisibleDatasetIds, + 100, + ); + + function saveVisibleRange() { + const range = visibleTimeRange(); + utils.url.writeParam(ids.from, String(range.from)); + utils.url.writeParam(ids.to, String(range.to)); + localStorage.setItem(ids.visibleTimeRange(scale), JSON.stringify(range)); + } + const debouncedSaveVisibleRange = utils.debounce(saveVisibleRange, 250); + + const hoveredLegend = signals.createSignal( + /** @type {HoveredLegend | undefined} */ (undefined), + ); + const notHoveredLegendTransparency = "66"; + /** + * @param {Object} args + * @param {SingleSeries | SplitSeries} args.series + * @param {string} [args.extraName] + */ + function createLegend({ series, extraName }) { + const div = window.document.createElement("div"); + + if ("disabled" in series) { + signals.createEffect(series.disabled, (disabled) => { + div.hidden = disabled; + }); + } + + legendElement.prepend(div); + + extraName ||= "Line"; + + const { input, label } = utils.dom.createLabeledInput({ + inputId: utils.stringToId(`legend-${series.title}-${extraName}`), + inputName: utils.stringToId(`selected-${series.title}-${extraName}`), + inputValue: "value", + labelTitle: "Click to toggle", + onClick: (event) => { + event.preventDefault(); + event.stopPropagation(); + input.checked = !input.checked; + series.active.set(input.checked); + }, + }); + + const spanMain = window.document.createElement("span"); + spanMain.classList.add("main"); + label.append(spanMain); + + const spanName = utils.dom.createSpanName(series.title); + spanMain.append(spanName); + + div.append(label); + label.addEventListener("mouseover", () => { + const hovered = hoveredLegend(); + + if (!hovered || hovered.label !== label) { + hoveredLegend.set({ label, series }); + } + }); + label.addEventListener("mouseleave", () => { + hoveredLegend.set(undefined); + }); + + signals.createEffect(series.active, (checked) => { + input.checked = checked; + }); + + function shouldHighlight() { + const hovered = hoveredLegend(); + return ( + !hovered || + (hovered.label === label && hovered.series.active()) || + (hovered.label !== label && !hovered.series.active()) + ); + } + + const spanColors = window.document.createElement("span"); + spanColors.classList.add("colors"); + spanMain.prepend(spanColors); + const colors = Array.isArray(series.color) + ? series.color + : [series.color]; + 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 = `${color}${notHoveredLegendTransparency}`; + } + }, + ); + }); + + function createHoverEffect() { + const initialColors = /** @type {Record} */ ({}); + const darkenedColors = /** @type {Record} */ ({}); + + /** @type {HoveredLegend | undefined} */ + let previouslyHovered = undefined; + + /** + * @param {Object} param0 + * @param {HoveredLegend | undefined} param0.hovered + * @param {ISeriesApi | undefined} param0.series + * @param {number} [param0.seriesIndex] + */ + function applySeriesOption({ hovered, series, seriesIndex = 0 }) { + if (!series) return; + + const i = seriesIndex; + + if (hovered) { + const seriesOptions = series.options(); + if (!seriesOptions) return; + + initialColors[i] = {}; + darkenedColors[i] = {}; + + Object.entries(seriesOptions).forEach(([k, v]) => { + if (k.toLowerCase().includes("color") && v) { + if (typeof v === "string" && !v.startsWith("#")) { + return; + } + + v = /** @type {string} */ (v).substring(0, 7); + initialColors[i][k] = v; + darkenedColors[i][k] = `${v}${notHoveredLegendTransparency}`; + } else if (k === "lastValueVisible" && v) { + initialColors[i][k] = true; + darkenedColors[i][k] = false; + } + }); + } + + signals.createEffect(shouldHighlight, (shouldHighlight) => { + if (shouldHighlight) { + series.applyOptions(initialColors[i]); + } else { + series.applyOptions(darkenedColors[i]); + } + }); + } + + signals.createEffect( + () => ({ + hovered: hoveredLegend(), + ids: visibleDatasetIds(), + }), + ({ hovered, ids }) => { + if (!hovered && !previouslyHovered) return hovered; + + if ("chunks" in series) { + for (let i = 0; i < ids.length; i++) { + const chunkId = ids[i]; + const chunkIndex = utils.chunkIdToIndex(scale, chunkId); + const chunk = series.chunks[chunkIndex]; + + signals.createEffect(chunk, (chunk) => { + applySeriesOption({ + hovered, + series: chunk, + seriesIndex: i, + }); + }); + } + } else { + applySeriesOption({ + series: series.series, + hovered, + }); + } + + previouslyHovered = hovered; + }, + ); + } + createHoverEffect(); + + if ("dataset" in series) { + const anchor = window.document.createElement("a"); + anchor.href = series.dataset.url; + anchor.target = "_blank"; + anchor.rel = "noopener noreferrer"; + div.append(anchor); + } + } + + const panesElement = window.document.createElement("div"); + panesElement.classList.add("panes"); + div.append(panesElement); + + /** @type {ChartPane[]} */ + const panes = []; + + if (kind === "static") { + new ResizeObserver(() => { + panes.forEach((chart) => { + chart.timeScale().fitContent(); + }); + }).observe(panesElement); + } + + /** + * @param {CreatePaneParameters} param + */ + function createPane({ paneIndex, whitespace, unit, options, config }) { + const chartWrapper = window.document.createElement("div"); + chartWrapper.classList.add("pane"); + panesElement.append(chartWrapper); + + const chartDiv = window.document.createElement("div"); + chartDiv.classList.add("lightweight-chart"); + chartWrapper.append(chartDiv); + + options = { ...options }; + if (kind === "static") { + options.handleScale = false; + options.handleScroll = false; + } else { + options.crosshair = { + ...options.crosshair, + mode: 0, + }; + } + + const _chart = createLightweightChart({ + scale, + element: chartDiv, + signals, + colors, + options, + utils, + }); + + /** + * @param {CreateBaselineSeriesParams} args + */ + function createBaseLineSeries({ color, options, owner, data }) { + const topLineColor = color || colors.profit; + const bottomLineColor = color || colors.loss; + + function computeColors() { + return { + topLineColor: topLineColor(), + bottomLineColor: bottomLineColor(), + }; + } + + const transparent = "transparent"; + + /** @type {DeepPartial} */ + const seriesOptions = { + priceScaleId: "right", + ...defaultSeriesOptions, + ...options, + topFillColor1: transparent, + topFillColor2: transparent, + bottomFillColor1: transparent, + bottomFillColor2: transparent, + ...computeColors(), + }; + + const series = _chart.addBaselineSeries(seriesOptions); + + signals.runWithOwner(owner, () => { + signals.createEffect(computeColors, (computeColors) => { + series.applyOptions(computeColors); + }); + }); + + if (data) { + series.setData(data); + } + + return series; + } + + /** + * @param {CreateCandlestickSeriesParams} args + */ + function createCandlestickSeries({ options, owner, data }) { + function computeColors() { + const upColor = colors.profit(); + const downColor = colors.loss(); + + return { + upColor, + wickUpColor: upColor, + downColor, + wickDownColor: downColor, + }; + } + + const series = _chart.addCandlestickSeries({ + baseLineVisible: false, + borderVisible: false, + priceLineVisible: false, + baseLineColor: "", + borderColor: "", + borderDownColor: "", + borderUpColor: "", + ...options, + ...computeColors(), + }); + + signals.runWithOwner(owner, () => { + signals.createEffect(computeColors, (computeColors) => { + series.applyOptions(computeColors); + }); + }); + + if (data) { + series.setData(data); + } + + return series; + } + + /** + * @param {CreateLineSeriesParams} args + */ + function createLineSeries({ color, options, owner, data }) { + function computeColors() { + return { + color: color(), + }; + } + + const series = _chart.addLineSeries({ + ...defaultSeriesOptions, + ...options, + ...computeColors(), + }); + + if (data) { + series.setData(data); + } + + signals.runWithOwner(owner, () => { + signals.createEffect(computeColors, (computeColors) => { + series.applyOptions(computeColors); + }); + }); + + return series; + } + + /** + * @template {TimeScale} S + * @param {CreateSplitSeriesParameters} args + */ + function createSplitSeries({ + option, + index: seriesIndex, + disabled: _disabled, + setMinMaxMarkersWhenIdle, + dataset, + seriesBlueprint, + splitSeries, + }) { + const { + title, + color, + defaultActive, + type, + options: seriesOptions, + } = seriesBlueprint; + + /** @type {Signal | undefined>[]} */ + const chunks = new Array(dataset.fetchedJSONs.length); + + const id = utils.stringToId(title); + const storageId = utils.stringToId(`${option.id}-${title}`); + + const active = signals.createSignal( + utils.url.readBoolParam(id) ?? + utils.storage.readBool(storageId) ?? + defaultActive ?? + true, + ); + + const disabled = signals.createMemo(_disabled || (() => false)); + + const visible = signals.createMemo(() => active() && !disabled()); + + /** @satisfies {SplitSeries} */ + const series = { + active, + chunks, + color: color || [colors.profit, colors.loss], + dataset, + disabled, + id, + title, + visible, + }; + + signals.createEffect( + () => ({ disabled: disabled(), active: active() }), + ({ disabled, active }) => { + if (disabled) { + return; + } + + if (active !== (defaultActive || true)) { + utils.url.writeParam(id, active); + utils.storage.write(storageId, active); + } else { + utils.url.removeParam(id); + utils.storage.remove(storageId); + } + }, + ); + + splitSeries.push(series); + + const owner = signals.getOwner(); + + dataset.fetchedJSONs.forEach((json, index) => { + const chunk = signals.createSignal( + /** @type {ISeriesApi | undefined} */ (undefined), + ); + + chunks[index] = chunk; + + const isMyTurn = signals.createMemo(() => { + if (seriesIndex <= 0) return true; + + const previousSeriesChunk = splitSeries.at(seriesIndex - 1)?.chunks[ + index + ]; + const isPreviousSeriesOnChart = previousSeriesChunk?.(); + + return !!isPreviousSeriesOnChart; + }); + + signals.createEffect( + () => ({ values: json.vec(), isMyTurn: isMyTurn() }), + ({ values, isMyTurn }) => { + if (!values || !isMyTurn) return; + + let s = chunk(); + + if (!s) { + switch (type) { + case "Baseline": { + s = createBaseLineSeries({ + color, + options: seriesOptions, + owner, + }); + break; + } + case "Candlestick": { + s = createCandlestickSeries({ + options: seriesOptions, + owner, + }); + break; + } + default: + case "Line": { + s = createLineSeries({ + color, + options: seriesOptions, + owner, + }); + break; + } + } + + chunk.set(s); + } + + s.setData(values); + + setMinMaxMarkersWhenIdle(); + }, + ); + + signals.createEffect( + () => ({ + chunk: chunk(), + currentVec: dataset.fetchedJSONs.at(index)?.vec(), + nextVec: dataset.fetchedJSONs.at(index + 1)?.vec(), + }), + ({ chunk, currentVec, nextVec }) => { + if (chunk && currentVec?.length && nextVec?.length) { + chunk.update(nextVec[0]); + } + }, + ); + + signals.createEffect(chunk, (chunk) => { + const isChunkLastVisible = signals.createMemo(() => { + const last = lastVisibleDatasetIndex(); + return last !== undefined && last === index; + }); + + signals.createEffect( + () => ({ + visible: series.visible(), + isChunkLastVisible: isChunkLastVisible(), + }), + ({ visible, isChunkLastVisible }) => { + chunk?.applyOptions({ + lastValueVisible: visible && isChunkLastVisible, + }); + }, + ); + }); + + const shouldChunkBeVisible = signals.createMemo(() => { + if (visibleDatasetIds().length) { + const start = utils.chunkIdToIndex( + scale, + /** @type {number} */ (visibleDatasetIds().at(0)), + ); + const end = utils.chunkIdToIndex( + scale, + /** @type {number} */ (visibleDatasetIds().at(-1)), + ); + + if (index >= start && index <= end) { + return true; + } + } + + return false; + }); + + let wasChunkVisible = false; + const chunkVisible = signals.createMemo(() => { + if (series.disabled()) { + wasChunkVisible = false; + } else { + wasChunkVisible = wasChunkVisible || shouldChunkBeVisible(); + } + return wasChunkVisible; + }); + + signals.createEffect(chunk, (chunk) => { + if (!chunk) return; + + const visible = signals.createMemo( + () => series.visible() && chunkVisible(), + ); + + signals.createEffect(visible, (visible) => { + chunk.applyOptions({ + visible, + }); + }); + }); + }); + + createLegend({ series, extraName: type }); + + return series; + } + + const chartPane = /** @type {ChartPane} */ (_chart); + + chartPane.createSplitSeries = createSplitSeries; + chartPane.createBaseLineSeries = createBaseLineSeries; + chartPane.createCandlesticksSeries = createCandlestickSeries; + chartPane.createLineSeries = createLineSeries; + chartPane.hidden = () => { + return chartWrapper.hidden; + }; + chartPane.setHidden = (b) => { + chartWrapper.hidden = b; + }; + chartPane.setInitialVisibleTimeRange = () => { + const range = visibleTimeRange(); + + if (range) { + chartPane.timeScale().setVisibleRange(/** @type {any} */ (range)); + + // On small screen it doesn't it might not set it in time + setTimeout(() => { + try { + chartPane.timeScale().setVisibleRange(/** @type {any} */ (range)); + } catch {} + }, 50); + } + }; + + if (whitespace) { + chartPane.whitespace = setWhitespace({ chart: _chart, scale, utils }); + } + + function createUnitAndModeElements() { + const fieldset = window.document.createElement("fieldset"); + fieldset.dataset.size = "sm"; + chartWrapper.append(fieldset); + + const id = `chart-${chartId}-${paneIndex}-mode`; + + const chartModes = /** @type {const} */ (["Lin", "Log"]); + const chartMode = signals.createSignal( + /** @type {Lowercase} */ ( + localStorage.getItem(id) || "lin" + ), + ); + + const field = utils.dom.createHorizontalChoiceField({ + choices: chartModes, + selected: chartMode(), + id, + title: unit, + signals, + }); + fieldset.append(field); + + field.addEventListener("change", (event) => { + // @ts-ignore + const value = event.target.value; + localStorage.setItem(id, value); + chartMode.set(value); + }); + + signals.createEffect(chartMode, (chartMode) => + _chart.priceScale("right").applyOptions({ + mode: chartMode === "lin" ? 0 : 1, + }), + ); + } + createUnitAndModeElements(); + + config?.forEach((params) => { + // createLegend(params); + switch (params.kind) { + case "line": { + chartPane.createLineSeries(params); + break; + } + case "candle": { + chartPane.createCandlesticksSeries(params); + break; + } + case "baseline": { + chartPane.createBaseLineSeries(params); + break; + } + } + }); + + switch (kind) { + case "static": { + chartPane.timeScale().fitContent(); + + break; + } + case "moveable": { + chartPane.setInitialVisibleTimeRange(); + updateVisibleDatasetIds(); + + if (!paneIndex) { + setTimeout(() => { + chartPane.timeScale().subscribeVisibleTimeRangeChange((range) => { + if (!range) return; + visibleTimeRange.set(range); + debouncedUpdateVisibleDatasetIds(); + debouncedSaveVisibleRange(); + }); + }); + } + + break; + } + } + + panes.push(chartPane); + + return chartPane; + } + + config?.forEach((params) => { + createPane(params); + }); + + /** + * + * @param {Object} param0 + * @param {TimeScale} param0.scale + */ + function reset({ scale: _scale }) { + scale = _scale; + panes.forEach((pane) => pane.remove()); + panes.length = 0; + legendElement.innerHTML = ""; + panesElement.innerHTML = ""; + } + + /** + * @param {Object} args + * @param {LogicalRange} [args.visibleLogicalRange] + * @param {TimeRange} [args.visibleTimeRange] + */ + function getTicksToWidthRatio({ visibleLogicalRange, visibleTimeRange }) { + try { + const chartPane = panes.find((pane) => !pane.hidden()); + if (!chartPane) return; + const width = chartPane.chartElement().clientWidth; + + /** @type {number} */ + let ratio; + + if (visibleLogicalRange) { + ratio = (visibleLogicalRange.to - visibleLogicalRange.from) / width; + } else if (visibleTimeRange) { + if (scale === "date") { + const to = /** @type {Time} */ (visibleTimeRange.to); + const from = /** @type {Time} */ (visibleTimeRange.from); + + ratio = + utils.getNumberOfDaysBetweenTwoDates( + utils.date.fromTime(from), + utils.date.fromTime(to), + ) / width; + } else { + const to = /** @type {number} */ (visibleTimeRange.to); + const from = /** @type {number} */ (visibleTimeRange.from); + + ratio = (to - from) / width; + } + } else { + throw Error(); + } + + return ratio; + } catch {} + } + + return { + legendElement, + panesElement, + createPane, + hoveredLegend, + createLegend, + panes, + reset, + visibleTimeRange, + visibleDatasetIds, + lastVisibleDatasetIndex, + getInitialVisibleTimeRange, + updateVisibleDatasetIds, + debouncedUpdateVisibleDatasetIds, + saveVisibleRange, + getTicksToWidthRatio, + debouncedSaveVisibleRange, + }; + } + + return { + createChart, + }; +}); diff --git a/website/packages/solid-signals/types.d.ts b/website/packages/solid-signals/types.d.ts new file mode 100644 index 000000000..d56de24a8 --- /dev/null +++ b/website/packages/solid-signals/types.d.ts @@ -0,0 +1,4 @@ +import { Accessor, Setter } from "./2024-11-02/types/signals"; + +export type Signal = Accessor & { set: Setter; reset: VoidFunction }; +export type Signals = Awaited; diff --git a/website/packages/solid-signals/wrapper.js b/website/packages/solid-signals/wrapper.js new file mode 100644 index 000000000..462d16e11 --- /dev/null +++ b/website/packages/solid-signals/wrapper.js @@ -0,0 +1,138 @@ +// @ts-check + +/** + * @import { SignalOptions } from "./2024-11-02/types/core/core" + * @import { getOwner as GetOwner, onCleanup as OnCleanup, Owner } from "./2024-11-02/types/core/owner" + * @import { createSignal as CreateSignal, createEffect as CreateEffect, Accessor, Setter, createMemo as CreateMemo, createRoot as CreateRoot, runWithOwner as RunWithOwner } from "./2024-11-02/types/signals"; + * @import { Signal } from "./types"; + */ + +const importSignals = import("./2024-11-02/script.js").then((_signals) => { + const signals = { + createSolidSignal: /** @type {CreateSignal} */ (_signals.createSignal), + createSolidEffect: /** @type {CreateEffect} */ (_signals.createEffect), + createEffect: /** @type {CreateEffect} */ (compute, effect) => { + let dispose = /** @type {VoidFunction | null} */ (null); + // @ts-ignore + _signals.createEffect(compute, (v) => { + dispose?.(); + signals.createRoot((_dispose) => { + dispose = _dispose; + effect(v); + }); + signals.onCleanup(() => dispose?.()); + }); + signals.onCleanup(() => dispose?.()); + }, + createMemo: /** @type {CreateMemo} */ (_signals.createMemo), + createRoot: /** @type {CreateRoot} */ (_signals.createRoot), + getOwner: /** @type {GetOwner} */ (_signals.getOwner), + runWithOwner: /** @type {RunWithOwner} */ (_signals.runWithOwner), + onCleanup: /** @type {OnCleanup} */ (_signals.onCleanup), + flushSync: _signals.flushSync, + /** + * @template T + * @param {T} initialValue + * @param {SignalOptions & {save?: {id?: string; param?: string; serialize: (v: NonNullable) => string; deserialize: (v: string) => NonNullable}}} [options] + * @returns {Signal} + */ + createSignal(initialValue, options) { + const [get, set] = this.createSolidSignal( + /** @type {any} */ (initialValue), + options, + ); + + // @ts-ignore + get.set = set; + + // @ts-ignore + get.reset = () => set(initialValue); + + if (options?.save) { + const save = options.save; + + let serialized = /** @type {string | null} */ (null); + if (save.param) { + serialized = new URLSearchParams(window.location.search).get( + save.param, + ); + } + if (serialized === null && save.id) { + serialized = localStorage.getItem(save.id); + } + if (serialized) { + set(save.deserialize(serialized)); + } + + let firstEffect = true; + this.createEffect(get, (value) => { + if (!save) return; + + if (!firstEffect && save.id) { + if ( + value !== undefined && + value !== null && + (initialValue === undefined || + initialValue === null || + save.serialize(value) !== save.serialize(initialValue)) + ) { + localStorage.setItem(save.id, save.serialize(value)); + } else { + localStorage.removeItem(save.id); + } + } + + if (save.param) { + if ( + value !== undefined && + value !== null && + (initialValue === undefined || + initialValue === null || + save.serialize(value) !== save.serialize(initialValue)) + ) { + writeParam(save.param, save.serialize(value)); + } else { + removeParam(save.param); + } + } + + firstEffect = false; + }); + } + + // @ts-ignore + return get; + }, + }; + + return signals; +}); + +/** + * @param {string} key + * @param {string | undefined} value + */ +function writeParam(key, value) { + const urlParams = new URLSearchParams(window.location.search); + + if (value !== undefined) { + urlParams.set(key, String(value)); + } else { + urlParams.delete(key); + } + + window.history.replaceState( + null, + "", + `${window.location.pathname}?${urlParams.toString()}`, + ); +} + +/** + * @param {string} key + */ +function removeParam(key) { + writeParam(key, undefined); +} + +export default importSignals; diff --git a/website/scripts/chart.js b/website/scripts/chart.js index 14f26da1d..bc29719d0 100644 --- a/website/scripts/chart.js +++ b/website/scripts/chart.js @@ -1,32 +1,28 @@ +// @ts-check + /** - * @import { HoveredLegend, PriceSeriesType, Series } from "./types/self" + * @import { ChartPane, HoveredLegend, PriceSeriesType, SplitSeries } from "./types/self" * @import { Options } from './options'; */ /** * @param {Object} args * @param {Colors} args.colors - * @param {Consts} args.consts * @param {LightweightCharts} args.lightweightCharts * @param {Accessor} args.selected * @param {Signals} args.signals * @param {Utilities} args.utils - * @param {Options} args.options * @param {Datasets} args.datasets * @param {WebSockets} args.webSockets * @param {Elements} args.elements - * @param {Ids} args.ids * @param {Accessor} args.dark */ export function init({ colors, - consts, dark, datasets, elements, - ids, lightweightCharts, - options, selected, signals, utils, @@ -34,138 +30,29 @@ export function init({ }) { console.log("init chart state"); - /** @type {ChartPane[]} */ - let charts = []; - const scale = signals.createMemo(() => selected().scale); elements.charts.append(utils.dom.createShadow("left")); elements.charts.append(utils.dom.createShadow("right")); const { headerElement, titleElement, descriptionElement } = - utils.dom.createHeader({ - title: selected().title, - description: selected().serializedPath, - }); + utils.dom.createHeader({}); elements.charts.append(headerElement); signals.createEffect(selected, (option) => { titleElement.innerHTML = option.title; descriptionElement.innerHTML = option.serializedPath; }); - // const div = window.document.createElement("div"); - // elements.charts.append(div); - - // const legendElement = window.document.createElement("legend"); - // div.append(legendElement); - - // const chartListElement = window.document.createElement("div"); - // chartListElement.classList.add("chart-list"); - // div.append(chartListElement); - // - const { - chartListElement, - legendElement, - createPane: addChart, - } = lightweightCharts.createChart({ + const chart = lightweightCharts.createChart({ parent: elements.charts, signals, colors, id: "chart", + scale: scale(), + kind: "moveable", + utils, }); - /** - * @returns {TimeRange} - */ - function getInitialVisibleTimeRange() { - const urlParams = new URLSearchParams(window.location.search); - - const urlFrom = urlParams.get(ids.from); - const urlTo = urlParams.get(ids.to); - - if (urlFrom && urlTo) { - if (scale() === "date" && urlFrom.includes("-") && urlTo.includes("-")) { - console.log({ - from: new Date(urlFrom).toJSON().split("T")[0], - to: new Date(urlTo).toJSON().split("T")[0], - }); - return { - from: new Date(urlFrom).toJSON().split("T")[0], - to: new Date(urlTo).toJSON().split("T")[0], - }; - } else if ( - scale() === "height" && - (!urlFrom.includes("-") || !urlTo.includes("-")) - ) { - console.log({ - from: Number(urlFrom), - to: Number(urlTo), - }); - return { - from: Number(urlFrom), - to: Number(urlTo), - }; - } - } - - function getSavedTimeRange() { - return /** @type {TimeRange | null} */ ( - JSON.parse( - localStorage.getItem(ids.visibleTimeRange(scale())) || "null", - ) - ); - } - - const savedTimeRange = getSavedTimeRange(); - - console.log(savedTimeRange); - - if (savedTimeRange) { - return savedTimeRange; - } - - function getDefaultTimeRange() { - switch (scale()) { - case "date": { - const defaultTo = new Date(); - const defaultFrom = new Date(); - defaultFrom.setDate(defaultFrom.getUTCDate() - 6 * 30); - - return { - from: defaultFrom.toJSON().split("T")[0], - to: defaultTo.toJSON().split("T")[0], - }; - } - case "height": { - return { - from: 850_000, - to: 900_000, - }; - } - } - } - - return getDefaultTimeRange(); - } - - /** - * @param {IChartApi} chart - */ - function setInitialVisibleTimeRange(chart) { - const range = visibleTimeRange(); - - if (range) { - chart.timeScale().setVisibleRange(/** @type {any} */ (range)); - - // On small screen it doesn't it might not set it in time - setTimeout(() => { - try { - chart.timeScale().setVisibleRange(/** @type {any} */ (range)); - } catch {} - }, 50); - } - } - const activeDatasets = signals.createSignal( /** @type {Set>} */ (new Set()), { @@ -173,76 +60,16 @@ export function init({ }, ); - const visibleTimeRange = signals.createSignal(getInitialVisibleTimeRange()); - - const visibleDatasetIds = signals.createSignal(/** @type {number[]} */ ([]), { - equals: false, - }); - - const lastVisibleDatasetIndex = signals.createMemo(() => { - const last = visibleDatasetIds().at(-1); - return last !== undefined ? utils.chunkIdToIndex(scale(), last) : undefined; - }); - const priceSeriesType = signals.createSignal( /** @type {PriceSeriesType} */ ("Candlestick"), ); - function updateVisibleDatasetIds() { - /** @type {number[]} */ - let ids = []; - - const today = new Date(); - const { from: rawFrom, to: rawTo } = visibleTimeRange(); - - if (typeof rawFrom === "string" && typeof rawTo === "string") { - const from = new Date(rawFrom).getUTCFullYear(); - const to = new Date(rawTo).getUTCFullYear(); - - ids = Array.from({ length: to - from + 1 }, (_, i) => i + from).filter( - (year) => year >= 2009 && year <= today.getUTCFullYear(), - ); - } else { - const from = Math.floor(Number(rawFrom) / consts.HEIGHT_CHUNK_SIZE); - const to = Math.floor(Number(rawTo) / consts.HEIGHT_CHUNK_SIZE); - - const length = to - from + 1; - - ids = Array.from( - { length }, - (_, i) => (from + i) * consts.HEIGHT_CHUNK_SIZE, - ); - } - - const old = visibleDatasetIds(); - - if ( - old.length !== ids.length || - old.at(0) !== ids.at(0) || - old.at(-1) !== ids.at(-1) - ) { - console.log("range:", ids); - - visibleDatasetIds.set(ids); - } - } - updateVisibleDatasetIds(); - const debouncedUpdateVisibleDatasetIds = utils.debounce( - updateVisibleDatasetIds, - 100, - ); - - function saveVisibleRange() { - const range = visibleTimeRange(); - utils.url.writeParam(ids.from, String(range.from)); - utils.url.writeParam(ids.to, String(range.to)); - localStorage.setItem(ids.visibleTimeRange(scale()), JSON.stringify(range)); - } - const debouncedSaveVisibleRange = utils.debounce(saveVisibleRange, 250); - function createFetchChunksOfVisibleDatasetsEffect() { signals.createEffect( - () => ({ ids: visibleDatasetIds(), activeDatasets: activeDatasets() }), + () => ({ + ids: chart.visibleDatasetIds(), + activeDatasets: activeDatasets(), + }), ({ ids, activeDatasets }) => { const datasets = Array.from(activeDatasets); @@ -260,465 +87,37 @@ export function init({ createFetchChunksOfVisibleDatasetsEffect(); /** - * @param {IChartApi} chart + * @param {Parameters[0]} args */ - function subscribeVisibleTimeRangeChange(chart) { - chart.timeScale().subscribeVisibleTimeRangeChange((range) => { - if (!range) return; - - visibleTimeRange.set(range); - - debouncedUpdateVisibleDatasetIds(); - - debouncedSaveVisibleRange(); - }); - } - - /** - * @param {Object} args - * @param {IChartApi} args.chart - * @param {LogicalRange} [args.visibleLogicalRange] - * @param {TimeRange} [args.visibleTimeRange] - */ - function updateVisiblePriceSeriesType({ - chart, - visibleLogicalRange, - visibleTimeRange, - }) { - try { - const width = chart.timeScale().width(); - - /** @type {number} */ - let ratio; - - if (visibleLogicalRange) { - ratio = (visibleLogicalRange.to - visibleLogicalRange.from) / width; - } else if (visibleTimeRange) { - if (scale() === "date") { - const to = /** @type {Time} */ (visibleTimeRange.to); - const from = /** @type {Time} */ (visibleTimeRange.from); - - ratio = - utils.getNumberOfDaysBetweenTwoDates( - utils.date.fromTime(from), - utils.date.fromTime(to), - ) / width; - } else { - const to = /** @type {number} */ (visibleTimeRange.to); - const from = /** @type {number} */ (visibleTimeRange.from); - - ratio = (to - from) / width; - } - } else { - throw Error(); - } - + function updateVisiblePriceSeriesType(args) { + const ratio = chart.getTicksToWidthRatio(args); + if (ratio) { if (ratio <= 0.5) { priceSeriesType.set("Candlestick"); } else { priceSeriesType.set("Line"); } - } catch {} + } } const debouncedUpdateVisiblePriceSeriesType = utils.debounce( updateVisiblePriceSeriesType, 50, ); - const hoveredLegend = signals.createSignal( - /** @type {HoveredLegend | undefined} */ (undefined), - ); - const notHoveredLegendTransparency = "66"; - /** - * @param {Object} args - * @param {Series} args.series - * @param {Accessor} [args.disabled] - * @param {string} [args.name] - */ - function createLegend({ series, disabled, name }) { - const div = window.document.createElement("div"); - - if (disabled) { - signals.createEffect(disabled, (disabled) => { - div.hidden = disabled; - }); - } - - legendElement.prepend(div); - - const { input, label } = utils.dom.createLabeledInput({ - inputId: `legend-${series.title}`, - inputName: `selected-${series.title}${name}`, - inputValue: "value", - labelTitle: "Click to toggle", - onClick: (event) => { - event.preventDefault(); - event.stopPropagation(); - input.checked = !input.checked; - series.active.set(input.checked); - }, - }); - - const spanMain = window.document.createElement("span"); - spanMain.classList.add("main"); - label.append(spanMain); - - const spanName = utils.dom.createSpanName(series.title); - spanMain.append(spanName); - - div.append(label); - label.addEventListener("mouseover", () => { - const hovered = hoveredLegend(); - - if (!hovered || hovered.label !== label) { - hoveredLegend.set({ label, series }); - } - }); - label.addEventListener("mouseleave", () => { - hoveredLegend.set(undefined); - }); - - signals.createEffect(series.active, (checked) => { - input.checked = checked; - }); - - function shouldHighlight() { - const hovered = hoveredLegend(); - return ( - !hovered || - (hovered.label === label && hovered.series.active()) || - (hovered.label !== label && !hovered.series.active()) - ); - } - - const spanColors = window.document.createElement("span"); - spanColors.classList.add("colors"); - spanMain.prepend(spanColors); - const colors = Array.isArray(series.color) ? series.color : [series.color]; - 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 = `${color}${notHoveredLegendTransparency}`; - } - }, - ); - }); - - function createHoverEffect() { - const initialColors = /** @type {Record} */ ({}); - const darkenedColors = /** @type {Record} */ ({}); - - /** @type {HoveredLegend | undefined} */ - let previouslyHovered = undefined; - - signals.createEffect( - () => ({ hovered: hoveredLegend(), ids: visibleDatasetIds() }), - ({ hovered, ids }) => { - if (!hovered && !previouslyHovered) return hovered; - - for (let i = 0; i < ids.length; i++) { - const chunkId = ids[i]; - const chunkIndex = utils.chunkIdToIndex(scale(), chunkId); - const chunk = series.chunks[chunkIndex]; - - signals.createEffect(chunk, (chunk) => { - if (!chunk) return; - - if (hovered) { - const seriesOptions = chunk.options(); - if (!seriesOptions) return; - - initialColors[i] = {}; - darkenedColors[i] = {}; - - Object.entries(seriesOptions).forEach(([k, v]) => { - if (k.toLowerCase().includes("color") && v) { - if (typeof v === "string" && !v.startsWith("#")) { - return; - } - - v = /** @type {string} */ (v).substring(0, 7); - initialColors[i][k] = v; - darkenedColors[i][k] = - `${v}${notHoveredLegendTransparency}`; - } else if (k === "lastValueVisible" && v) { - initialColors[i][k] = true; - darkenedColors[i][k] = false; - } - }); - } - - signals.createEffect(shouldHighlight, (shouldHighlight) => { - if (shouldHighlight) { - chunk.applyOptions(initialColors[i]); - } else { - chunk.applyOptions(darkenedColors[i]); - } - }); - }); - } - - previouslyHovered = hovered; - }, - ); - } - createHoverEffect(); - - const anchor = window.document.createElement("a"); - anchor.href = series.dataset.url; - anchor.target = "_blank"; - anchor.rel = "noopener noreferrer"; - div.append(anchor); - } - - /** - * @template {TimeScale} S - * @param {Object} args - * @param {ResourceDataset} args.dataset - * @param {SeriesBlueprint} args.seriesBlueprint - * @param {Option} args.option - * @param {ChartPane} args.chart - * @param {number} args.index - * @param {Series[]} args.chartSeries - * @param {Accessor} args.lastVisibleDatasetIndex - * @param {VoidFunction} args.setMinMaxMarkersWhenIdle - * @param {Accessor} [args.disabled] - */ - function createSeries({ - chart, - option, - index: seriesIndex, - disabled: _disabled, - lastVisibleDatasetIndex, - setMinMaxMarkersWhenIdle, - dataset, - seriesBlueprint, - chartSeries, - }) { - const { - title, - color, - defaultActive, - type, - options: seriesOptions, - } = seriesBlueprint; - - /** @type {Signal | undefined>[]} */ - const chunks = new Array(dataset.fetchedJSONs.length); - - const id = ids.fromString(title); - const storageId = options.optionAndSeriesToKey(option, seriesBlueprint); - - const active = signals.createSignal( - utils.url.readBoolParam(id) ?? - utils.storage.readBool(storageId) ?? - defaultActive ?? - true, - ); - - const disabled = signals.createMemo(_disabled || (() => false)); - - const visible = signals.createMemo(() => active() && !disabled()); - - signals.createEffect( - () => ({ disabled: disabled(), active: active() }), - ({ disabled, active }) => { - if (disabled) { - return; - } - - if (active !== (defaultActive || true)) { - utils.url.writeParam(id, active); - utils.storage.write(storageId, active); - } else { - utils.url.removeParam(id); - utils.storage.remove(storageId); - } - }, - ); - - /** @type {Series} */ - const series = { - active, - chunks, - color: color || [colors.profit, colors.loss], - dataset, - disabled, - id, - title, - visible, - }; - - chartSeries.push(series); - - const owner = signals.getOwner(); - - dataset.fetchedJSONs.forEach((json, index) => { - const chunk = signals.createSignal( - /** @type {ISeriesApi | undefined} */ (undefined), - ); - - chunks[index] = chunk; - - const isMyTurn = signals.createMemo(() => { - if (seriesIndex <= 0) return true; - - const previousSeriesChunk = chartSeries.at(seriesIndex - 1)?.chunks[ - index - ]; - const isPreviousSeriesOnChart = previousSeriesChunk?.(); - - return !!isPreviousSeriesOnChart; - }); - - signals.createEffect( - () => ({ values: json.vec(), isMyTurn: isMyTurn() }), - ({ values, isMyTurn }) => { - if (!values || !isMyTurn) return; - - let s = chunk(); - - if (!s) { - switch (type) { - case "Baseline": { - s = chart.createBaseLineSeries({ - color, - options: seriesOptions, - owner, - }); - break; - } - case "Candlestick": { - s = chart.createCandlesticksSeries({ - options: seriesOptions, - owner, - }); - break; - } - default: - case "Line": { - s = chart.createLineSeries({ - color, - options: seriesOptions, - owner, - }); - break; - } - } - - chunk.set(s); - } - - s.setData(values); - - setMinMaxMarkersWhenIdle(); - }, - ); - - signals.createEffect( - () => ({ - chunk: chunk(), - currentVec: dataset.fetchedJSONs.at(index)?.vec(), - nextVec: dataset.fetchedJSONs.at(index + 1)?.vec(), - }), - ({ chunk, currentVec, nextVec }) => { - if (chunk && currentVec?.length && nextVec?.length) { - chunk.update(nextVec[0]); - } - }, - ); - - signals.createEffect(chunk, (chunk) => { - const isChunkLastVisible = signals.createMemo(() => { - const last = lastVisibleDatasetIndex(); - return last !== undefined && last === index; - }); - - signals.createEffect( - () => ({ - visible: series.visible(), - isChunkLastVisible: isChunkLastVisible(), - }), - ({ visible, isChunkLastVisible }) => { - chunk?.applyOptions({ - lastValueVisible: visible && isChunkLastVisible, - }); - }, - ); - }); - - const shouldChunkBeVisible = signals.createMemo(() => { - if (visibleDatasetIds().length) { - const start = utils.chunkIdToIndex( - scale(), - /** @type {number} */ (visibleDatasetIds().at(0)), - ); - const end = utils.chunkIdToIndex( - scale(), - /** @type {number} */ (visibleDatasetIds().at(-1)), - ); - - if (index >= start && index <= end) { - return true; - } - } - - return false; - }); - - let wasChunkVisible = false; - const chunkVisible = signals.createMemo(() => { - if (series.disabled()) { - wasChunkVisible = false; - } else { - wasChunkVisible = wasChunkVisible || shouldChunkBeVisible(); - } - return wasChunkVisible; - }); - - signals.createEffect(chunk, (chunk) => { - if (!chunk) return; - - const visible = signals.createMemo( - () => series.visible() && chunkVisible(), - ); - - signals.createEffect(visible, (visible) => { - chunk.applyOptions({ - visible, - }); - }); - }); - }); - - createLegend({ series, disabled, name: type }); - - return series; - } - /** * @param {Object} args * @param {PriceSeriesType} args.type * @param {VoidFunction} args.setMinMaxMarkersWhenIdle * @param {Option} args.option - * @param {ChartPane} args.chart - * @param {Series[]} args.chartSeries - * @param {Accessor} args.lastVisibleDatasetIndex + * @param {ChartPane} args.chartPane + * @param {SplitSeries[]} args.chartSeries */ function createPriceSeries({ type, setMinMaxMarkersWhenIdle, option, - chart, - chartSeries, - lastVisibleDatasetIndex, + chartPane, + chartSeries: splitSeries, }) { const s = scale(); @@ -751,14 +150,12 @@ export function init({ const disabled = signals.createMemo(() => priceSeriesType() !== type); - const priceSeries = createSeries({ + const priceSeries = chartPane.createSplitSeries({ seriesBlueprint, dataset, option, index: -1, - chart, - chartSeries, - lastVisibleDatasetIndex, + splitSeries, disabled, setMinMaxMarkersWhenIdle, }); @@ -788,7 +185,7 @@ export function init({ */ function applyChartOption(option) { const scale = option.scale; - visibleTimeRange.set(getInitialVisibleTimeRange()); + chart.visibleTimeRange.set(chart.getInitialVisibleTimeRange()); activeDatasets.set((s) => { s.clear(); @@ -802,25 +199,22 @@ export function init({ (list) => (list ? [list] : []), ); - /** @type {Series[]} */ + /** @type {SplitSeries[]} */ const allSeries = []; - charts = chartsBlueprints.map((seriesBlueprints, chartIndex) => { - const chart = addChart({ - chartIndex, - scale, - unit: chartIndex ? option.unit : "US Dollars", + chartsBlueprints.map((seriesBlueprints, paneIndex) => { + const chartPane = chart.createPane({ + paneIndex, + unit: paneIndex ? option.unit : "US Dollars", whitespace: true, }); - setInitialVisibleTimeRange(chart); - - /** @type {Series[]} */ - const chartSeries = []; + /** @type {SplitSeries[]} */ + const splitSeries = []; function setMinMaxMarkers() { try { - const { from, to } = visibleTimeRange(); + const { from, to } = chart.visibleTimeRange(); const dateFrom = new Date(String(from)); const dateTo = new Date(String(to)); @@ -830,10 +224,10 @@ export function init({ /** @type {Marker | undefined} */ let min = undefined; - const ids = visibleDatasetIds(); + const ids = chart.visibleDatasetIds(); - for (let i = 0; i < chartSeries.length; i++) { - const { chunks, dataset } = chartSeries[i]; + for (let i = 0; i < splitSeries.length; i++) { + const { chunks, dataset } = splitSeries[i]; for (let j = 0; j < ids.length; j++) { const id = ids[j]; @@ -961,26 +355,22 @@ export function init({ function createSetMinMaxMarkersWhenIdleEffect() { signals.createEffect( - () => [visibleTimeRange(), dark()], + () => [chart.visibleTimeRange(), dark()], setMinMaxMarkersWhenIdle, ); } createSetMinMaxMarkersWhenIdleEffect(); - if (!chartIndex) { - subscribeVisibleTimeRangeChange(chart); - + if (!paneIndex) { updateVisiblePriceSeriesType({ - chart, - visibleTimeRange: visibleTimeRange(), + visibleTimeRange: chart.visibleTimeRange(), }); /** @param {PriceSeriesType} type */ function _createPriceSeries(type) { return createPriceSeries({ - chart, - chartSeries, - lastVisibleDatasetIndex, + chartPane, + chartSeries: splitSeries, option, setMinMaxMarkersWhenIdle, type, @@ -1011,14 +401,12 @@ export function init({ // Don't trigger reactivity by design activeDatasets().add(dataset); - createSeries({ + chartPane.createSplitSeries({ index, seriesBlueprint, - chart, option, - lastVisibleDatasetIndex, setMinMaxMarkersWhenIdle, - chartSeries, + splitSeries, dataset, }); }); @@ -1027,7 +415,7 @@ export function init({ activeDatasets.set((s) => s); - chartSeries.forEach((series) => { + splitSeries.forEach((series) => { allSeries.unshift(series); signals.createEffect(series.active, () => { @@ -1036,26 +424,26 @@ export function init({ }); const chartVisible = signals.createMemo(() => - chartSeries.some((series) => series.visible()), + splitSeries.some((series) => series.visible()), ); function createChartVisibilityEffect() { signals.createEffect(chartVisible, (chartVisible) => { - chart.setHidden(!chartVisible); + chartPane.setHidden(!chartVisible); }); } createChartVisibilityEffect(); function createTimeScaleVisibilityEffect() { signals.createEffect(chartVisible, (chartVisible) => { - const visible = chartIndex === chartCount - 1 && chartVisible; + const visible = paneIndex === chartCount - 1 && chartVisible; - chart.timeScale().applyOptions({ + chartPane.timeScale().applyOptions({ visible, }); - if (chartIndex === 1) { - charts[0].timeScale().applyOptions({ + if (paneIndex === 1) { + chart.panes[0].timeScale().applyOptions({ visible: !visible, }); } @@ -1063,31 +451,32 @@ export function init({ } createTimeScaleVisibilityEffect(); - chart.timeScale().subscribeVisibleLogicalRangeChange((logicalRange) => { - if (!logicalRange) return; + chartPane + .timeScale() + .subscribeVisibleLogicalRangeChange((logicalRange) => { + if (!logicalRange) return; - // Must be the chart with the visible timeScale - if (chartIndex === chartCount - 1) { - debouncedUpdateVisiblePriceSeriesType({ - chart, - visibleLogicalRange: logicalRange, - }); - } - - for ( - let otherChartIndex = 0; - otherChartIndex <= chartCount - 1; - otherChartIndex++ - ) { - if (chartIndex !== otherChartIndex) { - charts[otherChartIndex] - .timeScale() - .setVisibleLogicalRange(logicalRange); + // Must be the chart with the visible timeScale + if (paneIndex === chartCount - 1) { + debouncedUpdateVisiblePriceSeriesType({ + visibleLogicalRange: logicalRange, + }); } - } - }); - chart.subscribeCrosshairMove(({ time, sourceEvent }) => { + for ( + let otherChartIndex = 0; + otherChartIndex <= chartCount - 1; + otherChartIndex++ + ) { + if (paneIndex !== otherChartIndex) { + chart.panes[otherChartIndex] + .timeScale() + .setVisibleLogicalRange(logicalRange); + } + } + }); + + chartPane.subscribeCrosshairMove(({ time, sourceEvent }) => { // Don't override crosshair position from scroll event if (time && !sourceEvent) return; @@ -1096,9 +485,9 @@ export function init({ otherChartIndex <= chartCount - 1; otherChartIndex++ ) { - const otherChart = charts[otherChartIndex]; + const otherChart = chart.panes[otherChartIndex]; - if (otherChart && chartIndex !== otherChartIndex) { + if (otherChart && paneIndex !== otherChartIndex) { if (time) { otherChart.setCrosshairPosition(NaN, time, otherChart.whitespace); } else { @@ -1113,28 +502,9 @@ export function init({ }); } - function resetLegendElement() { - legendElement.innerHTML = ""; - } - - function resetChartListElement() { - while ( - chartListElement.lastElementChild?.classList.contains("chart-wrapper") - ) { - chartListElement.lastElementChild?.remove(); - } - } - - function reset() { - charts.forEach((chart) => chart.remove()); - charts = []; - resetLegendElement(); - resetChartListElement(); - } - function createApplyChartOptionEffect() { signals.createEffect(selected, (option) => { - reset(); + chart.reset({ scale: option.scale }); applyChartOption(option); }); } diff --git a/website/scripts/live-price.js b/website/scripts/live-price.js index b45fe93d7..e1c7f148e 100644 --- a/website/scripts/live-price.js +++ b/website/scripts/live-price.js @@ -1,3 +1,5 @@ +// @ts-check + /** * @import {Options} from './options'; */ diff --git a/website/scripts/main.js b/website/scripts/main.js index b2dfde1c3..8d3fc22ec 100644 --- a/website/scripts/main.js +++ b/website/scripts/main.js @@ -1,7 +1,7 @@ // @ts-check /** - * @import { Option, ResourceDataset, TimeScale, TimeRange, Unit, Marker, Weighted, DatasetPath, OHLC, FetchedJSON, DatasetValue, FetchedResult, AnyDatasetPath, SeriesBlueprint, BaselineSpecificSeriesBlueprint, CandlestickSpecificSeriesBlueprint, LineSpecificSeriesBlueprint, SpecificSeriesBlueprintWithChart, Signal, Color, DatasetCandlestickData, PartialChartOption, ChartOption, AnyPartialOption, ProcessedOptionAddons, OptionsTree, AnyPath, SimulationOption, Frequency, CreatePaneParameters, CreateBaselineSeriesParams, CreateCandlestickSeriesParams, CreateLineSeriesParams, LastValues } from "./types/self" + * @import { Option, ResourceDataset, TimeScale, TimeRange, Unit, Marker, Weighted, DatasetPath, OHLC, FetchedJSON, DatasetValue, FetchedResult, AnyDatasetPath, SeriesBlueprint, BaselineSpecificSeriesBlueprint, CandlestickSpecificSeriesBlueprint, LineSpecificSeriesBlueprint, SpecificSeriesBlueprintWithChart, Color, DatasetCandlestickData, PartialChartOption, ChartOption, AnyPartialOption, ProcessedOptionAddons, OptionsTree, AnyPath, SimulationOption, Frequency, CreatePaneParameters, CreateBaselineSeriesParams, CreateCandlestickSeriesParams, CreateLineSeriesParams, LastValues, HoveredLegend, ChartPane, SplitSeries, SingleSeries, CreateSplitSeriesParameters } from "./types/self" * @import {createChart as CreateClassicChart, createChartEx as CreateCustomChart, LineStyleOptions} from "../packages/lightweight-charts/v4.2.0/types"; * @import * as _ from "../packages/ufuzzy/v1.0.14/types" * @import { DeepPartial, ChartOptions, IChartApi, IHorzScaleBehavior, WhitespaceData, SingleValueData, ISeriesApi, Time, LineData, LogicalRange, SeriesMarker, CandlestickData, SeriesType, BaselineStyleOptions, SeriesOptionsCommon } from "../packages/lightweight-charts/v4.2.0/types" @@ -9,729 +9,20 @@ * @import { SignalOptions } from "../packages/solid-signals/2024-11-02/types/core/core" * @import { getOwner as GetOwner, onCleanup as OnCleanup, Owner } from "../packages/solid-signals/2024-11-02/types/core/owner" * @import { createSignal as CreateSignal, createEffect as CreateEffect, Accessor, Setter, createMemo as CreateMemo, createRoot as CreateRoot, runWithOwner as RunWithOwner } from "../packages/solid-signals/2024-11-02/types/signals"; + * @import {Signal, Signals} from "../packages/solid-signals/types"; */ function initPackages() { - async function importSignals() { - return import("../packages/solid-signals/2024-11-02/script.js").then( - (_signals) => { - const signals = { - createSolidSignal: /** @type {CreateSignal} */ ( - _signals.createSignal - ), - createSolidEffect: /** @type {CreateEffect} */ ( - _signals.createEffect - ), - createEffect: /** @type {CreateEffect} */ (compute, effect) => { - let dispose = /** @type {VoidFunction | null} */ (null); - // @ts-ignore - _signals.createEffect(compute, (v) => { - dispose?.(); - signals.createRoot((_dispose) => { - dispose = _dispose; - effect(v); - }); - signals.onCleanup(() => dispose?.()); - }); - signals.onCleanup(() => dispose?.()); - }, - createMemo: /** @type {CreateMemo} */ (_signals.createMemo), - createRoot: /** @type {CreateRoot} */ (_signals.createRoot), - getOwner: /** @type {GetOwner} */ (_signals.getOwner), - runWithOwner: /** @type {RunWithOwner} */ (_signals.runWithOwner), - onCleanup: /** @type {OnCleanup} */ (_signals.onCleanup), - flushSync: _signals.flushSync, - /** - * @template T - * @param {T} initialValue - * @param {SignalOptions & {save?: {id?: string; param?: string; serialize: (v: NonNullable) => string; deserialize: (v: string) => NonNullable}}} [options] - * @returns {Signal} - */ - createSignal(initialValue, options) { - const [get, set] = this.createSolidSignal( - /** @type {any} */ (initialValue), - options, - ); - - // @ts-ignore - get.set = set; - - // @ts-ignore - get.reset = () => set(initialValue); - - if (options?.save) { - const save = options.save; - - let serialized = null; - if (save.param) { - serialized = utils.url.readParam(save.param); - } - if (serialized === null && save.id) { - serialized = utils.storage.read(save.id); - } - if (serialized) { - set(save.deserialize(serialized)); - } - - let firstEffect = true; - this.createEffect(get, (value) => { - if (!save) return; - - if (!firstEffect && save.id) { - if ( - value !== undefined && - value !== null && - (initialValue === undefined || - initialValue === null || - save.serialize(value) !== save.serialize(initialValue)) - ) { - localStorage.setItem(save.id, save.serialize(value)); - } else { - localStorage.removeItem(save.id); - } - } - - if (save.param) { - if ( - value !== undefined && - value !== null && - (initialValue === undefined || - initialValue === null || - save.serialize(value) !== save.serialize(initialValue)) - ) { - utils.url.writeParam(save.param, save.serialize(value)); - } else { - utils.url.removeParam(save.param); - } - } - - firstEffect = false; - }); - } - - // @ts-ignore - return get; - }, - }; - - return signals; - }, - ); - } - - /** @typedef {Awaited>} Signals */ - const imports = { - signals: importSignals, + async signals() { + return import("../packages/solid-signals/wrapper.js").then((d) => + d.default.then((d) => d), + ); + }, async lightweightCharts() { return window.document.fonts.ready.then(() => - import("../packages/lightweight-charts/v4.2.0/script.js").then( - ({ - createChart: createClassicChart, - createChartEx: createCustomChart, - }) => { - /** - * @class - * @implements {IHorzScaleBehavior} - */ - class HorzScaleBehaviorHeight { - options() { - return /** @type {any} */ (undefined); - } - setOptions() {} - preprocessData() {} - updateFormatter() {} - - createConverterToInternalObj() { - /** @type {(p: any) => any} */ - return (price) => price; - } - - /** @param {any} item */ - key(item) { - return item; - } - - /** @param {any} item */ - cacheKey(item) { - return item; - } - - /** @param {any} item */ - convertHorzItemToInternal(item) { - return item; - } - - /** @param {any} item */ - formatHorzItem(item) { - return item; - } - - /** @param {any} tickMark */ - formatTickmark(tickMark) { - return tickMark.time.toLocaleString("en-us"); - } - - /** @param {any} tickMarks */ - maxTickMarkWeight(tickMarks) { - return tickMarks.reduce( - this.getMarkWithGreaterWeight, - tickMarks[0], - ).weight; - } - - /** - * @param {any} sortedTimePoints - * @param {number} startIndex - */ - fillWeightsForPoints(sortedTimePoints, startIndex) { - for ( - let index = startIndex; - index < sortedTimePoints.length; - ++index - ) { - sortedTimePoints[index].timeWeight = this.computeHeightWeight( - sortedTimePoints[index].time, - ); - } - } - - /** - * @param {any} a - * @param {any} b - */ - getMarkWithGreaterWeight(a, b) { - return a.weight > b.weight ? a : b; - } - - /** @param {number} value */ - computeHeightWeight(value) { - // if (value === Math.ceil(value / 1000000) * 1000000) { - // return 12; - // } - if (value === Math.ceil(value / 100000) * 100000) { - return 11; - } - if (value === Math.ceil(value / 10000) * 10000) { - return 10; - } - if (value === Math.ceil(value / 1000) * 1000) { - return 9; - } - if (value === Math.ceil(value / 100) * 100) { - return 8; - } - if (value === Math.ceil(value / 50) * 50) { - return 7; - } - if (value === Math.ceil(value / 25) * 25) { - return 6; - } - if (value === Math.ceil(value / 10) * 10) { - return 5; - } - if (value === Math.ceil(value / 5) * 5) { - return 4; - } - if (value === Math.ceil(value)) { - return 3; - } - if (value * 2 === Math.ceil(value * 2)) { - return 1; - } - - return 0; - } - } - - /** - * @param {Object} args - * @param {TimeScale} args.scale - * @param {HTMLElement} args.element - * @param {Signals} args.signals - * @param {Colors} args.colors - * @param {DeepPartial} [args.options] - */ - function createLightweightChart({ - scale, - element, - signals, - colors, - options: _options = {}, - }) { - /** @satisfies {DeepPartial} */ - const options = { - autoSize: true, - layout: { - fontFamily: "Satoshi Chart", - fontSize: 13, - background: { color: "transparent" }, - attributionLogo: false, - }, - grid: { - vertLines: { visible: false }, - horzLines: { visible: false }, - }, - timeScale: { - minBarSpacing: 0.05, - shiftVisibleRangeOnNewBar: false, - allowShiftVisibleRangeOnWhitespaceReplacement: false, - }, - handleScale: { - axisDoubleClickReset: { - time: false, - }, - }, - localization: { - priceFormatter: utils.locale.numberToShortUSFormat, - locale: "en-us", - ...(scale === "date" - ? { - // dateFormat: "EEEE, dd MMM 'yy", - } - : {}), - }, - ..._options, - }; - - /** @type {IChartApi} */ - let chart; - - if (scale === "date") { - chart = createClassicChart(element, options); - } else { - const horzScaleBehavior = new HorzScaleBehaviorHeight(); - // @ts-ignore - chart = createCustomChart(element, horzScaleBehavior, options); - } - - chart.priceScale("right").applyOptions({ - scaleMargins: { - top: 0.075, - bottom: 0.05, - }, - minimumWidth: 78, - }); - - signals.createEffect( - () => ({ - defaultColor: colors.default(), - offColor: colors.off(), - }), - ({ defaultColor, offColor }) => { - chart.applyOptions({ - layout: { - textColor: offColor, - }, - rightPriceScale: { - borderVisible: false, - }, - timeScale: { - borderVisible: false, - }, - crosshair: { - horzLine: { - color: defaultColor, - labelBackgroundColor: defaultColor, - }, - vertLine: { - color: defaultColor, - labelBackgroundColor: defaultColor, - }, - }, - }); - }, - ); - - return chart; - } - - /** - * @type {DeepPartial} - */ - const defaultSeriesOptions = { - // @ts-ignore - lineWidth: 1.5, - priceLineVisible: false, - baseLineVisible: false, - baseLineColor: "", - }; - - function initWhitespace() { - const whitespaceStartDate = new Date("1970-01-01"); - const whitespaceStartDateYear = - whitespaceStartDate.getUTCFullYear(); - const whitespaceStartDateMonth = - whitespaceStartDate.getUTCMonth(); - const whitespaceStartDateDate = whitespaceStartDate.getUTCDate(); - const whitespaceEndDate = new Date("2141-01-01"); - let whitespaceDateDataset = - /** @type {(WhitespaceData | SingleValueData)[]} */ ([]); - - function initDateWhitespace() { - whitespaceDateDataset = new Array( - utils.getNumberOfDaysBetweenTwoDates( - whitespaceStartDate, - whitespaceEndDate, - ), - ); - // Hack to be able to scroll freely - // Setting them all to NaN is much slower - for (let i = 0; i < whitespaceDateDataset.length; i++) { - const date = new Date( - whitespaceStartDateYear, - whitespaceStartDateMonth, - whitespaceStartDateDate + i, - ); - - const time = utils.date.toString(date); - - if (i === whitespaceDateDataset.length - 1) { - whitespaceDateDataset[i] = { - time, - value: NaN, - }; - } else { - whitespaceDateDataset[i] = { - time, - }; - } - } - } - - const heightStart = -50_000; - let whitespaceHeightDataset = - /** @type {WhitespaceData[]} */ ([]); - - function initHeightWhitespace() { - whitespaceHeightDataset = new Array( - (new Date().getUTCFullYear() - 2009 + 1) * 60_000, - ); - for (let i = 0; i < whitespaceHeightDataset.length; i++) { - const height = heightStart + i; - - whitespaceHeightDataset[i] = { - time: /** @type {Time} */ (height), - }; - } - } - - /** - * @param {IChartApi} chart - * @param {TimeScale} scale - * @returns {ISeriesApi<'Line'>} - */ - function setWhitespace(chart, scale) { - const whitespace = chart.addLineSeries(); - - if (scale === "date") { - if (!whitespaceDateDataset.length) { - initDateWhitespace(); - } - - whitespace.setData(whitespaceDateDataset); - } else { - if (!whitespaceHeightDataset.length) { - initHeightWhitespace(); - } - - whitespace.setData(whitespaceHeightDataset); - - const time = whitespaceHeightDataset.length; - whitespace.update({ - time: /** @type {Time} */ (time), - value: NaN, - }); - } - - return whitespace; - } - - return { setWhitespace }; - } - const { setWhitespace } = initWhitespace(); - - /** - * @typeof {Object} PaneParameters - * @property {Unit} param.unit - * @param {TimeScale} param.scale - * @param {number} [param.chartIndex] - * @param {true} [param.whitespace] - * @param {DeepPartial} [param.options] - */ - - /** - * @param {Object} param0 - * @param {string} param0.id - * @param {HTMLElement} param0.parent - * @param {Signals} param0.signals - * @param {Colors} param0.colors - * @param {"static" | "dynamic"} [param0.kind] - * @param {CreatePaneParameters[]} [param0.config] - */ - function createChart({ - parent, - signals, - colors, - id: chartId, - kind, - config, - }) { - const div = window.document.createElement("div"); - div.classList.add("charts"); - parent.append(div); - - const legendElement = window.document.createElement("legend"); - div.append(legendElement); - - const chartListElement = window.document.createElement("div"); - chartListElement.classList.add("chart-list"); - div.append(chartListElement); - - /** - * @param {CreatePaneParameters} param - */ - function createPane({ - chartIndex, - whitespace, - scale, - unit, - options, - config, - }) { - const chartWrapper = window.document.createElement("div"); - chartWrapper.classList.add("chart-wrapper"); - chartListElement.append(chartWrapper); - - const chartDiv = window.document.createElement("div"); - chartDiv.classList.add("chart-div"); - chartWrapper.append(chartDiv); - - options = { ...options }; - if (kind === "static") { - options.handleScale = false; - options.handleScroll = false; - } else { - options.crosshair = { - ...options.crosshair, - mode: 0, - }; - } - - const _chart = createLightweightChart({ - scale, - element: chartDiv, - signals, - colors, - options, - }); - - /** - * @param {CreateBaselineSeriesParams} args - */ - function createBaseLineSeries({ color, options, owner, data }) { - const topLineColor = color || colors.profit; - const bottomLineColor = color || colors.loss; - - function computeColors() { - return { - topLineColor: topLineColor(), - bottomLineColor: bottomLineColor(), - }; - } - - const transparent = "transparent"; - - /** @type {DeepPartial} */ - const seriesOptions = { - priceScaleId: "right", - ...defaultSeriesOptions, - ...options, - topFillColor1: transparent, - topFillColor2: transparent, - bottomFillColor1: transparent, - bottomFillColor2: transparent, - ...computeColors(), - }; - - const series = _chart.addBaselineSeries(seriesOptions); - - signals.runWithOwner(owner, () => { - signals.createEffect(computeColors, (computeColors) => { - series.applyOptions(computeColors); - }); - }); - - if (data) { - series.setData(data); - } - - return series; - } - - /** - * @param {CreateCandlestickSeriesParams} args - */ - function createCandlestickSeries({ options, owner, data }) { - function computeColors() { - const upColor = colors.profit(); - const downColor = colors.loss(); - - return { - upColor, - wickUpColor: upColor, - downColor, - wickDownColor: downColor, - }; - } - - const series = _chart.addCandlestickSeries({ - baseLineVisible: false, - borderVisible: false, - priceLineVisible: false, - baseLineColor: "", - borderColor: "", - borderDownColor: "", - borderUpColor: "", - ...options, - ...computeColors(), - }); - - signals.runWithOwner(owner, () => { - signals.createEffect(computeColors, (computeColors) => { - series.applyOptions(computeColors); - }); - }); - - if (data) { - series.setData(data); - } - - return series; - } - - /** - * @param {CreateLineSeriesParams} args - */ - function createLineSeries({ color, options, owner, data }) { - function computeColors() { - return { - color: color(), - }; - } - - const series = _chart.addLineSeries({ - ...defaultSeriesOptions, - ...options, - ...computeColors(), - }); - - if (data) { - series.setData(data); - } - - signals.runWithOwner(owner, () => { - signals.createEffect(computeColors, (computeColors) => { - series.applyOptions(computeColors); - }); - }); - - return series; - } - - const chart = - /** @type {IChartApi & { whitespace: ISeriesApi<"Line">, createBaseLineSeries: typeof createBaseLineSeries, createCandlesticksSeries: typeof createCandlestickSeries, createLineSeries: typeof createLineSeries; setHidden: (b: boolean) => void }} */ ( - _chart - ); - - if (whitespace) { - chart.whitespace = setWhitespace(_chart, scale); - } - - chart.createBaseLineSeries = createBaseLineSeries; - chart.createCandlesticksSeries = createCandlestickSeries; - chart.createLineSeries = createLineSeries; - chart.setHidden = (b) => { - chartWrapper.hidden = b; - }; - - function createUnitAndModeElements() { - const fieldset = window.document.createElement("fieldset"); - fieldset.dataset.size = "sm"; - chartWrapper.append(fieldset); - - const id = `chart-${chartId}-${chartIndex}-mode`; - - const chartModes = /** @type {const} */ (["Lin", "Log"]); - const chartMode = signals.createSignal( - /** @type {Lowercase} */ ( - localStorage.getItem(id) || "lin" - ), - ); - - const field = utils.dom.createHorizontalChoiceField({ - choices: chartModes, - selected: chartMode(), - id, - title: unit, - signals, - }); - fieldset.append(field); - - field.addEventListener("change", (event) => { - // @ts-ignore - const value = event.target.value; - localStorage.setItem(id, value); - chartMode.set(value); - }); - - signals.createEffect(chartMode, (chartMode) => - _chart.priceScale("right").applyOptions({ - mode: chartMode === "lin" ? 0 : 1, - }), - ); - } - createUnitAndModeElements(); - - config?.forEach((params) => { - switch (params.kind) { - case "line": { - chart.createLineSeries(params); - break; - } - case "candle": { - chart.createCandlesticksSeries(params); - break; - } - case "baseline": { - chart.createBaseLineSeries(params); - break; - } - } - }); - - if (kind === "static") { - chart.timeScale().fitContent(); - } - - return chart; - } - - config?.forEach((params) => { - createPane(params); - }); - - return { - legendElement, - chartListElement, - createPane, - }; - } - - return { - createChart, - }; - }, + import("../packages/lightweight-charts/wrapper.js").then((d) => + d.default.then((d) => d), ), ); }, @@ -778,9 +69,8 @@ function initPackages() { } const packages = initPackages(); /** - * @typedef {Awaited>} Signals * @typedef {Awaited>} LightweightCharts - * @typedef {ReturnType>['createChart']>['createPane']>} ChartPane + * @typedef {ReturnType} Chart */ const options = import("./options.js"); @@ -1091,19 +381,33 @@ const utils = { * @param {Object} args * @param {string} args.id * @param {string} args.title + * @param {string} args.placeholder * @param {Signal} args.signal * @param {number} args.min - * @param {number} args.max * @param {number} args.step + * @param {number} [args.max] * @param {{createEffect: typeof CreateEffect}} args.signals */ - createInputNumberElement({ id, title, signal, min, max, step, signals }) { + createInputNumberElement({ + id, + title, + signal, + min, + max, + step, + placeholder, + signals, + }) { const input = window.document.createElement("input"); + if (!id || !title || !placeholder) throw Error("input attribute missing"); input.id = id; input.title = title; + input.placeholder = placeholder; input.type = "number"; input.min = String(min); - input.max = String(max); + if (max) { + input.max = String(max); + } input.step = String(step); let stateValue = /** @type {string | null} */ (null); @@ -1123,14 +427,32 @@ const utils = { input.addEventListener("input", () => { const valueSer = input.value; + stateValue = valueSer; const value = Number(valueSer); - if (value >= min && value <= max) { - stateValue = valueSer; + if (value >= min && (max ? value <= max : true)) { signal.set(value); } }); - return input; + return { input, signal }; + }, + /** + * @param {Object} args + * @param {string} args.id + * @param {string} args.title + * @param {Signal} args.signal + * @param {{createEffect: typeof CreateEffect}} args.signals + */ + createInputDollar({ id, title, signal, signals }) { + return this.createInputNumberElement({ + id, + placeholder: "US Dollars", + min: 0, + title, + signal, + signals, + step: 1, + }); }, /** * @param {Object} args @@ -1175,12 +497,12 @@ const utils = { } }); - return input; + return { input, signal }; }, /** * @param {Object} param0 - * @param {string} param0.title - * @param {string} param0.description + * @param {string} [param0.title] + * @param {string} [param0.description] */ createHeader({ title, description }) { const headerElement = window.document.createElement("header"); @@ -1194,12 +516,16 @@ const utils = { h1.style.flexDirection = "column"; const titleElement = window.document.createElement("span"); - titleElement.append(title); + if (title) { + titleElement.append(title); + } h1.append(titleElement); titleElement.style.display = "block"; const descriptionElement = window.document.createElement("small"); - descriptionElement.append(description); + if (description) { + descriptionElement.append(description); + } h1.append(descriptionElement); return { @@ -1229,6 +555,7 @@ const utils = { createSelect({ id, list, signal }) { const select = window.document.createElement("select"); select.name = id; + select.id = id; /** @type {Record} */ const setters = {}; @@ -1262,7 +589,7 @@ const utils = { select.value = signal().value; - return select; + return { select, signal }; }, /** * @param {'left' | 'bottom' | 'top' | 'right'} position @@ -1394,12 +721,8 @@ const utils = { numberToShortUSFormat(value) { const absoluteValue = Math.abs(value); - // value = absoluteValue; - if (isNaN(value)) { return ""; - // } else if (value === 0) { - // return "0"; } else if (absoluteValue < 10) { return utils.locale.numberToUSFormat(value, 3); } else if (absoluteValue < 100) { @@ -1410,7 +733,7 @@ const utils = { return utils.locale.numberToUSFormat(value, 0); } else if (absoluteValue < 1_000_000) { return `${utils.locale.numberToUSFormat(value / 1_000, 1)}K`; - } else if (absoluteValue >= 1_000_000_000_000_000_000) { + } else if (absoluteValue >= 9_000_000_000_000_000) { return "Inf."; } @@ -1735,10 +1058,10 @@ const utils = { : Math.floor(id / consts.HEIGHT_CHUNK_SIZE); }, /** - * @param {string} str + * @param {string} s */ - stringToId(str) { - return str.toLowerCase().replace(" ", "-"); + stringToId(s) { + return s.replace(/\W/g, " ").trim().replace(/ +/g, "-").toLowerCase(); }, }; /** @typedef {typeof utils} Utilities */ @@ -1806,22 +1129,7 @@ const consts = createConstants(); const ids = /** @type {const} */ ({ selectedId: `selected-id`, asideSelectorLabel: `aside-selector-label`, - chartRange: "chart-range", - from: "from", - to: "to", checkedFrameSelectorLabel: "checked-frame-selector-label", - /** - * @param {TimeScale} scale - */ - visibleTimeRange(scale) { - return `${ids.chartRange}-${scale}`; - }, - /** - * @param {string} s - */ - fromString(s) { - return s.replace(/\W/g, " ").trim().replace(/ +/g, "-").toLowerCase(); - }, }); /** @typedef {typeof ids} Ids */ @@ -2751,6 +2059,17 @@ packages.signals().then((signals) => qrcode, }); + function createWindowPopStateEvent() { + window.addEventListener("popstate", (event) => { + const urlSelected = utils.url.pathnameToSelectedId(); + const option = options.list.find((option) => urlSelected === option.id); + if (option) { + options.selected.set(option); + } + }); + } + // createWindowPopStateEvent(); + function initSelected() { function initSelectedFrame() { console.log("selected: init"); @@ -2808,13 +2127,10 @@ packages.signals().then((signals) => signals.runWithOwner(owner, () => initChartsElement({ colors, - consts, dark, datasets, elements, - ids, lightweightCharts, - options, selected: /** @type {any} */ (lastChartOption), signals, utils, diff --git a/website/scripts/moscow-time.js b/website/scripts/moscow-time.js index 0d40137ba..a02d2e0f7 100644 --- a/website/scripts/moscow-time.js +++ b/website/scripts/moscow-time.js @@ -1,3 +1,5 @@ +// @ts-check + /** * @import {Options} from './options'; */ diff --git a/website/scripts/options.js b/website/scripts/options.js index 010bbcbd5..ca9d591eb 100644 --- a/website/scripts/options.js +++ b/website/scripts/options.js @@ -1,5 +1,7 @@ +// @ts-check + /** - * @import { AnySpecificSeriesBlueprint, CohortOption, CohortOptions, Color, DefaultCohortOption, DefaultCohortOptions, OptionPath, OptionsGroup, PartialChartOption, PartialOptionsGroup, PartialOptionsTree, RatioOption, RatioOptions, Series, SeriesBlueprint, SeriesBlueprintParam, SeriesBluePrintType, Signal, TimeScale } from "./types/self" + * @import { AnySpecificSeriesBlueprint, CohortOption, CohortOptions, Color, DefaultCohortOption, DefaultCohortOptions, OptionPath, OptionsGroup, PartialChartOption, PartialOptionsGroup, PartialOptionsTree, RatioOption, RatioOptions, SplitSeries, SeriesBlueprint, SeriesBlueprintParam, SeriesBluePrintType, TimeScale } from "./types/self" */ const DATE_TO_PREFIX = "date-to-"; @@ -5113,6 +5115,10 @@ function createPartialOptions(colors) { name: "Geyser Leaderboard", url: () => "https://geyser.fund/project/kibo/leaderboard", }, + { + name: "Donate to OpenSats", + url: () => "https://opensats.org/", + }, ], }, { @@ -5121,9 +5127,18 @@ function createPartialOptions(colors) { url: () => window.location.href, }, { - name: "Social", - url: () => - "https://primal.net/p/npub1jagmm3x39lmwfnrtvxcs9ac7g300y3dusv9lgzhk2e4x5frpxlrqa73v44", + name: "Socials", + tree: [ + { + name: "Bluesky", + url: () => "https://bsky.app/profile/kibo.money", + }, + { + name: "Nostr", + url: () => + "https://primal.net/p/npub1jagmm3x39lmwfnrtvxcs9ac7g300y3dusv9lgzhk2e4x5frpxlrqa73v44", + }, + ], }, { name: "Developers", @@ -5444,7 +5459,7 @@ export function initOptions({ }, /** @type {HTMLLIElement | null} */ (null)); if ("tree" in anyPartial) { - const folderId = ids.fromString( + const folderId = utils.stringToId( `${(path || [])?.map(({ name }) => name).join(" ")} ${ anyPartial.name } folder`, @@ -5537,16 +5552,16 @@ export function initOptions({ title = anyPartial.title; } else if ("pdf" in anyPartial) { kind = "pdf"; - id = `${path?.at(-1)?.name || ""}-${ids.fromString(anyPartial.name)}-pdf`; + id = `${path?.at(-1)?.name || ""}-${utils.stringToId(anyPartial.name)}-pdf`; title = anyPartial.name; anyPartial.pdf = `/assets/pdfs/${anyPartial.pdf}`; } else if ("url" in anyPartial) { kind = "url"; - id = `${ids.fromString(anyPartial.name)}-url`; + id = `${utils.stringToId(anyPartial.name)}-url`; title = anyPartial.name; } else if ("scale" in anyPartial) { kind = "chart"; - id = `chart-${anyPartial.scale}-to-${ids.fromString( + id = `chart-${anyPartial.scale}-to-${utils.stringToId( anyPartial.title, )}`; title = anyPartial.title; @@ -5554,7 +5569,7 @@ export function initOptions({ kind = anyPartial.kind; title = "title" in anyPartial ? anyPartial.title : anyPartial.name; console.log("Unprocessed", anyPartial); - id = `${kind}-${ids.fromString(title)}`; + id = `${kind}-${utils.stringToId(title)}`; } /** @type {ProcessedOptionAddons} */ @@ -5651,13 +5666,6 @@ export function initOptions({ tree: /** @type {OptionsTree} */ (partialOptions), treeElement, createOptionElement, - /** - * @param {Option} option - * @param {Series | SeriesBlueprint} series - */ - optionAndSeriesToKey(option, series) { - return `${option.id}-${ids.fromString(series.title)}`; - }, }; } /** @typedef {ReturnType} Options */ diff --git a/website/scripts/simulation.js b/website/scripts/simulation.js index 7ef1eaaf9..311b130da 100644 --- a/website/scripts/simulation.js +++ b/website/scripts/simulation.js @@ -1,3 +1,5 @@ +// @ts-check + /** * @import { Options } from './options'; * @import { ColorName, Frequencies, Frequency } from './types/self'; @@ -63,8 +65,8 @@ export function init({ ), }, }, - swap: { - amount: { + bitcoin: { + investment: { initial: signals.createSignal(/** @type {number | null} */ (1000), { save: { ...utils.serde.number, @@ -79,17 +81,17 @@ export function init({ param: "recurrent-swap", }, }), - }, - frequency: signals.createSignal( - /** @type {Frequency} */ (frequencies.list[0]), - { - save: { - ...frequencies.serde, - id: `${storagePrefix}-swap-freq`, - param: "swap-freq", + frequency: signals.createSignal( + /** @type {Frequency} */ (frequencies.list[0]), + { + save: { + ...frequencies.serde, + id: `${storagePrefix}-swap-freq`, + param: "swap-freq", + }, }, - }, - ), + ), + }, }, interval: { start: signals.createSignal( @@ -156,10 +158,14 @@ export function init({ }), description: "The amount of dollars you have ready on the exchange on day one.", - input: createInputDollar({ - id: "simulation-dollars-initial", - title: "Initial Dollar Amount", - signal: settings.dollars.initial.amount, + input: createResetableInput({ + ...utils.dom.createInputDollar({ + id: "simulation-dollars-initial", + title: "Initial Dollar Amount", + signal: settings.dollars.initial.amount, + signals, + }), + utils, }), }), ); @@ -173,10 +179,13 @@ export function init({ }), description: "The frequency at which you'll top up your account at the exchange.", - input: utils.dom.createSelect({ - id: "top-up-frequency", - list: frequencies.list, - signal: settings.dollars.topUp.frenquency, + input: createResetableInput({ + ...utils.dom.createSelect({ + id: "top-up-frequency", + list: frequencies.list, + signal: settings.dollars.topUp.frenquency, + }), + utils, }), }), ); @@ -190,10 +199,14 @@ export function init({ }), description: "The recurrent amount of dollars you'll be transfering to said exchange.", - input: createInputDollar({ - id: "simulation-dollars-later", - title: "Top Up Dollar Amount", - signal: settings.dollars.topUp.amount, + input: createResetableInput({ + ...utils.dom.createInputDollar({ + id: "simulation-dollars-top-up-amount", + title: "Top Up Dollar Amount", + signal: settings.dollars.topUp.amount, + signals, + }), + utils, }), }), ); @@ -202,15 +215,19 @@ export function init({ createFieldElement({ title: createColoredTypeHTML({ color: "orange", - type: "Swap", - text: "Initial Amount", + type: "Bitcoin", + text: "Initial Investment", }), description: "The amount, if available, of dollars that will be used to buy Bitcoin on day one.", - input: createInputDollar({ - id: "simulation-dollars-later", - title: "Initial Swap Amount", - signal: settings.swap.amount.initial, + input: createResetableInput({ + ...utils.dom.createInputDollar({ + id: "simulation-bitcoin-initial-investment", + title: "Initial Swap Amount", + signal: settings.bitcoin.investment.initial, + signals, + }), + utils, }), }), ); @@ -219,14 +236,17 @@ export function init({ createFieldElement({ title: createColoredTypeHTML({ color: "orange", - type: "Swap", - text: "Frequency", + type: "Bitcoin", + text: "Investment Frequency", }), description: "The frequency at which you'll be buying Bitcoin.", - input: utils.dom.createSelect({ - id: "top-up-frequency", - list: frequencies.list, - signal: settings.swap.frequency, + input: createResetableInput({ + ...utils.dom.createSelect({ + id: "investment-frequency", + list: frequencies.list, + signal: settings.bitcoin.investment.frequency, + }), + utils, }), }), ); @@ -235,15 +255,19 @@ export function init({ createFieldElement({ title: createColoredTypeHTML({ color: "orange", - type: "Swap", - text: "Recurrent Amount", + type: "Bitcoin", + text: "Recurrent Investment", }), description: "The recurrent amount, if available, of dollars that will be used to buy Bitcoin.", - input: createInputDollar({ - id: "simulation-dollars-later", - title: "Recurrent Swap Amount", - signal: settings.swap.amount.recurrent, + input: createResetableInput({ + ...utils.dom.createInputDollar({ + id: "simulation-bitcoin-recurrent-investment", + title: "Bitcoin Recurrent Investment", + signal: settings.bitcoin.investment.recurrent, + signals, + }), + utils, }), }), ); @@ -256,9 +280,13 @@ export function init({ text: "Start", }), description: "The first day of the simulation.", - input: createInputDateField({ - signal: settings.interval.start, - signals, + input: createResetableInput({ + ...utils.dom.createInputDate({ + id: "simulation-inverval-start", + title: "First Simulation Date", + signal: settings.interval.start, + signals, + }), utils, }), }), @@ -272,9 +300,13 @@ export function init({ text: "End", }), description: "The last day of the simulation.", - input: createInputDateField({ - signal: settings.interval.end, - signals, + input: createResetableInput({ + ...utils.dom.createInputDate({ + id: "simulation-inverval-end", + title: "Last Simulation Day", + signal: settings.interval.end, + signals, + }), utils, }), }), @@ -288,14 +320,18 @@ export function init({ text: "Exchange", }), description: "The amount of trading fees (in %) at the exchange.", - input: utils.dom.createInputNumberElement({ - id: "", - title: "", - signal: settings.fees.percentage, - min: 0, - max: 50, - step: 0.01, - signals, + input: createResetableInput({ + ...utils.dom.createInputNumberElement({ + id: "simulation-fees", + title: "Exchange Fees", + signal: settings.fees.percentage, + min: 0, + max: 50, + step: 0.01, + signals, + placeholder: "Fees", + }), + utils, }), }), ); @@ -315,9 +351,9 @@ export function init({ initialDollarAmount: settings.dollars.initial.amount() || 0, topUpAmount: settings.dollars.topUp.amount() || 0, topUpFrequency: settings.dollars.topUp.frenquency(), - initialSwap: settings.swap.amount.initial() || 0, - recurrentSwap: settings.swap.amount.recurrent() || 0, - swapFrequency: settings.swap.frequency(), + initialSwap: settings.bitcoin.investment.initial() || 0, + recurrentSwap: settings.bitcoin.investment.recurrent() || 0, + swapFrequency: settings.bitcoin.investment.frequency(), start: settings.interval.start(), end: settings.interval.end(), fees: settings.fees.percentage(), @@ -333,8 +369,6 @@ export function init({ end, fees, }) => { - console.log({ start, end }); - resultsElement.innerHTML = ""; resultsElement.append(p1); resultsElement.append(p2); @@ -611,30 +645,35 @@ export function init({ colors, id: `simulation-0`, kind: "static", + scale: "date", + utils, config: [ { unit: "US Dollars", - scale: "date", config: [ { + title: "Bitcoin Value", kind: "line", color: colors.amber, owner, data: bitcoinValueData, }, { + title: "Dollars Left", kind: "line", color: colors.offDollars, owner, data: dollarsLeftData, }, { + title: "Dollars Converted", kind: "line", color: colors.dollars, owner, data: totalInvestedAmountData, }, { + title: "Fees Paid", kind: "line", color: colors.rose, owner, @@ -650,13 +689,15 @@ export function init({ signals, colors, id: `simulation-1`, + scale: "date", kind: "static", + utils, config: [ { unit: "US Dollars", - scale: "date", config: [ { + title: "Bitcoin Stack", kind: "line", color: colors.bitcoin, owner, @@ -672,19 +713,22 @@ export function init({ signals, colors, id: `simulation-average-price`, + scale: "date", kind: "static", + utils, config: [ { unit: "US Dollars", - scale: "date", config: [ { + title: "Bitcoin Price", kind: "line", owner, color: colors.default, data: bitcoinPriceData, }, { + title: "Average Price Paid", kind: "line", owner, color: colors.lightDollars, @@ -700,13 +744,15 @@ export function init({ signals, colors, id: `simulation-return-ratio`, + scale: "date", kind: "static", + utils, config: [ { unit: "US Dollars", - scale: "date", config: [ { + title: "Return Of Investment", kind: "baseline", owner, data: resultData, @@ -732,18 +778,21 @@ export function init({ colors, id: `simulation-profitability-ratios`, kind: "static", + scale: "date", + utils, config: [ { unit: "Percentage", - scale: "date", config: [ { + title: "Unprofitable Days Ratio", kind: "line", owner, color: colors.red, data: unprofitableDaysRatioData, }, { + title: "Profitable Days Ratio", kind: "line", owner, color: colors.green, @@ -800,58 +849,39 @@ function createFieldElement({ title, description, input }) { div.append(input); + const forId = input.id || input.firstElementChild?.id; + + if (!forId) { + console.log(input); + throw `Input should've an ID`; + } + + // @ts-ignore + label.for = forId; + return div; } /** - * @param {Object} args - * @param {string} args.id - * @param {string} args.title - * @param {Signal} args.signal + * @param {Object} param0 + * @param {Signal} param0.signal + * @param {HTMLInputElement} [param0.input] + * @param {HTMLSelectElement} [param0.select] + * @param {Utilities} param0.utils */ -function createInputDollar({ id, title, signal }) { - const input = window.document.createElement("input"); - input.id = id; - input.type = "number"; - input.placeholder = "US Dollars"; - input.min = "0"; - input.title = title; - - const value = signal(); - input.value = value !== null ? String(value) : ""; - - input.addEventListener("input", () => { - const value = input.value; - signal.set(value ? Number(value) : null); - }); - - return input; -} - -/** - * - * @param {Object} arg - * @param {Signal} arg.signal - * @param {Utilities} arg.utils - * @param {Signals} arg.signals - */ -function createInputDateField({ signal, signals, utils }) { +function createResetableInput({ input, select, signal, utils }) { const div = window.document.createElement("div"); - div.append( - utils.dom.createInputDate({ - id: "", - title: "", - signal, - signals, - }), - ); + const element = input || select; + if (!element) throw "createResetableField element missing"; + div.append(element); const button = utils.dom.createButtonElement({ onClick: signal.reset, text: "Reset", title: "Reset field", }); + button.type = "reset"; div.append(button); diff --git a/website/scripts/types/self.d.ts b/website/scripts/types/self.d.ts index f6ef87808..8443c7f24 100644 --- a/website/scripts/types/self.d.ts +++ b/website/scripts/types/self.d.ts @@ -19,6 +19,7 @@ import { import { DatePath, HeightPath, LastPath } from "./paths"; import { Owner } from "../../packages/solid-signals/2024-11-02/types/core/owner"; import { AnyPossibleCohortId } from "../options"; +import { Signal } from "../../packages/solid-signals/types"; type GrowToSize = A["length"] extends N ? A @@ -26,8 +27,6 @@ type GrowToSize = A["length"] extends N type FixedArray = GrowToSize; -type Signal = Accessor & { set: Setter; reset: VoidFunction }; - type TimeScale = "date" | "height"; type TimeRange = Range