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