mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-04-25 07:09:59 -07:00
3752 lines
109 KiB
JavaScript
3752 lines
109 KiB
JavaScript
// @ts-check
|
|
|
|
// Lightweight Charts download link: https://unpkg.com/browse/lightweight-charts@4.2.0/dist/
|
|
|
|
/**
|
|
* @import { FilePath, PartialPreset, PartialPresetFolder, PartialPresetTree, Preset, PresetFolder, Series, PriceSeriesType, ResourceDataset, Scale, SerializedPresetsHistory, TimeRange, Unit, Marker, Weighted, DatasetPath, OHLC, FetchedJSON, DatasetValue, FetchedResult, AnyDatasetPath, SeriesBlueprint, BaselineSpecificSeriesBlueprint, CandlestickSpecificSeriesBlueprint, LineSpecificSeriesBlueprint, SpecificSeriesBlueprintWithChart, Signal, Color } from "./types/self"
|
|
* @import * as _ from "./libraries/ufuzzy/types"
|
|
* @import { DeepPartial, ChartOptions, IChartApi, IHorzScaleBehavior, WhitespaceData, SingleValueData, ISeriesApi, Time, LogicalRange, SeriesMarker, CandlestickData, SeriesType, BaselineStyleOptions, SeriesOptionsCommon } from "./libraries/lightweight-charts/types"
|
|
* @import { DatePath, HeightPath } from "./types/paths";
|
|
* @import { SignalOptions, untrack as Untrack } from "./libraries/solid-signals/types/core.js"
|
|
* @import { getOwner as GetOwner, onCleanup as OnCleanup, Owner } from "./libraries/solid-signals/types/owner.js"
|
|
* @import { createSignal as CreateSignal, createEffect as CreateEffect, Accessor, Setter, createMemo as CreateMemo, createRoot as CreateRoot, runWithOwner as RunWithOwner } from "./libraries/solid-signals/types/signals.js";
|
|
*/
|
|
|
|
import {
|
|
createSignal as _createSignal,
|
|
createEffect as _createEffect,
|
|
createMemo as _createMemo,
|
|
createRoot as _createRoot,
|
|
untrack as _untrack,
|
|
getOwner as _getOwner,
|
|
runWithOwner as _runWithOwner,
|
|
onCleanup as _onCleanup,
|
|
} from "./libraries/solid-signals/script.js";
|
|
|
|
const createSolidSignal = /** @type {CreateSignal} */ (_createSignal);
|
|
const createEffect = /** @type {CreateEffect} */ (_createEffect);
|
|
const createMemo = /** @type {CreateMemo} */ (_createMemo);
|
|
const createRoot = /** @type {CreateRoot} */ (_createRoot);
|
|
const untrack = /** @type {Untrack} */ (_untrack);
|
|
const getOwner = /** @type {GetOwner} */ (_getOwner);
|
|
const runWithOwner = /** @type {RunWithOwner} */ (_runWithOwner);
|
|
const onCleanup = /** @type {OnCleanup} */ (_onCleanup);
|
|
/**
|
|
* @template T
|
|
* @param {T} initialValue
|
|
* @param {SignalOptions<T>} [options]
|
|
* @returns {Signal<T>}
|
|
*/
|
|
function createSignal(initialValue, options) {
|
|
const [get, set] = createSolidSignal(initialValue, options);
|
|
// @ts-ignore
|
|
get.set = set;
|
|
// @ts-ignore
|
|
return get;
|
|
}
|
|
|
|
/**
|
|
* @param {string} id
|
|
* @returns {HTMLElement}
|
|
*/
|
|
function getElementById(id) {
|
|
const element = window.document.getElementById(id);
|
|
if (!element) throw `Element with id = "${id}" should exist`;
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} parent
|
|
* @param {HTMLElement} child
|
|
* @param {number} index
|
|
*/
|
|
function insertElementAtIndex(parent, child, index) {
|
|
if (!index) index = 0;
|
|
if (index >= parent.children.length) {
|
|
parent.appendChild(child);
|
|
} else {
|
|
parent.insertBefore(child, parent.children[index]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} s
|
|
* @returns {string}
|
|
*/
|
|
function stringToId(s) {
|
|
return s.replace(/\W/g, " ").trim().replace(/ +/g, "-").toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* @param {VoidFunction} callback
|
|
* @param {number} [timeout = 1]
|
|
*/
|
|
function runWhenIdle(callback, timeout = 1) {
|
|
if ("requestIdleCallback" in window) {
|
|
requestIdleCallback(callback);
|
|
} else {
|
|
setTimeout(callback, timeout);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Date} date
|
|
* @returns {string}
|
|
*/
|
|
function dateToString(date) {
|
|
return date.toJSON().split("T")[0];
|
|
}
|
|
|
|
/**
|
|
* @param {Date} oldest
|
|
* @param {Date} youngest
|
|
* @returns {number}
|
|
*/
|
|
function getNumberOfDaysBetweenTwoDates(oldest, youngest) {
|
|
return Math.round(
|
|
Math.abs((youngest.getTime() - oldest.getTime()) / ONE_DAY_IN_MS),
|
|
);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @template {(...args: any[]) => any} F
|
|
* @param {F} callback
|
|
* @param {number} [wait=250]
|
|
*/
|
|
function debounce(callback, wait = 250) {
|
|
/** @type {number | undefined} */
|
|
let timeoutId;
|
|
/** @type {Parameters<F>} */
|
|
let latestArgs;
|
|
|
|
return (/** @type {Parameters<F>} */ ...args) => {
|
|
latestArgs = args;
|
|
|
|
if (!timeoutId) {
|
|
timeoutId = window.setTimeout(async () => {
|
|
await callback(...latestArgs);
|
|
|
|
timeoutId = undefined;
|
|
}, wait);
|
|
}
|
|
};
|
|
}
|
|
|
|
const urlParamsHelpers = {
|
|
whitelist: ["from", "to"],
|
|
/**
|
|
* @param {Object} args
|
|
* @param {URLSearchParams} [args.urlParams]
|
|
* @param {string} [args.pathname]
|
|
*/
|
|
replaceHistory({ urlParams, pathname }) {
|
|
urlParams ||= new URLSearchParams(window.location.search);
|
|
pathname ||= window.location.pathname;
|
|
|
|
window.history.replaceState(
|
|
null,
|
|
"",
|
|
`${pathname}?${urlParams.toString()}`,
|
|
);
|
|
},
|
|
/**
|
|
* @param {string} [pathname]
|
|
*/
|
|
reset(pathname) {
|
|
const urlParams = new URLSearchParams();
|
|
|
|
[...new URLSearchParams(window.location.search).entries()]
|
|
.filter(([key, _]) => this.whitelist.includes(key))
|
|
.forEach(([key, value]) => {
|
|
urlParams.set(key, value);
|
|
});
|
|
|
|
this.replaceHistory({ urlParams, pathname });
|
|
},
|
|
/**
|
|
* @param {string} key
|
|
* @param {string | boolean | undefined} value
|
|
*/
|
|
write(key, value) {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
|
|
if (value !== undefined) {
|
|
urlParams.set(key, String(value));
|
|
} else {
|
|
urlParams.delete(key);
|
|
}
|
|
|
|
this.replaceHistory({ urlParams });
|
|
},
|
|
/**
|
|
* @param {string} key
|
|
*/
|
|
remove(key) {
|
|
this.write(key, undefined);
|
|
},
|
|
/**
|
|
*
|
|
* @param {string} key
|
|
* @returns {boolean | null}
|
|
*/
|
|
readBool(key) {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
|
|
const parameter = urlParams.get(key);
|
|
|
|
if (parameter) {
|
|
return utils.isSerializedBooleanTrue(parameter);
|
|
}
|
|
|
|
return null;
|
|
},
|
|
};
|
|
|
|
const localeStorageHelpers = {
|
|
/**
|
|
* @param {string} key
|
|
*/
|
|
readBool(key) {
|
|
const saved = localStorage.getItem(key);
|
|
if (saved) {
|
|
return utils.isSerializedBooleanTrue(saved);
|
|
}
|
|
return null;
|
|
},
|
|
/**
|
|
* @param {string} key
|
|
* @param {string | boolean | undefined} value
|
|
*/
|
|
write(key, value) {
|
|
value !== undefined && value !== null
|
|
? localStorage.setItem(key, String(value))
|
|
: localStorage.removeItem(key);
|
|
},
|
|
/**
|
|
* @param {string} key
|
|
*/
|
|
remove(key) {
|
|
this.write(key, undefined);
|
|
},
|
|
};
|
|
|
|
const utils = {
|
|
/**
|
|
* @param {string} serialized
|
|
* @returns {boolean}
|
|
*/
|
|
isSerializedBooleanTrue(serialized) {
|
|
return serialized === "true" || serialized === "1";
|
|
},
|
|
};
|
|
|
|
const ONE_SECOND_IN_MS = 1_000;
|
|
const FIVE_SECOND_IN_MS = 5 * ONE_SECOND_IN_MS;
|
|
const TEN_SECOND_IN_MS = 2 * FIVE_SECOND_IN_MS;
|
|
const ONE_MINUTE_IN_MS = 6 * TEN_SECOND_IN_MS;
|
|
const FIVE_MINUTES_IN_MS = 5 * ONE_MINUTE_IN_MS;
|
|
const TEN_MINUTES_IN_MS = 2 * FIVE_MINUTES_IN_MS;
|
|
const ONE_HOUR_IN_MS = 6 * TEN_MINUTES_IN_MS;
|
|
const ONE_DAY_IN_MS = 24 * ONE_HOUR_IN_MS;
|
|
|
|
const mainFrames = getElementById("frames");
|
|
const chartFrameSelectorLabelId = `selected-frame-selector-label`;
|
|
const chartLabel = getElementById(chartFrameSelectorLabelId);
|
|
const foldersLabel = getElementById(`folders-frame-selector-label`);
|
|
const searchLabel = getElementById(`search-frame-selector-label`);
|
|
const searchFrame = getElementById("search-frame");
|
|
const foldersFrame = getElementById("folders-frame");
|
|
const selectedFrame = getElementById("selected-frame");
|
|
const historyList = getElementById("history-list");
|
|
const searchInput = /** @type {HTMLInputElement} */ (
|
|
getElementById("search-input")
|
|
);
|
|
const searchSmall = getElementById("search-small");
|
|
const searchResults = getElementById("search-results");
|
|
const presetTitle = getElementById("preset-title");
|
|
const presetDescription = getElementById("preset-description");
|
|
const foldersFilterAllCount = getElementById("folders-filter-all-count");
|
|
const foldersFilterFavoritesCount = getElementById(
|
|
"folders-filter-favorites-count",
|
|
);
|
|
const foldersFilterNewCount = getElementById("folders-filter-new-count");
|
|
const chartListElement = getElementById("chart-list");
|
|
const legendElement = getElementById("legend");
|
|
const bodyStyle = getComputedStyle(document.body);
|
|
const buttonFavorite = getElementById("button-favorite");
|
|
const buttonShare = getElementById("button-share");
|
|
const timeScaleDateButtons = getElementById("timescale-date-buttons");
|
|
const timeScaleHeightButtons = getElementById("timescale-height-buttons");
|
|
|
|
function initFrames() {
|
|
const localStorageKey = "checked-frame-selector-label";
|
|
let selectedFrameLabel = localStorage.getItem(localStorageKey);
|
|
|
|
const fieldset = window.document.getElementById("frame-selectors");
|
|
if (!fieldset) throw "Fieldset should exist";
|
|
|
|
const children = Array.from(fieldset.children);
|
|
|
|
/** @type {HTMLElement | undefined} */
|
|
let focusedSection = undefined;
|
|
|
|
for (let i = 0; i < children.length; i++) {
|
|
const element = children[i];
|
|
|
|
switch (element.tagName) {
|
|
case "LABEL": {
|
|
element.addEventListener("click", (event) => {
|
|
const id = element.id;
|
|
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
|
|
const forId = element.getAttribute("for") || "";
|
|
const input = /** @type {HTMLInputElement | undefined} */ (
|
|
window.document.getElementById(forId)
|
|
);
|
|
|
|
if (!input) throw "Shouldn't be possible";
|
|
selectedFrameLabel = id;
|
|
localStorage.setItem(localStorageKey, id);
|
|
input.checked = true;
|
|
|
|
const sectionId = element.id.split("-").splice(0, 2).join("-");
|
|
|
|
const section = window.document.getElementById(sectionId);
|
|
|
|
if (!section) {
|
|
console.log(sectionId, section);
|
|
throw "Section should exist";
|
|
}
|
|
|
|
if (section === focusedSection) {
|
|
return;
|
|
}
|
|
|
|
section.hidden = false;
|
|
if (focusedSection?.parentElement === mainFrames) {
|
|
focusedSection.hidden = true;
|
|
}
|
|
focusedSection = section;
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (selectedFrameLabel) {
|
|
const frameLabel = window.document.getElementById(selectedFrameLabel);
|
|
if (!frameLabel) throw "Frame should exist";
|
|
frameLabel.click();
|
|
} else {
|
|
chartLabel.click();
|
|
}
|
|
|
|
// When going from mobile view to desktop view, if selected frame was open, go to the folders frame
|
|
new IntersectionObserver((entries) => {
|
|
for (let i = 0; i < entries.length; i++) {
|
|
if (
|
|
!entries[i].isIntersecting &&
|
|
entries[i].target === chartLabel &&
|
|
selectedFrameLabel === chartFrameSelectorLabelId
|
|
) {
|
|
foldersLabel.click();
|
|
}
|
|
}
|
|
}).observe(chartLabel);
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
switch (event.key) {
|
|
case "Escape": {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
foldersLabel.click();
|
|
break;
|
|
}
|
|
case "/": {
|
|
if (window.document.activeElement === searchInput) {
|
|
return;
|
|
}
|
|
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
searchLabel.click();
|
|
searchInput.focus();
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
initFrames();
|
|
|
|
const dark = createSignal(true);
|
|
|
|
function createColors() {
|
|
function lightRed() {
|
|
const tailwindRed300 = "#fca5a5";
|
|
const tailwindRed800 = "#991b1b";
|
|
return dark() ? tailwindRed300 : tailwindRed800;
|
|
}
|
|
function red() {
|
|
return "#e63636"; // 550
|
|
}
|
|
function darkRed() {
|
|
const tailwindRed900 = "#7f1d1d";
|
|
const tailwindRed100 = "#fee2e2";
|
|
return dark() ? tailwindRed900 : tailwindRed100;
|
|
}
|
|
function orange() {
|
|
return bodyStyle.getPropertyValue("--orange"); // 550
|
|
}
|
|
function darkOrange() {
|
|
const tailwindOrange900 = "#7c2d12";
|
|
const tailwindOrange100 = "#ffedd5";
|
|
return dark() ? tailwindOrange900 : tailwindOrange100;
|
|
}
|
|
function amber() {
|
|
return "#e78a05"; // 550
|
|
}
|
|
function yellow() {
|
|
return "#db9e03"; // 550
|
|
}
|
|
function lime() {
|
|
return "#74b713"; // 550
|
|
}
|
|
function green() {
|
|
return "#1cb454";
|
|
}
|
|
function darkGreen() {
|
|
const tailwindGreen900 = "#14532d";
|
|
const tailwindGreen100 = "#dcfce7";
|
|
return dark() ? tailwindGreen900 : tailwindGreen100;
|
|
}
|
|
function emerald() {
|
|
return "#0ba775";
|
|
}
|
|
function darkEmerald() {
|
|
const tailwindEmerald900 = "#064e3b";
|
|
const tailwindEmerald100 = "#d1fae5";
|
|
return dark() ? tailwindEmerald900 : tailwindEmerald100;
|
|
}
|
|
function teal() {
|
|
return "#10a697"; // 550
|
|
}
|
|
function cyan() {
|
|
return "#06a3c3"; // 550
|
|
}
|
|
function sky() {
|
|
return "#0794d8"; // 550
|
|
}
|
|
function blue() {
|
|
return "#2f73f1"; // 550
|
|
}
|
|
function indigo() {
|
|
return "#5957eb";
|
|
}
|
|
function violet() {
|
|
return "#834cf2";
|
|
}
|
|
function purple() {
|
|
return "#9d45f0";
|
|
}
|
|
function fuchsia() {
|
|
return "#cc37e1";
|
|
}
|
|
function pink() {
|
|
return "#e53882";
|
|
}
|
|
function rose() {
|
|
return "#ea3053";
|
|
}
|
|
function darkRose() {
|
|
const tailwindRose900 = "#881337";
|
|
const tailwindRose100 = "#ffe4e6";
|
|
return dark() ? tailwindRose900 : tailwindRose100;
|
|
}
|
|
|
|
function darkWhite() {
|
|
const _ = dark();
|
|
return bodyStyle.getPropertyValue("--off-color");
|
|
}
|
|
|
|
function gray() {
|
|
const _ = dark();
|
|
return bodyStyle.getPropertyValue("--border-color");
|
|
}
|
|
|
|
function white() {
|
|
const _ = dark();
|
|
return bodyStyle.getPropertyValue("--color");
|
|
}
|
|
|
|
function black() {
|
|
const _ = dark();
|
|
return bodyStyle.getPropertyValue("--background-color");
|
|
}
|
|
|
|
return {
|
|
white,
|
|
black,
|
|
darkWhite,
|
|
gray,
|
|
lightBitcoin: yellow,
|
|
bitcoin: orange,
|
|
darkBitcoin: darkOrange,
|
|
lightDollars: lime,
|
|
dollars: emerald,
|
|
darkDollars: darkEmerald,
|
|
|
|
_1d: lightRed,
|
|
_1w: red,
|
|
_8d: orange,
|
|
_13d: amber,
|
|
_21d: yellow,
|
|
_1m: lime,
|
|
_34d: green,
|
|
_55d: emerald,
|
|
_89d: teal,
|
|
_144d: cyan,
|
|
_6m: sky,
|
|
_1y: blue,
|
|
_2y: indigo,
|
|
_200w: violet,
|
|
_4y: purple,
|
|
_10y: fuchsia,
|
|
|
|
p2pk: lime,
|
|
p2pkh: violet,
|
|
p2sh: emerald,
|
|
p2wpkh: cyan,
|
|
p2wsh: pink,
|
|
p2tr: blue,
|
|
crab: red,
|
|
fish: lime,
|
|
humpback: violet,
|
|
plankton: emerald,
|
|
shark: cyan,
|
|
shrimp: pink,
|
|
whale: blue,
|
|
megalodon: purple,
|
|
realizedPrice: orange,
|
|
oneMonthHolders: cyan,
|
|
threeMonthsHolders: lime,
|
|
sth: yellow,
|
|
sixMonthsHolder: red,
|
|
oneYearHolders: pink,
|
|
twoYearsHolders: purple,
|
|
lth: fuchsia,
|
|
balancedPrice: yellow,
|
|
cointimePrice: yellow,
|
|
trueMarketMeanPrice: blue,
|
|
vaultedPrice: green,
|
|
cvdd: lime,
|
|
terminalPrice: red,
|
|
loss: red,
|
|
darkLoss: darkRed,
|
|
profit: green,
|
|
darkProfit: darkGreen,
|
|
thermoCap: green,
|
|
investorCap: rose,
|
|
realizedCap: orange,
|
|
ethereum: indigo,
|
|
usdt: emerald,
|
|
usdc: blue,
|
|
ust: red,
|
|
busd: yellow,
|
|
usdd: emerald,
|
|
frax: gray,
|
|
dai: amber,
|
|
tusd: indigo,
|
|
pyusd: blue,
|
|
darkLiveliness: darkRose,
|
|
liveliness: rose,
|
|
vaultedness: green,
|
|
activityToVaultednessRatio: violet,
|
|
up_to_1d: lightRed,
|
|
up_to_1w: red,
|
|
up_to_1m: orange,
|
|
up_to_2m: orange,
|
|
up_to_3m: orange,
|
|
up_to_4m: orange,
|
|
up_to_5m: orange,
|
|
up_to_6m: orange,
|
|
up_to_1y: orange,
|
|
up_to_2y: orange,
|
|
up_to_3y: orange,
|
|
up_to_4y: orange,
|
|
up_to_5y: orange,
|
|
up_to_7y: orange,
|
|
up_to_10y: orange,
|
|
up_to_15y: orange,
|
|
from_10y_to_15y: purple,
|
|
from_7y_to_10y: violet,
|
|
from_5y_to_7y: indigo,
|
|
from_3y_to_5y: sky,
|
|
from_2y_to_3y: teal,
|
|
from_1y_to_2y: green,
|
|
from_6m_to_1y: lime,
|
|
from_3m_to_6m: yellow,
|
|
from_1m_to_3m: amber,
|
|
from_1w_to_1m: orange,
|
|
from_1d_to_1w: red,
|
|
from_1y: green,
|
|
from_2y: teal,
|
|
from_4y: indigo,
|
|
from_10y: violet,
|
|
from_15y: fuchsia,
|
|
coinblocksCreated: purple,
|
|
coinblocksDestroyed: red,
|
|
coinblocksStored: green,
|
|
momentum: [green, yellow, red],
|
|
momentumGreen: green,
|
|
momentumYellow: yellow,
|
|
momentumRed: red,
|
|
probability0_1p: red,
|
|
probability0_5p: orange,
|
|
probability1p: yellow,
|
|
year_2009: yellow,
|
|
year_2010: yellow,
|
|
year_2011: yellow,
|
|
year_2012: yellow,
|
|
year_2013: yellow,
|
|
year_2014: yellow,
|
|
year_2015: yellow,
|
|
year_2016: yellow,
|
|
year_2017: yellow,
|
|
year_2018: yellow,
|
|
year_2019: yellow,
|
|
year_2020: yellow,
|
|
year_2021: yellow,
|
|
year_2022: yellow,
|
|
year_2023: yellow,
|
|
year_2024: yellow,
|
|
};
|
|
}
|
|
const colors = createColors();
|
|
|
|
function initEverythingRelatedToPresets() {
|
|
/** @type {Signal<Preset>} */
|
|
const selected = createSignal(/** @type {any} */ (undefined));
|
|
const selectedLocalStorageKey = `selected-id`;
|
|
const savedSelectedId = localStorage.getItem(selectedLocalStorageKey);
|
|
const firstTime = !savedSelectedId;
|
|
|
|
/**
|
|
* @param {Scale} scale
|
|
* @returns {PartialPresetFolder}
|
|
*/
|
|
function createMarketPresets(scale) {
|
|
return {
|
|
name: "Market",
|
|
tree: [
|
|
{
|
|
scale,
|
|
icon: "💵",
|
|
name: "Price",
|
|
title: "Market Price",
|
|
description: "",
|
|
unit: "US Dollars",
|
|
},
|
|
{
|
|
scale,
|
|
icon: "♾️",
|
|
name: "Capitalization",
|
|
title: "Market Capitalization",
|
|
description: "",
|
|
unit: "US Dollars",
|
|
bottom: [
|
|
{
|
|
title: "Capitalization",
|
|
datasetPath: `${scale}-to-market-cap`,
|
|
color: colors.bitcoin,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/** @type {PartialPresetTree} */
|
|
const partialTree = [
|
|
{
|
|
name: "Dashboards",
|
|
tree: [],
|
|
},
|
|
{
|
|
name: "Charts",
|
|
tree: [
|
|
{
|
|
name: "By Date",
|
|
tree: [createMarketPresets("date")],
|
|
},
|
|
{
|
|
name: "By Height - Desktop/Tablet Only",
|
|
tree: [createMarketPresets("height")],
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
/** @type {string[]} */
|
|
const presetsIds = [];
|
|
|
|
/** @type {Preset[]} */
|
|
const presetsList = [];
|
|
|
|
/** @type {HTMLDetailsElement[]} */
|
|
const detailsList = [];
|
|
|
|
/** @typedef {'all' | 'favorites' | 'new'} FoldersFilter */
|
|
|
|
const foldersFilterLocalStorageKey = "folders-filter";
|
|
const filter = createSignal(
|
|
/** @type {FoldersFilter} */ (
|
|
localStorage.getItem(foldersFilterLocalStorageKey) || "all"
|
|
),
|
|
);
|
|
|
|
const favoritesCount = createSignal(0);
|
|
const newCount = createSignal(0);
|
|
|
|
/** @param {Preset} preset */
|
|
function createCountersEffects(preset) {
|
|
let firstFavoritesRun = true;
|
|
|
|
createEffect(() => {
|
|
if (preset.isFavorite()) {
|
|
favoritesCount.set((c) => c + 1);
|
|
} else if (!firstFavoritesRun) {
|
|
favoritesCount.set((c) => c - 1);
|
|
}
|
|
firstFavoritesRun = false;
|
|
});
|
|
|
|
let firstNewRun = true;
|
|
|
|
createEffect(() => {
|
|
if (!preset.visited()) {
|
|
newCount.set((c) => c + 1);
|
|
} else if (!firstNewRun) {
|
|
newCount.set((c) => c - 1);
|
|
}
|
|
firstNewRun = false;
|
|
});
|
|
}
|
|
|
|
/** @param {Preset} preset */
|
|
function presetToVisitedLocalStorageKey(preset) {
|
|
return `${preset.id}-visited`;
|
|
}
|
|
|
|
/**
|
|
* @param {Preset} preset
|
|
* @param {Series | SeriesBlueprint} series
|
|
*/
|
|
function presetAndSeriesToLocalStorageKey(preset, series) {
|
|
return `${preset.id}-${stringToId(series.title)}`;
|
|
}
|
|
|
|
/**
|
|
* @param {Object} args
|
|
* @param {string} args.name
|
|
* @param {string} args.inputName
|
|
* @param {string} args.inputId
|
|
* @param {string} args.inputValue
|
|
* @param {string} args.labelTitle
|
|
* @param {VoidFunction} args.onClick
|
|
*/
|
|
function createLabel({
|
|
inputId,
|
|
inputName,
|
|
inputValue,
|
|
labelTitle,
|
|
name,
|
|
onClick,
|
|
}) {
|
|
const label = window.document.createElement("label");
|
|
|
|
const input = window.document.createElement("input");
|
|
input.type = "radio";
|
|
input.name = inputName;
|
|
input.id = inputId;
|
|
input.value = inputValue;
|
|
label.append(input);
|
|
|
|
label.id = `${inputId}-label`;
|
|
// @ts-ignore
|
|
label.for = inputId;
|
|
label.title = labelTitle;
|
|
|
|
const spanMain = window.document.createElement("span");
|
|
spanMain.classList.add("main");
|
|
label.append(spanMain);
|
|
|
|
const spanName = window.document.createElement("span");
|
|
spanName.classList.add("name");
|
|
spanName.innerHTML = name;
|
|
spanMain.append(spanName);
|
|
|
|
label.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
onClick();
|
|
});
|
|
|
|
return {
|
|
label,
|
|
input,
|
|
spanMain,
|
|
spanName,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {Object} args
|
|
* @param {Preset} args.preset
|
|
* @param {string} args.frame
|
|
* @param {string} [args.name]
|
|
* @param {string} [args.top]
|
|
* @param {string} [args.id]
|
|
* @param {Owner | null} [args.owner]
|
|
*/
|
|
function createPresetLabel({ preset, frame, name, top, id, owner }) {
|
|
const { input, label, spanMain, spanName } = createLabel({
|
|
inputId: `${preset.id}-${frame}${id || ""}-selector`,
|
|
inputValue: preset.id,
|
|
inputName: `preset-${frame}${id || ""}`,
|
|
labelTitle: preset.title,
|
|
name: name || preset.name,
|
|
onClick: () => selected.set(preset),
|
|
});
|
|
|
|
if (top) {
|
|
const small = window.document.createElement("small");
|
|
small.innerHTML = top;
|
|
label.insertBefore(small, spanMain);
|
|
}
|
|
|
|
const spanEmoji = window.document.createElement("span");
|
|
spanEmoji.classList.add("emoji");
|
|
spanEmoji.innerHTML = preset.icon;
|
|
spanMain.prepend(spanEmoji);
|
|
|
|
/** @type {HTMLSpanElement | undefined} */
|
|
let spanNew;
|
|
|
|
if (!preset.visited()) {
|
|
spanNew = window.document.createElement("span");
|
|
spanNew.classList.add("new");
|
|
spanMain.append(spanNew);
|
|
}
|
|
|
|
function createFavoriteEffect() {
|
|
// @ts-ignore
|
|
createEffect((_wasFavorite) => {
|
|
const wasFavorite = /** @type {boolean} */ (_wasFavorite);
|
|
const isFavorite = preset.isFavorite();
|
|
|
|
if (!wasFavorite && isFavorite) {
|
|
const iconFavorite = window.document.createElement("svg");
|
|
spanMain.append(iconFavorite);
|
|
iconFavorite.outerHTML =
|
|
'<svg viewBox="0 0 20 20" class="favorite"><path fill-rule="evenodd" d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401Z" clip-rule="evenodd" /></svg>';
|
|
} else if (wasFavorite && !isFavorite) {
|
|
spanMain.lastElementChild?.remove();
|
|
}
|
|
|
|
return isFavorite;
|
|
}, false);
|
|
}
|
|
|
|
function createUpdateEffect() {
|
|
createEffect(() => {
|
|
if (selected()?.id === preset.id) {
|
|
input.checked = true;
|
|
spanNew?.remove();
|
|
preset.visited.set(true);
|
|
localStorage.setItem(presetToVisitedLocalStorageKey(preset), "1");
|
|
localStorage.setItem(selectedLocalStorageKey, preset.id);
|
|
} else if (input.checked) {
|
|
input.checked = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (owner !== undefined) {
|
|
runWithOwner(owner, () => {
|
|
createUpdateEffect();
|
|
createFavoriteEffect();
|
|
});
|
|
} else {
|
|
createUpdateEffect();
|
|
createFavoriteEffect();
|
|
}
|
|
|
|
return label;
|
|
}
|
|
|
|
/**
|
|
* @param {Preset} preset
|
|
*/
|
|
function presetToFavoriteLocalStorageKey(preset) {
|
|
return `${preset.id}-favorite`;
|
|
}
|
|
|
|
/**
|
|
* @param {PartialPresetTree} partialTree
|
|
* @param {HTMLElement} parent
|
|
* @param {FilePath | undefined} path
|
|
* @returns {Accessor<number>}
|
|
*/
|
|
function processPartialTree(partialTree, parent, path = undefined) {
|
|
const ul = window.document.createElement("ul");
|
|
parent.appendChild(ul);
|
|
|
|
/** @type {Accessor<number>[]} */
|
|
const listForSum = [];
|
|
|
|
partialTree.forEach((anyPartial, partialIndex) => {
|
|
const li = window.document.createElement("li");
|
|
ul.appendChild(li);
|
|
|
|
if ("tree" in anyPartial) {
|
|
const folderId = stringToId(
|
|
`${(path || [])?.map(({ name }) => name).join(" ")} ${
|
|
anyPartial.name
|
|
} folder`,
|
|
);
|
|
|
|
/** @type {Omit<PresetFolder, keyof PartialPresetFolder>} */
|
|
const restFolder = {
|
|
id: folderId,
|
|
};
|
|
|
|
Object.assign(anyPartial, restFolder);
|
|
|
|
presetsIds.push(restFolder.id);
|
|
|
|
const details = window.document.createElement("details");
|
|
const folderOpenLocalStorageKey = `${folderId}-open`;
|
|
details.open = !!localStorage.getItem(folderOpenLocalStorageKey);
|
|
detailsList.push(details);
|
|
li.appendChild(details);
|
|
|
|
const summary = window.document.createElement("summary");
|
|
summary.id = folderId;
|
|
|
|
const spanMarker = window.document.createElement("span");
|
|
spanMarker.classList.add("marker");
|
|
spanMarker.innerHTML = "●";
|
|
summary.append(spanMarker);
|
|
|
|
const spanName = window.document.createElement("span");
|
|
spanName.classList.add("name");
|
|
spanName.innerHTML = anyPartial.name;
|
|
summary.append(spanName);
|
|
|
|
const smallCount = window.document.createElement("small");
|
|
smallCount.hidden = details.open;
|
|
summary.append(smallCount);
|
|
|
|
details.appendChild(summary);
|
|
|
|
const thisPath = {
|
|
name: anyPartial.name,
|
|
id: restFolder.id,
|
|
};
|
|
|
|
function setHightlightDataAttribute() {
|
|
if (details.open) {
|
|
delete summary.dataset.highlight;
|
|
} else if (selected()?.path.includes(thisPath)) {
|
|
summary.dataset.highlight = "";
|
|
}
|
|
}
|
|
createEffect(setHightlightDataAttribute);
|
|
|
|
details.addEventListener("toggle", () => {
|
|
const open = details.open;
|
|
|
|
smallCount.hidden = open;
|
|
|
|
if (open) {
|
|
spanMarker.innerHTML = "○";
|
|
localStorage.setItem(folderOpenLocalStorageKey, "1");
|
|
} else {
|
|
spanMarker.innerHTML = "●";
|
|
localStorage.removeItem(folderOpenLocalStorageKey);
|
|
}
|
|
|
|
setHightlightDataAttribute();
|
|
});
|
|
|
|
const childPresetsCount = processPartialTree(anyPartial.tree, details, [
|
|
...(path || []),
|
|
thisPath,
|
|
]);
|
|
|
|
listForSum.push(childPresetsCount);
|
|
|
|
createEffect(() => {
|
|
const count = childPresetsCount();
|
|
smallCount.innerHTML = count.toLocaleString();
|
|
|
|
if (!count) {
|
|
li.hidden = true;
|
|
} else {
|
|
li.hidden = false;
|
|
}
|
|
});
|
|
} else {
|
|
const id = `${anyPartial.scale}-to-${stringToId(anyPartial.title)}`;
|
|
|
|
/** @type {Omit<Preset, keyof PartialPreset>} */
|
|
const restPreset = {
|
|
id,
|
|
path: path || [],
|
|
serializedPath: `/ ${[
|
|
...(path || []).map(({ name }) => name),
|
|
anyPartial.name,
|
|
].join(" / ")}`,
|
|
isFavorite: createSignal(false),
|
|
visited: createSignal(false),
|
|
};
|
|
|
|
Object.assign(anyPartial, restPreset);
|
|
|
|
const preset = /** @type {Preset} */ (anyPartial);
|
|
|
|
if (!selected() && (firstTime || savedSelectedId === preset.id)) {
|
|
selected.set(preset);
|
|
}
|
|
|
|
preset.isFavorite.set(
|
|
!!localStorage.getItem(presetToFavoriteLocalStorageKey(preset)),
|
|
);
|
|
preset.visited.set(
|
|
!!localStorage.getItem(presetToVisitedLocalStorageKey(preset)),
|
|
);
|
|
|
|
createCountersEffects(preset);
|
|
|
|
presetsList.push(preset);
|
|
presetsIds.push(preset.id);
|
|
|
|
const label = createPresetLabel({
|
|
preset,
|
|
frame: "folders",
|
|
});
|
|
li.append(label);
|
|
|
|
const inDom = createSignal(true);
|
|
function createDomEffect() {
|
|
createEffect(() => {
|
|
switch (filter()) {
|
|
case "all": {
|
|
if (!inDom()) {
|
|
insertElementAtIndex(ul, li, partialIndex);
|
|
inDom.set(true);
|
|
}
|
|
break;
|
|
}
|
|
case "favorites": {
|
|
if (preset.isFavorite()) {
|
|
if (!inDom()) {
|
|
insertElementAtIndex(ul, li, partialIndex);
|
|
inDom.set(true);
|
|
}
|
|
} else if (inDom()) {
|
|
inDom.set(false);
|
|
ul.removeChild(li);
|
|
}
|
|
break;
|
|
}
|
|
case "new": {
|
|
if (!preset.visited()) {
|
|
if (!inDom()) {
|
|
insertElementAtIndex(ul, li, partialIndex);
|
|
inDom.set(true);
|
|
}
|
|
} else if (inDom()) {
|
|
inDom.set(false);
|
|
ul.removeChild(li);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
createDomEffect();
|
|
|
|
const memo = createMemo(() => (inDom() ? 1 : 0));
|
|
listForSum.push(memo);
|
|
}
|
|
});
|
|
|
|
return createMemo(() => listForSum.reduce((acc, s) => acc + s(), 0));
|
|
}
|
|
|
|
const tree = window.document.createElement("div");
|
|
tree.classList.add("tree");
|
|
foldersFrame.append(tree);
|
|
const allCount = processPartialTree(partialTree, tree);
|
|
|
|
function checkUniqueIds() {
|
|
if (presetsIds.length !== new Set(presetsIds).size) {
|
|
/** @type {Map<string, number>} */
|
|
const m = new Map();
|
|
|
|
presetsIds.forEach((id) => {
|
|
m.set(id, (m.get(id) || 0) + 1);
|
|
});
|
|
|
|
console.log(
|
|
[...m.entries()]
|
|
.filter(([_, value]) => value > 1)
|
|
.map(([key, _]) => key),
|
|
);
|
|
|
|
throw Error("ID duplicate");
|
|
}
|
|
}
|
|
checkUniqueIds();
|
|
|
|
function createCountersDomUpdateEffect() {
|
|
foldersFilterAllCount.innerHTML = allCount().toLocaleString();
|
|
createEffect(() => {
|
|
foldersFilterFavoritesCount.innerHTML = favoritesCount().toLocaleString();
|
|
});
|
|
createEffect(() => {
|
|
foldersFilterNewCount.innerHTML = newCount().toLocaleString();
|
|
});
|
|
}
|
|
createCountersDomUpdateEffect();
|
|
|
|
function initFilters() {
|
|
const filterAllInput = /** @type {HTMLInputElement} */ (
|
|
getElementById("folders-filter-all")
|
|
);
|
|
const filterFavoritesInput = /** @type {HTMLInputElement} */ (
|
|
getElementById("folders-filter-favorites")
|
|
);
|
|
const filterNewInput = /** @type {HTMLInputElement} */ (
|
|
getElementById("folders-filter-new")
|
|
);
|
|
|
|
filterAllInput.addEventListener("change", () => {
|
|
filter.set("all");
|
|
});
|
|
filterFavoritesInput.addEventListener("change", () => {
|
|
filter.set("favorites");
|
|
});
|
|
filterNewInput.addEventListener("change", () => {
|
|
filter.set("new");
|
|
});
|
|
|
|
createEffect(() => {
|
|
const f = filter();
|
|
localStorage.setItem(foldersFilterLocalStorageKey, f);
|
|
switch (f) {
|
|
case "all": {
|
|
filterAllInput.checked = true;
|
|
break;
|
|
}
|
|
case "favorites": {
|
|
filterFavoritesInput.checked = true;
|
|
break;
|
|
}
|
|
case "new": {
|
|
filterNewInput.checked = true;
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
initFilters();
|
|
|
|
function initCloseAllButton() {
|
|
getElementById("button-close-all-folders").addEventListener("click", () => {
|
|
detailsList.forEach((details) => (details.open = false));
|
|
});
|
|
}
|
|
initCloseAllButton();
|
|
|
|
function goToSelected() {
|
|
filter.set("all");
|
|
|
|
if (!selected()) throw "Selected should be set by now";
|
|
const selectedId = selected().id;
|
|
|
|
selected().path.forEach(({ id }) => {
|
|
const summary = getElementById(id);
|
|
const details = /** @type {HTMLDetailsElement | undefined} */ (
|
|
summary.parentElement
|
|
);
|
|
if (!details) throw "Parent details should exist";
|
|
if (!details.open) {
|
|
summary.click();
|
|
}
|
|
});
|
|
|
|
setTimeout(() => {
|
|
getElementById(`${selectedId}-folders-selector`).scrollIntoView({
|
|
behavior: "instant",
|
|
block: "center",
|
|
});
|
|
}, 0);
|
|
}
|
|
|
|
if (firstTime) {
|
|
goToSelected();
|
|
}
|
|
|
|
function createUpdateSelectedHeaderEffect() {
|
|
createEffect(() => {
|
|
const preset = selected();
|
|
presetTitle.innerHTML = preset.title;
|
|
presetDescription.innerHTML = preset.serializedPath;
|
|
});
|
|
}
|
|
createUpdateSelectedHeaderEffect();
|
|
|
|
const LOCAL_STORAGE_TIME_RANGE_KEY = "chart-range";
|
|
const URL_PARAMS_TIME_RANGE_FROM_KEY = "from";
|
|
const URL_PARAMS_TIME_RANGE_TO_KEY = "to";
|
|
const HEIGHT_CHUNK_SIZE = 10_000;
|
|
|
|
runWhenIdle(() =>
|
|
import("./libraries/lightweight-charts/script.js").then(
|
|
({
|
|
createChart: createClassicChart,
|
|
createChartEx: createCustomChart,
|
|
}) => {
|
|
/**
|
|
* @param {Scale} scale
|
|
* @returns {string}
|
|
*/
|
|
function getVisibleTimeRangeLocalStorageKey(scale) {
|
|
return `${LOCAL_STORAGE_TIME_RANGE_KEY}-${scale}`;
|
|
}
|
|
|
|
/**
|
|
* @param {Scale} scale
|
|
* @returns {TimeRange}
|
|
*/
|
|
function getInitialVisibleTimeRange(scale) {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
|
|
const urlFrom = urlParams.get(URL_PARAMS_TIME_RANGE_FROM_KEY);
|
|
const urlTo = urlParams.get(URL_PARAMS_TIME_RANGE_TO_KEY);
|
|
|
|
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(
|
|
getVisibleTimeRangeLocalStorageKey(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 scale = createMemo(() => selected().scale);
|
|
const activeDatasets = createSignal(
|
|
/** @type {Set<ResourceDataset<any, any>>} */ (new Set()),
|
|
{
|
|
equals: false,
|
|
},
|
|
);
|
|
const visibleTimeRange = createSignal(
|
|
getInitialVisibleTimeRange(scale()),
|
|
);
|
|
const visibleDatasetIds = createSignal(/** @type {number[]} */ ([]), {
|
|
equals: false,
|
|
});
|
|
const lastVisibleDatasetIndex = createMemo(() => {
|
|
const last = visibleDatasetIds().at(-1);
|
|
return last !== undefined ? chunkIdToIndex(scale(), last) : undefined;
|
|
});
|
|
const priceSeriesType = 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) / HEIGHT_CHUNK_SIZE);
|
|
const to = Math.floor(Number(rawTo) / HEIGHT_CHUNK_SIZE);
|
|
|
|
const length = to - from + 1;
|
|
|
|
ids = Array.from(
|
|
{ length },
|
|
(_, i) => (from + i) * 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 = debounce(
|
|
updateVisibleDatasetIds,
|
|
100,
|
|
);
|
|
|
|
function saveVisibleRange() {
|
|
const range = visibleTimeRange();
|
|
|
|
urlParamsHelpers.write(
|
|
URL_PARAMS_TIME_RANGE_FROM_KEY,
|
|
String(range.from),
|
|
);
|
|
|
|
urlParamsHelpers.write(
|
|
URL_PARAMS_TIME_RANGE_TO_KEY,
|
|
String(range.to),
|
|
);
|
|
|
|
localStorage.setItem(
|
|
getVisibleTimeRangeLocalStorageKey(scale()),
|
|
JSON.stringify(range),
|
|
);
|
|
}
|
|
const debouncedSaveVisibleRange = debounce(saveVisibleRange, 250);
|
|
|
|
function createFetchChunksOfVisibleDatasetsEffect() {
|
|
createEffect(() => {
|
|
const ids = visibleDatasetIds();
|
|
const datasets = Array.from(activeDatasets());
|
|
|
|
if (ids.length === 0 || datasets.length === 0) return;
|
|
|
|
untrack(() => {
|
|
console.log(ids, datasets);
|
|
for (let i = 0; i < ids.length; i++) {
|
|
const id = ids[i];
|
|
for (let j = 0; j < datasets.length; j++) {
|
|
datasets[j].fetch(id);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
createFetchChunksOfVisibleDatasetsEffect();
|
|
|
|
/** @param {number} value */
|
|
function numberToShortUSLocale(value) {
|
|
const absoluteValue = Math.abs(value);
|
|
|
|
// value = absoluteValue;
|
|
|
|
if (isNaN(value)) {
|
|
return "";
|
|
// } else if (value === 0) {
|
|
// return "0";
|
|
} else if (absoluteValue < 10) {
|
|
return numberToUSLocale(value, 3);
|
|
} else if (absoluteValue < 100) {
|
|
return numberToUSLocale(value, 2);
|
|
} else if (absoluteValue < 1_000) {
|
|
return numberToUSLocale(value, 1);
|
|
} else if (absoluteValue < 100_000) {
|
|
return numberToUSLocale(value, 0);
|
|
} else if (absoluteValue < 1_000_000) {
|
|
return `${numberToUSLocale(value / 1_000, 1)}K`;
|
|
} else if (absoluteValue >= 1_000_000_000_000_000_000) {
|
|
return "Inf.";
|
|
}
|
|
|
|
const log = Math.floor(Math.log10(absoluteValue) - 6);
|
|
|
|
const suffices = ["M", "B", "T", "Q"];
|
|
const letterIndex = Math.floor(log / 3);
|
|
const letter = suffices[letterIndex];
|
|
|
|
const modulused = log % 3;
|
|
|
|
if (modulused === 0) {
|
|
return `${numberToUSLocale(
|
|
value / (1_000_000 * 1_000 ** letterIndex),
|
|
3,
|
|
)}${letter}`;
|
|
} else if (modulused === 1) {
|
|
return `${numberToUSLocale(
|
|
value / (1_000_000 * 1_000 ** letterIndex),
|
|
2,
|
|
)}${letter}`;
|
|
} else {
|
|
return `${numberToUSLocale(
|
|
value / (1_000_000 * 1_000 ** letterIndex),
|
|
1,
|
|
)}${letter}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {number} value
|
|
* @param {number} [digits]
|
|
* @param {Intl.NumberFormatOptions} [options]
|
|
*/
|
|
function numberToUSLocale(value, digits, options) {
|
|
return value.toLocaleString("en-us", {
|
|
...options,
|
|
minimumFractionDigits: digits,
|
|
maximumFractionDigits: digits,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @class
|
|
* @implements {IHorzScaleBehavior<number>}
|
|
*/
|
|
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;
|
|
}
|
|
}
|
|
|
|
/** @arg {{scale: Scale, element: HTMLElement}} args */
|
|
function createChart({ scale, element }) {
|
|
/** @satisfies {DeepPartial<ChartOptions>} */
|
|
const options = {
|
|
autoSize: true,
|
|
layout: {
|
|
fontFamily: "Satoshi Chart",
|
|
background: { color: "transparent" },
|
|
attributionLogo: false,
|
|
},
|
|
grid: {
|
|
vertLines: { visible: false },
|
|
horzLines: { visible: false },
|
|
},
|
|
timeScale: {
|
|
minBarSpacing: 0.05,
|
|
shiftVisibleRangeOnNewBar: false,
|
|
allowShiftVisibleRangeOnWhitespaceReplacement: false,
|
|
},
|
|
handleScale: {
|
|
axisDoubleClickReset: {
|
|
time: false,
|
|
},
|
|
},
|
|
crosshair: {
|
|
mode: 0,
|
|
},
|
|
localization: {
|
|
priceFormatter: numberToShortUSLocale,
|
|
locale: "en-us",
|
|
},
|
|
};
|
|
|
|
/** @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,
|
|
});
|
|
|
|
createEffect(() => {
|
|
const { white, darkWhite } = colors;
|
|
|
|
const color = white();
|
|
|
|
chart.applyOptions({
|
|
layout: {
|
|
textColor: darkWhite(),
|
|
},
|
|
rightPriceScale: {
|
|
borderVisible: false,
|
|
},
|
|
timeScale: {
|
|
borderVisible: false,
|
|
},
|
|
crosshair: {
|
|
horzLine: {
|
|
color: color,
|
|
labelBackgroundColor: color,
|
|
},
|
|
vertLine: {
|
|
color: color,
|
|
labelBackgroundColor: color,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
return chart;
|
|
}
|
|
|
|
function resetChartListElement() {
|
|
while (
|
|
chartListElement.lastElementChild?.classList.contains(
|
|
"chart-wrapper",
|
|
)
|
|
) {
|
|
chartListElement.lastElementChild?.remove();
|
|
}
|
|
}
|
|
|
|
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");
|
|
const whitespaceDateDataset =
|
|
/** @type {(WhitespaceData | SingleValueData)[]} */ (
|
|
new Array(
|
|
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 = dateToString(date);
|
|
|
|
if (i === whitespaceDateDataset.length - 1) {
|
|
whitespaceDateDataset[i] = {
|
|
time,
|
|
value: NaN,
|
|
};
|
|
} else {
|
|
whitespaceDateDataset[i] = {
|
|
time,
|
|
};
|
|
}
|
|
}
|
|
|
|
const heightStart = -50_000;
|
|
const whitespaceHeightDataset = /** @type {WhitespaceData[]} */ (
|
|
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 {Scale} scale
|
|
* @returns {ISeriesApi<'Line'>}
|
|
*/
|
|
function setWhitespace(chart, scale) {
|
|
const whitespace = chart.addLineSeries();
|
|
|
|
if (scale === "date") {
|
|
whitespace.setData(whitespaceDateDataset);
|
|
} else {
|
|
whitespace.setData(whitespaceHeightDataset);
|
|
|
|
const time = whitespaceHeightDataset.length;
|
|
whitespace.update({
|
|
time: /** @type {Time} */ (time),
|
|
value: NaN,
|
|
});
|
|
}
|
|
|
|
return whitespace;
|
|
}
|
|
|
|
return { setWhitespace };
|
|
}
|
|
const { setWhitespace } = initWhitespace();
|
|
|
|
/**
|
|
* @param {HTMLElement} parent
|
|
* @param {number} chartIndex
|
|
* @param {Preset} preset
|
|
*/
|
|
function createChartDiv(parent, chartIndex, preset) {
|
|
const chartWrapper = window.document.createElement("div");
|
|
chartWrapper.classList.add("chart-wrapper");
|
|
parent.append(chartWrapper);
|
|
|
|
const chartDiv = window.document.createElement("div");
|
|
chartDiv.classList.add("chart-div");
|
|
chartWrapper.append(chartDiv);
|
|
|
|
const unitField = window.document.createElement("fieldset");
|
|
const unitName = window.document.createElement("span");
|
|
unitName.innerHTML = chartIndex
|
|
? preset.unit
|
|
: /** @satisfies {Unit} */ ("US Dollars");
|
|
unitField.append(unitName);
|
|
const unitDash = window.document.createElement("span");
|
|
unitDash.innerHTML = "—";
|
|
|
|
chartWrapper.append(chartDiv);
|
|
|
|
return chartDiv;
|
|
}
|
|
|
|
/**
|
|
* @param {IChartApi} chart
|
|
*/
|
|
function subscribeVisibleTimeRangeChange(chart) {
|
|
chart.timeScale().subscribeVisibleTimeRangeChange((range) => {
|
|
if (!range) return;
|
|
|
|
visibleTimeRange.set(range);
|
|
|
|
debouncedUpdateVisibleDatasetIds();
|
|
|
|
debouncedSaveVisibleRange();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {{ chart: IChartApi; visibleLogicalRange?: LogicalRange; visibleTimeRange?: TimeRange }} args
|
|
*/
|
|
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 =
|
|
getNumberOfDaysBetweenTwoDates(
|
|
dateFromTime(from),
|
|
dateFromTime(to),
|
|
) / width;
|
|
} else {
|
|
const to = /** @type {number} */ (visibleTimeRange.to);
|
|
const from = /** @type {number} */ (visibleTimeRange.from);
|
|
|
|
ratio = (to - from) / width;
|
|
}
|
|
} else {
|
|
throw Error();
|
|
}
|
|
|
|
if (ratio <= 0.5) {
|
|
priceSeriesType.set("Candlestick");
|
|
} else {
|
|
priceSeriesType.set("Line");
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
/** @param {Time} time */
|
|
function dateFromTime(time) {
|
|
return typeof time === "string"
|
|
? new Date(time)
|
|
: // @ts-ignore
|
|
new Date(time.year, time.month, time.day);
|
|
}
|
|
|
|
const debouncedUpdateVisiblePriceSeriesType = debounce(
|
|
updateVisiblePriceSeriesType,
|
|
50,
|
|
);
|
|
|
|
/**
|
|
* @param {Scale} scale
|
|
* @param {number} id
|
|
*/
|
|
function chunkIdToIndex(scale, id) {
|
|
return scale === "date"
|
|
? id - 2009
|
|
: Math.floor(id / HEIGHT_CHUNK_SIZE);
|
|
}
|
|
|
|
function createDatasets() {
|
|
/** @type {Map<DatePath, ResourceDataset<"date">>} */
|
|
const date = new Map();
|
|
/** @type {Map<HeightPath, ResourceDataset<"height">>} */
|
|
const height = new Map();
|
|
|
|
const USE_LOCAL_URL = true;
|
|
const LOCAL_URL = "http://localhost:3110";
|
|
const WEB_URL = "https://api.satonomics.xyz";
|
|
const BACKUP_WEB_URL = "https://api-bkp.satonomics.xyz";
|
|
|
|
const datasetsOwner = getOwner();
|
|
|
|
/**
|
|
* @template {Scale} S
|
|
* @template {number | OHLC} [T=number]
|
|
* @param {S} scale
|
|
* @param {string} path
|
|
*/
|
|
function createResourceDataset(scale, path) {
|
|
return /** @type {ResourceDataset<S, T>} */ (
|
|
runWithOwner(datasetsOwner, () => {
|
|
/** @typedef {DatasetValue<T extends number ? SingleValueData : CandlestickData>} Value */
|
|
|
|
const baseURL = `${
|
|
USE_LOCAL_URL && location.hostname === "localhost"
|
|
? LOCAL_URL
|
|
: WEB_URL
|
|
}/${path}`;
|
|
|
|
const backupURL = `${
|
|
USE_LOCAL_URL && location.hostname === "localhost"
|
|
? LOCAL_URL
|
|
: BACKUP_WEB_URL
|
|
}/${path}`;
|
|
|
|
const fetchedJSONs = new Array(
|
|
(new Date().getFullYear() -
|
|
new Date("2009-01-01").getFullYear() +
|
|
2) *
|
|
(scale === "date" ? 1 : 6),
|
|
)
|
|
.fill(null)
|
|
.map(() => {
|
|
const json = createSignal(
|
|
/** @type {FetchedJSON<S, T> | null} */ (null),
|
|
);
|
|
|
|
/** @type {FetchedResult<S, T>} */
|
|
const fetchedResult = {
|
|
at: null,
|
|
json,
|
|
loading: false,
|
|
vec: createMemo(() => {
|
|
const map = json()?.dataset.map;
|
|
|
|
if (!map) {
|
|
return null;
|
|
}
|
|
|
|
const chunkId = json()?.chunk.id;
|
|
|
|
if (chunkId === undefined) {
|
|
throw `ChunkId ${chunkId} is undefined`;
|
|
}
|
|
|
|
if (Array.isArray(map)) {
|
|
const values = new Array(map.length);
|
|
|
|
for (let i = 0; i < map.length; i++) {
|
|
const value = map[i];
|
|
|
|
values[i] = /** @type {Value} */ ({
|
|
time: /** @type {Time} */ (chunkId + i),
|
|
...(typeof value !== "number" && value !== null
|
|
? {
|
|
.../** @type {OHLC} */ (value),
|
|
value: value.close,
|
|
}
|
|
: {
|
|
value:
|
|
value === null
|
|
? NaN
|
|
: /** @type {number} */ (value),
|
|
}),
|
|
});
|
|
}
|
|
|
|
return values;
|
|
} else {
|
|
return Object.entries(map).map(
|
|
([date, value]) =>
|
|
/** @type {Value} */ ({
|
|
time: date,
|
|
...(typeof value !== "number" && value !== null
|
|
? {
|
|
.../** @type {OHLC} */ (value),
|
|
value: value.close,
|
|
}
|
|
: {
|
|
value:
|
|
value === null
|
|
? NaN
|
|
: /** @type {number} */ (value),
|
|
}),
|
|
}),
|
|
);
|
|
}
|
|
}),
|
|
};
|
|
|
|
return fetchedResult;
|
|
});
|
|
|
|
/**
|
|
* @param {number} id
|
|
*/
|
|
async function _fetch(id) {
|
|
const index = chunkIdToIndex(scale, id);
|
|
|
|
if (
|
|
index < 0 ||
|
|
(scale === "date" && id > new Date().getUTCFullYear()) ||
|
|
(scale === "height" &&
|
|
id > 165 * 365 * (new Date().getUTCFullYear() - 2009))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const fetched = fetchedJSONs.at(index);
|
|
|
|
if (scale === "height" && index > 0) {
|
|
const length = fetchedJSONs.at(index - 1)?.vec()?.length;
|
|
|
|
if (length !== undefined && length < HEIGHT_CHUNK_SIZE) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!fetched || fetched.loading) {
|
|
return;
|
|
} else if (fetched.at) {
|
|
const diff = new Date().getTime() - fetched.at.getTime();
|
|
|
|
if (
|
|
diff < ONE_MINUTE_IN_MS ||
|
|
(index < fetchedJSONs.findLastIndex((json) => json.at) &&
|
|
diff < ONE_HOUR_IN_MS)
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
fetched.loading = true;
|
|
|
|
/** @type {Cache | undefined} */
|
|
let cache;
|
|
|
|
const urlWithQuery = `${baseURL}?chunk=${id}`;
|
|
const backupUrlWithQuery = `${backupURL}?chunk=${id}`;
|
|
|
|
if (!fetched.json()) {
|
|
try {
|
|
cache = await caches.open("resources");
|
|
|
|
const cachedResponse = await cache.match(urlWithQuery);
|
|
|
|
if (cachedResponse) {
|
|
/** @type {FetchedJSON<S, T> | null} */
|
|
const json =
|
|
await convertResponseToJSON(cachedResponse);
|
|
|
|
if (json) {
|
|
console.log(`cache: ${path}?chunk=${id}`);
|
|
|
|
fetched.json.set(() => json);
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if (!navigator.onLine) {
|
|
fetched.loading = false;
|
|
return;
|
|
}
|
|
|
|
/** @type {Response | undefined} */
|
|
let fetchedResponse;
|
|
|
|
/** @type {RequestInit} */
|
|
const fetchConfig = {
|
|
signal: AbortSignal.timeout(5000),
|
|
};
|
|
|
|
try {
|
|
fetchedResponse = await fetch(urlWithQuery, fetchConfig);
|
|
|
|
if (!fetchedResponse.ok) {
|
|
throw Error;
|
|
}
|
|
} catch {
|
|
try {
|
|
fetchedResponse = await fetch(
|
|
backupUrlWithQuery,
|
|
fetchConfig,
|
|
);
|
|
} catch {
|
|
fetched.loading = false;
|
|
return;
|
|
}
|
|
|
|
if (!fetchedResponse || !fetchedResponse.ok) {
|
|
fetched.loading = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
const clonedResponse = fetchedResponse.clone();
|
|
|
|
/** @type {FetchedJSON<S, T> | null} */
|
|
const json = await convertResponseToJSON(fetchedResponse);
|
|
|
|
if (!json) {
|
|
fetched.loading = false;
|
|
return;
|
|
}
|
|
|
|
console.log(`fetch: ${path}?chunk=${id}`);
|
|
|
|
const previousMap = fetched.json()?.dataset;
|
|
const newMap = json.dataset.map;
|
|
|
|
const previousLength = Object.keys(previousMap || []).length;
|
|
const newLength = Object.keys(newMap).length;
|
|
|
|
if (!newLength) {
|
|
fetched.loading = false;
|
|
return;
|
|
}
|
|
|
|
if (previousLength && previousLength === newLength) {
|
|
const previousLastValue = Object.values(
|
|
previousMap || [],
|
|
).at(-1);
|
|
const newLastValue = Object.values(newMap).at(-1);
|
|
|
|
if (newLastValue === null && previousLastValue === null) {
|
|
fetched.at = new Date();
|
|
fetched.loading = false;
|
|
return;
|
|
} else if (typeof newLastValue === "number") {
|
|
if (previousLastValue === newLastValue) {
|
|
fetched.at = new Date();
|
|
fetched.loading = false;
|
|
return;
|
|
}
|
|
} else {
|
|
const previousLastOHLC = /** @type {OHLC} */ (
|
|
previousLastValue
|
|
);
|
|
const newLastOHLC = /** @type {OHLC} */ (newLastValue);
|
|
|
|
if (
|
|
previousLastOHLC.open === newLastOHLC.open &&
|
|
previousLastOHLC.high === newLastOHLC.high &&
|
|
previousLastOHLC.low === newLastOHLC.low &&
|
|
previousLastOHLC.close === newLastOHLC.close
|
|
) {
|
|
fetched.loading = false;
|
|
fetched.at = new Date();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
fetched.json.set(() => json);
|
|
|
|
runWhenIdle(async function () {
|
|
try {
|
|
await cache?.put(urlWithQuery, clonedResponse);
|
|
} catch (_) {}
|
|
});
|
|
|
|
fetched.at = new Date();
|
|
fetched.loading = false;
|
|
}
|
|
|
|
/** @type {ResourceDataset<S, T>} */
|
|
const resource = {
|
|
scale,
|
|
url: baseURL,
|
|
fetch: _fetch,
|
|
fetchedJSONs,
|
|
// drop() {
|
|
// dispose();
|
|
// fetchedJSONs.forEach((fetched) => {
|
|
// fetched.at = null;
|
|
// fetched.json.set(null);
|
|
// });
|
|
// },
|
|
};
|
|
|
|
return resource;
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @template {Scale} S
|
|
* @template {number | OHLC} T
|
|
* @param {Response} response
|
|
*/
|
|
async function convertResponseToJSON(response) {
|
|
try {
|
|
return /** @type {FetchedJSON<S, T>} */ (await response.json());
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @template {Scale} S
|
|
* @param {S} scale
|
|
* @param {DatasetPath<S>} path
|
|
* @returns {ResourceDataset<S>}
|
|
*/
|
|
function getOrImport(scale, path) {
|
|
if (scale === "date") {
|
|
const found = date.get(/** @type {DatePath} */ (path));
|
|
if (found) return /** @type {ResourceDataset<S>} */ (found);
|
|
} else {
|
|
const found = height.get(/** @type {HeightPath} */ (path));
|
|
if (found) return /** @type {ResourceDataset<S>} */ (found);
|
|
}
|
|
|
|
/** @type {ResourceDataset<S, any>} */
|
|
let dataset;
|
|
|
|
if (path === `/${scale}-to-price`) {
|
|
/** @type {ResourceDataset<S, OHLC>} */
|
|
dataset = createResourceDataset(scale, path);
|
|
} else {
|
|
/** @type {ResourceDataset<S, number>} */
|
|
dataset = createResourceDataset(scale, path);
|
|
}
|
|
|
|
if (scale === "date") {
|
|
date.set(
|
|
/** @type {DatePath} */ (path),
|
|
/** @type {any} */ (dataset),
|
|
);
|
|
} else {
|
|
height.set(
|
|
/** @type {HeightPath} */ (path),
|
|
/** @type {any} */ (dataset),
|
|
);
|
|
}
|
|
|
|
return dataset;
|
|
}
|
|
|
|
return {
|
|
getOrImport,
|
|
};
|
|
}
|
|
|
|
const datasets = createDatasets();
|
|
|
|
/** @type {DeepPartial<SeriesOptionsCommon>} */
|
|
const defaultSeriesOptions = {
|
|
// @ts-ignore
|
|
lineWidth: 1.5,
|
|
priceLineVisible: false,
|
|
baseLineVisible: false,
|
|
baseLineColor: "",
|
|
};
|
|
|
|
/**
|
|
* @param {SpecificSeriesBlueprintWithChart<BaselineSpecificSeriesBlueprint>} args
|
|
*/
|
|
function createBaseLineSeries({ chart, color, options }) {
|
|
const topLineColor = color || colors.profit;
|
|
const bottomLineColor = color || colors.loss;
|
|
|
|
function computeColors() {
|
|
return {
|
|
topLineColor: topLineColor(),
|
|
bottomLineColor: bottomLineColor(),
|
|
};
|
|
}
|
|
|
|
const transparent = "transparent";
|
|
|
|
/** @type {DeepPartial<BaselineStyleOptions & SeriesOptionsCommon>} */
|
|
const seriesOptions = {
|
|
priceScaleId: "right",
|
|
...defaultSeriesOptions,
|
|
...options,
|
|
topFillColor1: transparent,
|
|
topFillColor2: transparent,
|
|
bottomFillColor1: transparent,
|
|
bottomFillColor2: transparent,
|
|
...computeColors(),
|
|
};
|
|
|
|
const series = chart.addBaselineSeries(seriesOptions);
|
|
|
|
createEffect(() => {
|
|
series.applyOptions(computeColors());
|
|
});
|
|
|
|
return series;
|
|
}
|
|
|
|
/**
|
|
* @param {SpecificSeriesBlueprintWithChart<CandlestickSpecificSeriesBlueprint>} args
|
|
*/
|
|
function createCandlesticksSeries({ chart, options }) {
|
|
function computeColors() {
|
|
const upColor = colors.profit();
|
|
const downColor = colors.loss();
|
|
|
|
return {
|
|
upColor,
|
|
wickUpColor: upColor,
|
|
downColor,
|
|
wickDownColor: downColor,
|
|
};
|
|
}
|
|
|
|
const candlestickSeries = chart.addCandlestickSeries({
|
|
baseLineVisible: false,
|
|
borderVisible: false,
|
|
priceLineVisible: false,
|
|
baseLineColor: "",
|
|
borderColor: "",
|
|
borderDownColor: "",
|
|
borderUpColor: "",
|
|
...options,
|
|
...computeColors(),
|
|
});
|
|
|
|
createEffect(() => {
|
|
candlestickSeries.applyOptions(computeColors());
|
|
});
|
|
|
|
return candlestickSeries;
|
|
}
|
|
|
|
/**
|
|
* @param {SpecificSeriesBlueprintWithChart<LineSpecificSeriesBlueprint>} args
|
|
*/
|
|
function createLineSeries({ chart, color, options }) {
|
|
function computeColors() {
|
|
return {
|
|
color: color(),
|
|
};
|
|
}
|
|
|
|
const series = chart.addLineSeries({
|
|
...defaultSeriesOptions,
|
|
...options,
|
|
...computeColors(),
|
|
});
|
|
|
|
createEffect(() => {
|
|
series.applyOptions(computeColors());
|
|
});
|
|
|
|
return series;
|
|
}
|
|
|
|
const hoveredLegend = createSignal(
|
|
/** @type {{label: HTMLLabelElement, series: Series} | undefined} */ (
|
|
undefined
|
|
),
|
|
);
|
|
const notHoveredLegendTransparency = "66";
|
|
/**
|
|
* @param {Object} args
|
|
* @param {Series} args.series
|
|
* @param {Accessor<boolean>} [args.disabled]
|
|
* @param {string} [args.name]
|
|
*/
|
|
function createLegend({ series, disabled, name }) {
|
|
const div = window.document.createElement("div");
|
|
createEffect(() => {
|
|
div.hidden = disabled?.() ? true : false;
|
|
});
|
|
legendElement.prepend(div);
|
|
|
|
const { input, label, spanMain } = createLabel({
|
|
inputId: `legend-${series.title}`,
|
|
inputName: `selected-${series.title}${name}`,
|
|
inputValue: "value",
|
|
labelTitle: "Click to toggle",
|
|
name: series.title,
|
|
onClick: () => {
|
|
input.checked = !input.checked;
|
|
series.active.set(input.checked);
|
|
},
|
|
});
|
|
div.append(label);
|
|
label.addEventListener("mouseover", () => {
|
|
const hovered = hoveredLegend();
|
|
|
|
if (!hovered || hovered.label !== label) {
|
|
hoveredLegend.set({ label, series });
|
|
}
|
|
});
|
|
label.addEventListener("mouseleave", () => {
|
|
hoveredLegend.set(undefined);
|
|
});
|
|
|
|
createEffect(() => {
|
|
input.checked = series.active();
|
|
});
|
|
|
|
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);
|
|
|
|
createEffect(() => {
|
|
const c = color();
|
|
if (shouldHighlight()) {
|
|
spanColor.style.backgroundColor = c;
|
|
} else {
|
|
spanColor.style.backgroundColor = `${c}${notHoveredLegendTransparency}`;
|
|
}
|
|
});
|
|
});
|
|
|
|
function createHoverEffect() {
|
|
const initialColors = /** @type {Record<string, any>} */ ({});
|
|
const darkenedColors = /** @type {Record<string, any>} */ ({});
|
|
|
|
// @ts-ignore
|
|
createEffect((previouslyHovered) => {
|
|
const hovered = hoveredLegend();
|
|
|
|
if (!hovered && !previouslyHovered) return hovered;
|
|
|
|
const ids = visibleDatasetIds();
|
|
|
|
for (let i = 0; i < ids.length; i++) {
|
|
const chunkId = ids[i];
|
|
const chunkIndex = chunkIdToIndex(scale(), chunkId);
|
|
const chunk = series.chunks[chunkIndex]?.();
|
|
|
|
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;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (shouldHighlight()) {
|
|
chunk.applyOptions(initialColors[i]);
|
|
} else {
|
|
chunk.applyOptions(darkenedColors[i]);
|
|
}
|
|
}
|
|
|
|
return hovered;
|
|
}, undefined);
|
|
}
|
|
createHoverEffect();
|
|
|
|
const anchor = window.document.createElement("a");
|
|
anchor.href = series.dataset.url;
|
|
anchor.innerHTML = `<svg viewBox="0 0 16 16"><path d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z" /><path d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z" /></svg>`;
|
|
anchor.target = "_target";
|
|
anchor.rel = "noopener noreferrer";
|
|
div.append(anchor);
|
|
}
|
|
|
|
/**
|
|
* @template {Scale} S
|
|
* @param {Object} args
|
|
* @param {ResourceDataset<S>} args.dataset
|
|
* @param {SeriesBlueprint} args.seriesBlueprint
|
|
* @param {Preset} args.preset
|
|
* @param {IChartApi} args.chart
|
|
* @param {number} args.index
|
|
* @param {Series[]} args.chartSeries
|
|
* @param {Accessor<number | undefined>} args.lastVisibleDatasetIndex
|
|
* @param {VoidFunction} args.setMinMaxMarkersWhenIdle
|
|
* @param {Accessor<boolean>} [args.disabled]
|
|
*/
|
|
function createSeries({
|
|
chart,
|
|
preset,
|
|
index: seriesIndex,
|
|
disabled: _disabled,
|
|
lastVisibleDatasetIndex,
|
|
setMinMaxMarkersWhenIdle,
|
|
dataset,
|
|
seriesBlueprint,
|
|
chartSeries,
|
|
}) {
|
|
const { title, color, defaultActive, type, options } =
|
|
seriesBlueprint;
|
|
|
|
/** @type {Signal<ISeriesApi<SeriesType> | undefined>[]} */
|
|
const chunks = new Array(dataset.fetchedJSONs.length);
|
|
|
|
const id = stringToId(title);
|
|
const storageId = presetAndSeriesToLocalStorageKey(
|
|
preset,
|
|
seriesBlueprint,
|
|
);
|
|
|
|
const active = createSignal(
|
|
urlParamsHelpers.readBool(id) ??
|
|
localeStorageHelpers.readBool(storageId) ??
|
|
defaultActive ??
|
|
true,
|
|
);
|
|
|
|
const disabled = createMemo(_disabled || (() => false));
|
|
|
|
const visible = createMemo(() => active() && !disabled());
|
|
|
|
createEffect(() => {
|
|
if (disabled()) {
|
|
return;
|
|
}
|
|
|
|
const a = active();
|
|
|
|
if (a !== (defaultActive || true)) {
|
|
urlParamsHelpers.write(id, a);
|
|
localeStorageHelpers.write(storageId, a);
|
|
} else {
|
|
urlParamsHelpers.remove(id);
|
|
localeStorageHelpers.remove(storageId);
|
|
}
|
|
});
|
|
|
|
/** @type {Series} */
|
|
const series = {
|
|
active,
|
|
chunks,
|
|
color: color || [colors.profit, colors.loss],
|
|
dataset,
|
|
disabled,
|
|
id,
|
|
title,
|
|
visible,
|
|
};
|
|
|
|
chartSeries.push(series);
|
|
|
|
dataset.fetchedJSONs.forEach((json, index) => {
|
|
const chunk = createSignal(
|
|
/** @type {ISeriesApi<SeriesType> | undefined} */ (undefined),
|
|
);
|
|
|
|
chunks[index] = chunk;
|
|
|
|
createEffect(() => {
|
|
const values = json.vec();
|
|
|
|
if (!values) return;
|
|
|
|
if (seriesIndex > 0) {
|
|
const previousSeriesChunk = chartSeries.at(seriesIndex - 1)
|
|
?.chunks[index];
|
|
const isPreviousSeriesOnChart = previousSeriesChunk?.();
|
|
if (!isPreviousSeriesOnChart) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
untrack(() => {
|
|
let s = chunk();
|
|
|
|
if (!s) {
|
|
switch (type) {
|
|
case "Baseline": {
|
|
s = createBaseLineSeries({
|
|
chart,
|
|
color,
|
|
options,
|
|
});
|
|
break;
|
|
}
|
|
case "Candlestick": {
|
|
s = createCandlesticksSeries({
|
|
chart,
|
|
options,
|
|
});
|
|
break;
|
|
}
|
|
// case "Histogram": {
|
|
// s = createHistogramSeries({
|
|
// chart,
|
|
// options,
|
|
// });
|
|
// break;
|
|
// }
|
|
default:
|
|
case "Line": {
|
|
s = createLineSeries({
|
|
chart,
|
|
color,
|
|
options,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
|
|
// if (priceScaleOptions) {
|
|
// s.priceScale().applyOptions(priceScaleOptions);
|
|
// }
|
|
|
|
chunk.set(s);
|
|
}
|
|
|
|
s.setData(values);
|
|
|
|
setMinMaxMarkersWhenIdle();
|
|
});
|
|
});
|
|
|
|
createEffect(() => {
|
|
const _chunk = chunk();
|
|
const currentVec = dataset.fetchedJSONs.at(index)?.vec();
|
|
const nextVec = dataset.fetchedJSONs.at(index + 1)?.vec();
|
|
|
|
if (_chunk && currentVec?.length && nextVec?.length) {
|
|
_chunk.update(nextVec[0]);
|
|
}
|
|
});
|
|
|
|
const isChunkLastVisible = createMemo(() => {
|
|
const last = lastVisibleDatasetIndex();
|
|
return last !== undefined && last === index;
|
|
});
|
|
|
|
createEffect(() => {
|
|
chunk()?.applyOptions({
|
|
lastValueVisible: series.visible() && isChunkLastVisible(),
|
|
});
|
|
});
|
|
|
|
const shouldChunkBeVisible = createMemo(() => {
|
|
if (visibleDatasetIds().length) {
|
|
const start = chunkIdToIndex(
|
|
scale(),
|
|
/** @type {number} */ (visibleDatasetIds().at(0)),
|
|
);
|
|
const end = chunkIdToIndex(
|
|
scale(),
|
|
/** @type {number} */ (visibleDatasetIds().at(-1)),
|
|
);
|
|
|
|
if (index >= start && index <= end) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
let wasChunkVisible = false;
|
|
const chunkVisible = createMemo(() => {
|
|
if (series.disabled()) {
|
|
wasChunkVisible = false;
|
|
} else {
|
|
wasChunkVisible = wasChunkVisible || shouldChunkBeVisible();
|
|
}
|
|
return wasChunkVisible;
|
|
});
|
|
|
|
createEffect(() => {
|
|
const visible = series.visible() && chunkVisible();
|
|
chunk()?.applyOptions({
|
|
visible,
|
|
});
|
|
});
|
|
});
|
|
|
|
createLegend({ series, disabled, name: type });
|
|
|
|
return series;
|
|
}
|
|
|
|
/**
|
|
* @param {Object} args
|
|
* @param {PriceSeriesType} args.type
|
|
* @param {VoidFunction} args.setMinMaxMarkersWhenIdle
|
|
* @param {Preset} args.preset
|
|
* @param {IChartApi} args.chart
|
|
* @param {Series[]} args.chartSeries
|
|
* @param {Accessor<number | undefined>} args.lastVisibleDatasetIndex
|
|
*/
|
|
function createPriceSeries({
|
|
type,
|
|
setMinMaxMarkersWhenIdle,
|
|
preset,
|
|
chart,
|
|
chartSeries,
|
|
lastVisibleDatasetIndex,
|
|
}) {
|
|
const s = scale();
|
|
|
|
/** @type {AnyDatasetPath} */
|
|
const datasetPath = `${s}-to-price`;
|
|
|
|
const dataset = datasets.getOrImport(s, datasetPath);
|
|
|
|
// Don't trigger reactivity by design
|
|
activeDatasets().add(dataset);
|
|
|
|
const title = "Price";
|
|
|
|
/** @type {SeriesBlueprint} */
|
|
let seriesBlueprint;
|
|
|
|
if (type === "Candlestick") {
|
|
seriesBlueprint = {
|
|
datasetPath,
|
|
title,
|
|
type: "Candlestick",
|
|
};
|
|
} else {
|
|
seriesBlueprint = {
|
|
datasetPath,
|
|
title,
|
|
color: colors.white,
|
|
};
|
|
}
|
|
|
|
const disabled = createMemo(() => priceSeriesType() !== type);
|
|
|
|
const priceSeries = createSeries({
|
|
seriesBlueprint,
|
|
dataset,
|
|
preset,
|
|
index: -1,
|
|
chart,
|
|
chartSeries,
|
|
lastVisibleDatasetIndex,
|
|
disabled,
|
|
setMinMaxMarkersWhenIdle,
|
|
});
|
|
|
|
// createEffect(() => {
|
|
// const latest = webSockets.liveKrakenCandle.latest();
|
|
|
|
// if (!latest) return;
|
|
|
|
// const index = chunkIdToIndex(s, latest.year);
|
|
|
|
// const series = priceSeries.seriesList.at(index)?.();
|
|
|
|
// series?.update(latest);
|
|
// });
|
|
|
|
return priceSeries;
|
|
}
|
|
|
|
function resetLegendElement() {
|
|
legendElement.innerHTML = "";
|
|
}
|
|
|
|
/** @type {Array<(IChartApi & {whitespace: ISeriesApi<"Line">})>} */
|
|
let charts = [];
|
|
|
|
function applyPreset() {
|
|
const preset = selected();
|
|
const scale = preset.scale;
|
|
visibleTimeRange.set(getInitialVisibleTimeRange(scale));
|
|
|
|
activeDatasets.set((s) => {
|
|
s.clear();
|
|
return s;
|
|
});
|
|
|
|
const chartCount = 1 + (preset.bottom?.length ? 1 : 0);
|
|
const blueprintCount =
|
|
1 + (preset.top?.length || 0) + (preset.bottom?.length || 0);
|
|
const chartsBlueprints = [preset.top || [], preset.bottom].flatMap(
|
|
(list) => (list ? [list] : []),
|
|
);
|
|
|
|
resetLegendElement();
|
|
resetChartListElement();
|
|
|
|
/** @type {Series[]} */
|
|
const allSeries = [];
|
|
|
|
charts = chartsBlueprints.map((seriesBlueprints, chartIndex) => {
|
|
const chartDiv = createChartDiv(
|
|
chartListElement,
|
|
chartIndex,
|
|
preset,
|
|
);
|
|
|
|
const chart =
|
|
/** @type {IChartApi & {whitespace: ISeriesApi<"Line">}} */ (
|
|
createChart({
|
|
scale,
|
|
element: chartDiv,
|
|
})
|
|
);
|
|
chart.whitespace = setWhitespace(chart, scale);
|
|
|
|
setInitialVisibleTimeRange(chart);
|
|
|
|
/** @type {Series[]} */
|
|
const chartSeries = [];
|
|
|
|
function setMinMaxMarkers() {
|
|
try {
|
|
const { from, to } = visibleTimeRange();
|
|
|
|
const dateFrom = new Date(String(from));
|
|
const dateTo = new Date(String(to));
|
|
|
|
/** @type {Marker | undefined} */
|
|
let max = undefined;
|
|
/** @type {Marker | undefined} */
|
|
let min = undefined;
|
|
|
|
const ids = visibleDatasetIds();
|
|
|
|
for (let i = 0; i < chartSeries.length; i++) {
|
|
const { chunks, dataset } = chartSeries[i];
|
|
|
|
for (let j = 0; j < ids.length; j++) {
|
|
const id = ids[j];
|
|
|
|
const chunkIndex = chunkIdToIndex(scale, id);
|
|
|
|
const chunk = chunks.at(chunkIndex)?.();
|
|
|
|
if (!chunk || !chunk?.options().visible) continue;
|
|
|
|
chunk.setMarkers([]);
|
|
|
|
const isCandlestick = chunk.seriesType() === "Candlestick";
|
|
|
|
const vec = dataset.fetchedJSONs.at(chunkIndex)?.vec();
|
|
|
|
if (!vec) return;
|
|
|
|
for (let k = 0; k < vec.length; k++) {
|
|
const data = vec[k];
|
|
|
|
let number;
|
|
|
|
if (scale === "date") {
|
|
const date = dateFromTime(data.time);
|
|
|
|
number = date.getTime();
|
|
|
|
if (date <= dateFrom || date >= dateTo) {
|
|
continue;
|
|
}
|
|
} else {
|
|
const height = data.time;
|
|
|
|
number = /** @type {number} */ (height);
|
|
|
|
if (height <= from || height >= to) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// @ts-ignore
|
|
const high = isCandlestick ? data["high"] : data.value;
|
|
// @ts-ignore
|
|
const low = isCandlestick ? data["low"] : data.value;
|
|
|
|
if (!max || high > max.value) {
|
|
max = {
|
|
weight: number,
|
|
time: data.time,
|
|
value: high,
|
|
seriesChunk: chunk,
|
|
};
|
|
}
|
|
if (!min || low < min.value) {
|
|
min = {
|
|
weight: number,
|
|
time: data.time,
|
|
value: low,
|
|
seriesChunk: chunk,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @type {(SeriesMarker<Time> & Weighted) | undefined} */
|
|
let minMarker;
|
|
/** @type {(SeriesMarker<Time> & Weighted) | undefined} */
|
|
let maxMarker;
|
|
|
|
if (min) {
|
|
minMarker = {
|
|
weight: min.weight,
|
|
time: min.time,
|
|
color: colors.white(),
|
|
position: "belowBar",
|
|
shape: "arrowUp",
|
|
size: 0,
|
|
text: numberToShortUSLocale(min.value),
|
|
};
|
|
}
|
|
|
|
if (max) {
|
|
maxMarker = {
|
|
weight: max.weight,
|
|
time: max.time,
|
|
color: colors.white(),
|
|
position: "aboveBar",
|
|
shape: "arrowDown",
|
|
size: 0,
|
|
text: numberToShortUSLocale(max.value),
|
|
};
|
|
}
|
|
|
|
if (
|
|
min &&
|
|
max &&
|
|
min.seriesChunk === max.seriesChunk &&
|
|
minMarker &&
|
|
maxMarker
|
|
) {
|
|
min.seriesChunk.setMarkers(
|
|
[minMarker, maxMarker].sort((a, b) => a.weight - b.weight),
|
|
);
|
|
} else {
|
|
if (min && minMarker) {
|
|
min.seriesChunk.setMarkers([minMarker]);
|
|
}
|
|
|
|
if (max && maxMarker) {
|
|
max.seriesChunk.setMarkers([maxMarker]);
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
const setMinMaxMarkersWhenIdle = () =>
|
|
runWhenIdle(
|
|
() => {
|
|
setMinMaxMarkers();
|
|
},
|
|
blueprintCount * 10 + scale === "date" ? 50 : 100,
|
|
);
|
|
|
|
function createSetMinMaxMarkersWhenIdleEffect() {
|
|
createEffect(() => {
|
|
visibleTimeRange();
|
|
dark();
|
|
untrack(setMinMaxMarkersWhenIdle);
|
|
});
|
|
}
|
|
createSetMinMaxMarkersWhenIdleEffect();
|
|
|
|
if (!chartIndex) {
|
|
subscribeVisibleTimeRangeChange(chart);
|
|
|
|
updateVisiblePriceSeriesType({
|
|
chart,
|
|
visibleTimeRange: visibleTimeRange(),
|
|
});
|
|
|
|
/** @param {PriceSeriesType} type */
|
|
function _createPriceSeries(type) {
|
|
return createPriceSeries({
|
|
chart,
|
|
chartSeries,
|
|
lastVisibleDatasetIndex,
|
|
preset,
|
|
setMinMaxMarkersWhenIdle,
|
|
type,
|
|
});
|
|
}
|
|
|
|
const priceCandlestickSeries = _createPriceSeries("Candlestick");
|
|
const priceLineSeries = _createPriceSeries("Line");
|
|
|
|
function createLinkPriceSeriesEffect() {
|
|
createEffect(() => {
|
|
priceCandlestickSeries.active.set(priceLineSeries.active());
|
|
});
|
|
|
|
createEffect(() => {
|
|
priceLineSeries.active.set(priceCandlestickSeries.active());
|
|
});
|
|
}
|
|
createLinkPriceSeriesEffect();
|
|
}
|
|
|
|
[...seriesBlueprints]
|
|
.reverse()
|
|
.forEach((seriesBlueprint, index) => {
|
|
const dataset = datasets.getOrImport(
|
|
scale,
|
|
seriesBlueprint.datasetPath,
|
|
);
|
|
|
|
// Don't trigger reactivity by design
|
|
activeDatasets().add(dataset);
|
|
|
|
createSeries({
|
|
index,
|
|
seriesBlueprint,
|
|
chart,
|
|
preset,
|
|
lastVisibleDatasetIndex,
|
|
setMinMaxMarkersWhenIdle,
|
|
chartSeries,
|
|
dataset,
|
|
});
|
|
});
|
|
|
|
setMinMaxMarkers();
|
|
|
|
activeDatasets.set((s) => s);
|
|
|
|
chartSeries.forEach((series) => {
|
|
allSeries.unshift(series);
|
|
|
|
createEffect(() => {
|
|
series.active();
|
|
untrack(setMinMaxMarkersWhenIdle);
|
|
});
|
|
});
|
|
|
|
const chartVisible = createMemo(() =>
|
|
chartSeries.some((series) => series.visible()),
|
|
);
|
|
|
|
function createChartVisibilityEffect() {
|
|
createEffect(() => {
|
|
const chartWrapper = chartDiv.parentElement;
|
|
if (!chartWrapper) throw "Should exist";
|
|
chartWrapper.hidden = !chartVisible();
|
|
});
|
|
}
|
|
createChartVisibilityEffect();
|
|
|
|
function createTimeScaleVisibilityEffect() {
|
|
createEffect(() => {
|
|
const visible = chartIndex === chartCount - 1 && chartVisible();
|
|
|
|
chart.timeScale().applyOptions({
|
|
visible,
|
|
});
|
|
|
|
if (chartIndex === 1) {
|
|
charts[0].timeScale().applyOptions({
|
|
visible: !visible,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
createTimeScaleVisibilityEffect();
|
|
|
|
// createEffect(() =>
|
|
// chart.priceScale("right").applyOptions({
|
|
// mode: chartPriceMode.selected() === "Linear" ? 0 : 1,
|
|
// })
|
|
// );
|
|
|
|
chart
|
|
.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);
|
|
}
|
|
}
|
|
});
|
|
|
|
chart.subscribeCrosshairMove(({ time, sourceEvent }) => {
|
|
// Don't override crosshair position from scroll event
|
|
if (time && !sourceEvent) return;
|
|
|
|
for (
|
|
let otherChartIndex = 0;
|
|
otherChartIndex <= chartCount - 1;
|
|
otherChartIndex++
|
|
) {
|
|
const otherChart = charts[otherChartIndex];
|
|
|
|
if (otherChart && chartIndex !== otherChartIndex) {
|
|
if (time) {
|
|
otherChart.setCrosshairPosition(
|
|
NaN,
|
|
time,
|
|
otherChart.whitespace,
|
|
);
|
|
} else {
|
|
// No time when mouse goes outside the chart
|
|
otherChart.clearCrosshairPosition();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return chart;
|
|
});
|
|
}
|
|
|
|
function createSelectedEffect() {
|
|
// @ts-ignore
|
|
createEffect((_previouslySelected) => {
|
|
const previouslySelected = /** @type {Preset | undefined} */ (
|
|
_previouslySelected
|
|
);
|
|
|
|
const preset = selected();
|
|
|
|
untrack(() => {
|
|
if (previouslySelected) {
|
|
urlParamsHelpers.reset(preset.id);
|
|
}
|
|
urlParamsHelpers.replaceHistory({ pathname: preset.id });
|
|
createRoot(applyPreset);
|
|
});
|
|
|
|
return preset;
|
|
}, undefined);
|
|
}
|
|
createSelectedEffect();
|
|
|
|
function initTimeScaleElement() {
|
|
function initScrollButtons() {
|
|
const buttonBackward = getElementById("button-backward");
|
|
const buttonBackwardIcon = getElementById("button-backward-icon");
|
|
const buttonBackwardPauseIcon = getElementById(
|
|
"button-backward-pause-icon",
|
|
);
|
|
const buttonForward = getElementById("button-forward");
|
|
const buttonForwardIcon = getElementById("button-forward-icon");
|
|
const buttonForwardPauseIcon = getElementById(
|
|
"button-forward-pause-icon",
|
|
);
|
|
|
|
let interval = /** @type {number | undefined} */ (undefined);
|
|
let direction = /** @type {1 | -1 | 0} */ (0);
|
|
|
|
const DELAY = 5;
|
|
const MULTIPLIER = DELAY / 10000;
|
|
|
|
function scrollChart() {
|
|
if (direction <= 0) {
|
|
buttonForwardIcon.removeAttribute("hidden");
|
|
buttonForwardPauseIcon.setAttribute("hidden", "");
|
|
}
|
|
if (direction >= 0) {
|
|
buttonBackwardIcon.removeAttribute("hidden");
|
|
buttonBackwardPauseIcon.setAttribute("hidden", "");
|
|
}
|
|
if (direction === -1) {
|
|
buttonBackwardIcon.setAttribute("hidden", "");
|
|
buttonBackwardPauseIcon.removeAttribute("hidden");
|
|
}
|
|
if (direction === 1) {
|
|
buttonForwardIcon.setAttribute("hidden", "");
|
|
buttonForwardPauseIcon.removeAttribute("hidden");
|
|
}
|
|
|
|
if (!direction) {
|
|
clearInterval(interval);
|
|
return;
|
|
}
|
|
|
|
interval = setInterval(() => {
|
|
const time = charts.at(-1)?.timeScale();
|
|
|
|
if (!time) return;
|
|
|
|
const range = time.getVisibleLogicalRange();
|
|
|
|
if (!range) return;
|
|
|
|
const speed = (range.to - range.from) * MULTIPLIER * direction;
|
|
|
|
// @ts-ignore
|
|
range.from += speed;
|
|
// @ts-ignore
|
|
range.to += speed;
|
|
|
|
time.setVisibleLogicalRange(range);
|
|
}, DELAY);
|
|
}
|
|
|
|
buttonBackward.addEventListener("click", () => {
|
|
if (direction !== -1) {
|
|
direction = -1;
|
|
} else {
|
|
direction = 0;
|
|
}
|
|
scrollChart();
|
|
});
|
|
|
|
buttonForward.addEventListener("click", () => {
|
|
if (direction !== 1) {
|
|
direction = 1;
|
|
} else {
|
|
direction = 0;
|
|
}
|
|
scrollChart();
|
|
});
|
|
}
|
|
initScrollButtons();
|
|
|
|
const GENESIS_DAY = "2009-01-03";
|
|
|
|
/**
|
|
* @param {HTMLButtonElement} button
|
|
*/
|
|
function setTimeScale(button) {
|
|
const chart = charts.at(-1);
|
|
if (!chart) return;
|
|
const timeScale = chart.timeScale();
|
|
|
|
console.log(button);
|
|
|
|
const year = button.dataset.year;
|
|
let days = button.dataset.days;
|
|
let toHeight = button.dataset.to;
|
|
|
|
if (scale() === "date") {
|
|
let from = new Date();
|
|
let to = new Date();
|
|
to.setUTCHours(0, 0, 0, 0);
|
|
|
|
if (button.dataset.yearToDate !== null) {
|
|
console.log("aaaa");
|
|
days = String(
|
|
Math.ceil(
|
|
(to.getTime() -
|
|
new Date(`${to.getUTCFullYear()}-01-01`).getTime()) /
|
|
ONE_DAY_IN_MS,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (year) {
|
|
from = new Date(`${year}-01-01`);
|
|
to = new Date(`${year}-12-31`);
|
|
} else if (days) {
|
|
from.setDate(from.getUTCDate() - Number(days));
|
|
} else {
|
|
from = new Date(GENESIS_DAY);
|
|
}
|
|
|
|
timeScale.setVisibleRange({
|
|
from: /** @type {Time} */ (from.getTime() / 1000),
|
|
to: /** @type {Time} */ (to.getTime() / 1000),
|
|
});
|
|
} else if (scale() === "height") {
|
|
timeScale.setVisibleRange({
|
|
from: /** @type {Time} */ (0),
|
|
to: /** @type {Time} */ (
|
|
Number(toHeight?.slice(0, -1)) * 1_000
|
|
),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} timeScaleButtons
|
|
*/
|
|
function initGoToButtons(timeScaleButtons) {
|
|
Array.from(timeScaleButtons.children).forEach((button) => {
|
|
if (button.tagName !== "BUTTON") throw "Expect a button";
|
|
button.addEventListener("click", () => {
|
|
setTimeScale(/** @type {HTMLButtonElement} */ (button));
|
|
});
|
|
});
|
|
}
|
|
initGoToButtons(timeScaleDateButtons);
|
|
initGoToButtons(timeScaleHeightButtons);
|
|
|
|
function createScaleButtonsToggleEffect() {
|
|
createEffect(() => {
|
|
const scaleIsDate = scale() === "date";
|
|
|
|
timeScaleDateButtons.hidden = !scaleIsDate;
|
|
timeScaleHeightButtons.hidden = scaleIsDate;
|
|
});
|
|
}
|
|
createScaleButtonsToggleEffect();
|
|
}
|
|
initTimeScaleElement();
|
|
|
|
function initFavoriteButton() {
|
|
buttonFavorite.addEventListener("click", () => {
|
|
const preset = selected();
|
|
|
|
preset.isFavorite.set((f) => {
|
|
const newState = !f;
|
|
|
|
const localStorageKey = presetToFavoriteLocalStorageKey(preset);
|
|
if (newState) {
|
|
localStorage.setItem(localStorageKey, "1");
|
|
} else {
|
|
localStorage.removeItem(localStorageKey);
|
|
}
|
|
|
|
return newState;
|
|
});
|
|
});
|
|
|
|
createEffect(() => {
|
|
if (selected().isFavorite()) {
|
|
buttonFavorite.dataset.highlight = "";
|
|
} else {
|
|
delete buttonFavorite.dataset.highlight;
|
|
}
|
|
});
|
|
}
|
|
initFavoriteButton();
|
|
},
|
|
),
|
|
);
|
|
|
|
function initSearch() {
|
|
const resetSearchButton = getElementById("reset-search");
|
|
|
|
/**
|
|
* @param {string} [value = '']
|
|
*/
|
|
function setInputValue(value = "") {
|
|
searchInput.focus();
|
|
searchInput.value = value;
|
|
searchInput.dispatchEvent(new Event("input"));
|
|
}
|
|
|
|
resetSearchButton.addEventListener("click", () => {
|
|
setInputValue();
|
|
});
|
|
|
|
const localStorageSearchKey = "search";
|
|
|
|
function initInput() {
|
|
const haystack = presetsList.map(
|
|
(preset) => `${preset.title}\t${preset.serializedPath}`,
|
|
);
|
|
|
|
const searchSmallOgInnerHTML = searchSmall.innerHTML;
|
|
|
|
const RESULTS_PER_PAGE = 100;
|
|
|
|
import("./libraries/ufuzzy/script.js").then(({ default: ufuzzy }) => {
|
|
/**
|
|
* @param {uFuzzy.SearchResult} searchResult
|
|
* @param {number} pageIndex
|
|
*/
|
|
function computeResultPage(searchResult, pageIndex) {
|
|
/** @type {{ preset: Preset, path: string, title: string }[]} */
|
|
let list = [];
|
|
|
|
let [indexes, info, order] = searchResult || [null, null, null];
|
|
|
|
const minIndex = pageIndex * RESULTS_PER_PAGE;
|
|
|
|
if (indexes?.length) {
|
|
const maxIndex = Math.min(
|
|
(order || indexes).length - 1,
|
|
minIndex + RESULTS_PER_PAGE - 1,
|
|
);
|
|
|
|
list = Array(maxIndex - minIndex + 1);
|
|
|
|
if (info && order) {
|
|
for (let i = minIndex; i <= maxIndex; i++) {
|
|
let infoIdx = order[i];
|
|
|
|
const [title, path] = ufuzzy
|
|
.highlight(haystack[info.idx[infoIdx]], info.ranges[infoIdx])
|
|
.split("\t");
|
|
|
|
list[i % 100] = {
|
|
preset: presetsList[info.idx[infoIdx]],
|
|
path,
|
|
title,
|
|
};
|
|
}
|
|
} else {
|
|
for (let i = minIndex; i <= maxIndex; i++) {
|
|
let index = indexes[i];
|
|
|
|
const [title, path] = haystack[index].split("\t");
|
|
|
|
list[i % 100] = {
|
|
preset: presetsList[index],
|
|
path,
|
|
title,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return list;
|
|
}
|
|
|
|
/** @type {uFuzzy.Options} */
|
|
const config = {
|
|
intraIns: Infinity,
|
|
intraChars: `[a-z\d' ]`,
|
|
};
|
|
|
|
const fuzzyMultiInsert = /** @type {uFuzzy} */ (
|
|
ufuzzy({
|
|
intraIns: 1,
|
|
})
|
|
);
|
|
const fuzzyMultiInsertFuzzier = /** @type {uFuzzy} */ (ufuzzy(config));
|
|
const fuzzySingleError = /** @type {uFuzzy} */ (
|
|
ufuzzy({
|
|
intraMode: 1,
|
|
...config,
|
|
})
|
|
);
|
|
const fuzzySingleErrorFuzzier = /** @type {uFuzzy} */ (
|
|
ufuzzy({
|
|
intraMode: 1,
|
|
...config,
|
|
})
|
|
);
|
|
|
|
/** @type {VoidFunction | undefined} */
|
|
let dispose;
|
|
|
|
function inputEvent() {
|
|
createRoot((_dispose) => {
|
|
const needle = /** @type {string} */ (searchInput.value);
|
|
|
|
localeStorageHelpers.write(localStorageSearchKey, needle);
|
|
|
|
dispose?.();
|
|
|
|
dispose = _dispose;
|
|
|
|
searchResults.scrollTo({
|
|
top: 0,
|
|
});
|
|
|
|
if (!needle) {
|
|
searchSmall.innerHTML = searchSmallOgInnerHTML;
|
|
searchResults.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const outOfOrder = 5;
|
|
const infoThresh = 5_000;
|
|
|
|
let result = fuzzyMultiInsert?.search(
|
|
haystack,
|
|
needle,
|
|
undefined,
|
|
infoThresh,
|
|
);
|
|
|
|
if (!result?.[0]?.length || !result?.[1]) {
|
|
result = fuzzyMultiInsert?.search(
|
|
haystack,
|
|
needle,
|
|
outOfOrder,
|
|
infoThresh,
|
|
);
|
|
}
|
|
|
|
if (!result?.[0]?.length || !result?.[1]) {
|
|
result = fuzzySingleError?.search(
|
|
haystack,
|
|
needle,
|
|
outOfOrder,
|
|
infoThresh,
|
|
);
|
|
}
|
|
|
|
if (!result?.[0]?.length || !result?.[1]) {
|
|
result = fuzzySingleErrorFuzzier?.search(
|
|
haystack,
|
|
needle,
|
|
outOfOrder,
|
|
infoThresh,
|
|
);
|
|
}
|
|
|
|
if (!result?.[0]?.length || !result?.[1]) {
|
|
result = fuzzyMultiInsertFuzzier?.search(
|
|
haystack,
|
|
needle,
|
|
undefined,
|
|
infoThresh,
|
|
);
|
|
}
|
|
|
|
if (!result?.[0]?.length || !result?.[1]) {
|
|
result = fuzzyMultiInsertFuzzier?.search(
|
|
haystack,
|
|
needle,
|
|
outOfOrder,
|
|
infoThresh,
|
|
);
|
|
}
|
|
|
|
searchSmall.innerHTML = `Found <strong>${
|
|
result?.[0]?.length || 0
|
|
}</strong> preset(s)`;
|
|
searchResults.innerHTML = "";
|
|
|
|
const list = computeResultPage(result, 0);
|
|
|
|
list.forEach(({ preset, path, title }) => {
|
|
const li = window.document.createElement("li");
|
|
searchResults.appendChild(li);
|
|
|
|
const label = createPresetLabel({
|
|
preset,
|
|
frame: "search",
|
|
name: title,
|
|
top: path,
|
|
});
|
|
|
|
li.append(label);
|
|
});
|
|
});
|
|
}
|
|
|
|
if (searchInput.value) {
|
|
inputEvent();
|
|
}
|
|
|
|
searchInput.addEventListener("input", inputEvent);
|
|
});
|
|
}
|
|
|
|
searchInput.addEventListener("focus", initInput, {
|
|
once: true,
|
|
});
|
|
|
|
if (!searchFrame.hidden) {
|
|
setInputValue(localStorage.getItem(localStorageSearchKey) || "");
|
|
}
|
|
}
|
|
initSearch();
|
|
|
|
function initHistory() {
|
|
const LOCAL_STORAGE_HISTORY_KEY = "history";
|
|
const MAX_HISTORY_LENGTH = 1_000;
|
|
|
|
const owner = getOwner();
|
|
|
|
const history = /** @type {SerializedPresetsHistory} */ (
|
|
JSON.parse(localStorage.getItem(LOCAL_STORAGE_HISTORY_KEY) || "[]")
|
|
).flatMap(([presetId, timestamp]) => {
|
|
const preset = presetsList.find((preset) => preset.id === presetId);
|
|
return preset ? [{ preset, date: new Date(timestamp) }] : [];
|
|
});
|
|
|
|
/** @param {Date} date */
|
|
function dateToTestedString(date) {
|
|
return date.toLocaleString().split(",")[0];
|
|
}
|
|
|
|
/** @param {Date} date */
|
|
function dateToDisplayedString(date) {
|
|
const formattedDate = dateToTestedString(date);
|
|
|
|
const now = new Date();
|
|
if (dateToTestedString(now) === formattedDate) {
|
|
return "Today";
|
|
}
|
|
|
|
now.setUTCDate(now.getUTCDate() - 1);
|
|
|
|
if (dateToTestedString(now) === formattedDate) {
|
|
return "Yesterday";
|
|
}
|
|
|
|
return date.toLocaleDateString(undefined, {
|
|
weekday: "long",
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
const grouped = history.reduce((grouped, { preset, date }) => {
|
|
grouped[dateToTestedString(date)] ||= [];
|
|
grouped[dateToTestedString(date)].push({ preset, date });
|
|
return grouped;
|
|
}, /** @type {Record<string, {preset: Preset, date: Date}[]>} */ ({}));
|
|
|
|
/** @type {[string|undefined, string|undefined]} */
|
|
const firstTwo = [undefined, undefined];
|
|
|
|
function initHistoryListInDom() {
|
|
Object.entries(grouped).forEach(([key, tuples], index) => {
|
|
if (index < 2) {
|
|
firstTwo[index] = key;
|
|
}
|
|
|
|
const heading = window.document.createElement("h4");
|
|
heading.id = key;
|
|
heading.innerHTML = dateToDisplayedString(tuples[0].date);
|
|
historyList.append(heading);
|
|
|
|
tuples.forEach(({ preset, date }) => {
|
|
historyList.append(
|
|
createPresetLabel({
|
|
preset,
|
|
frame: "history",
|
|
name: preset.title,
|
|
id: date.valueOf().toString(),
|
|
top: date.toLocaleTimeString(),
|
|
owner,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
initHistoryListInDom();
|
|
|
|
function createUpdateHistoryEffect() {
|
|
createEffect(() => {
|
|
const preset = selected();
|
|
const date = new Date();
|
|
const testedString = dateToTestedString(date);
|
|
|
|
const label = createPresetLabel({
|
|
preset,
|
|
frame: "history",
|
|
name: preset.title,
|
|
id: date.valueOf().toString(),
|
|
top: date.toLocaleTimeString(),
|
|
owner,
|
|
});
|
|
|
|
const li = window.document.createElement("li");
|
|
li.append(label);
|
|
|
|
if (testedString === firstTwo[0]) {
|
|
if (selected() === grouped[testedString].at(0)?.preset) {
|
|
return;
|
|
}
|
|
|
|
grouped[testedString].unshift({ preset, date });
|
|
getElementById(testedString).after(li);
|
|
} else {
|
|
const [first, second] = firstTwo;
|
|
/** @param {string | undefined} id */
|
|
function updateHeading(id) {
|
|
if (!id) return;
|
|
getElementById(id).innerHTML = dateToDisplayedString(
|
|
grouped[id][0].date,
|
|
);
|
|
}
|
|
|
|
updateHeading(first);
|
|
updateHeading(second);
|
|
|
|
const heading = window.document.createElement("h4");
|
|
heading.innerHTML = dateToDisplayedString(date);
|
|
heading.id = testedString;
|
|
|
|
historyList.prepend(li);
|
|
historyList.prepend(heading);
|
|
|
|
grouped[testedString] = [{ preset, date }];
|
|
|
|
firstTwo[1] = firstTwo[0];
|
|
firstTwo[0] = testedString;
|
|
}
|
|
|
|
history.unshift({
|
|
date: new Date(),
|
|
preset,
|
|
});
|
|
|
|
runWhenIdle(() => {
|
|
/** @type {SerializedPresetsHistory} */
|
|
const serializedHistory = history.map(({ preset, date }) => [
|
|
preset.id,
|
|
date.getTime(),
|
|
]);
|
|
|
|
if (serializedHistory.length > MAX_HISTORY_LENGTH) {
|
|
serializedHistory.length = MAX_HISTORY_LENGTH;
|
|
}
|
|
|
|
localStorage.setItem(
|
|
LOCAL_STORAGE_HISTORY_KEY,
|
|
JSON.stringify(serializedHistory),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
createUpdateHistoryEffect();
|
|
}
|
|
initHistory();
|
|
}
|
|
initEverythingRelatedToPresets();
|