website: reorg

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