mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-04-24 22:59:58 -07:00
website: snapshot
This commit is contained in:
@@ -77,7 +77,6 @@ const lineWidth = /** @type {any} */ (1.5);
|
||||
* @param {BrkClient} args.brk
|
||||
* @param {true} [args.fitContent]
|
||||
* @param {HTMLElement} [args.captureElement]
|
||||
* @param {{unit: Unit; blueprints: AnyFetchedSeriesBlueprint[]}[]} [args.config]
|
||||
*/
|
||||
export function createChart({
|
||||
parent,
|
||||
@@ -85,7 +84,6 @@ export function createChart({
|
||||
brk,
|
||||
fitContent,
|
||||
captureElement,
|
||||
config,
|
||||
}) {
|
||||
const baseUrl = brk.baseUrl.replace(/\/$/, "");
|
||||
|
||||
@@ -173,6 +171,9 @@ export function createChart({
|
||||
fontFamily: style.fontFamily,
|
||||
background: { color: "transparent" },
|
||||
attributionLogo: false,
|
||||
panes: {
|
||||
enableResize: false,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
vertLines: { visible: false },
|
||||
@@ -465,10 +466,8 @@ export function createChart({
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {string} args.name
|
||||
* @param {Unit} args.unit
|
||||
* @param {number} args.order
|
||||
* @param {Color[]} args.colors
|
||||
* @param {LCSeriesType} args.seriesType
|
||||
* @param {AnyMetricPattern} args.metric
|
||||
* @param {number} args.paneIndex
|
||||
* @param {boolean} [args.defaultActive]
|
||||
@@ -481,14 +480,11 @@ export function createChart({
|
||||
* @param {(data: any[]) => void} args.setData
|
||||
* @param {(data: any) => void} args.update
|
||||
* @param {() => void} args.onRemove
|
||||
* @param {() => void} [args.onDataLoaded]
|
||||
*/
|
||||
create({
|
||||
metric,
|
||||
name,
|
||||
unit,
|
||||
order,
|
||||
seriesType,
|
||||
paneIndex,
|
||||
defaultActive,
|
||||
colors,
|
||||
@@ -501,7 +497,6 @@ export function createChart({
|
||||
setData,
|
||||
update,
|
||||
onRemove,
|
||||
onDataLoaded,
|
||||
}) {
|
||||
const key = stringToId(name);
|
||||
const id = `${key}-${paneIndex}`;
|
||||
@@ -689,7 +684,7 @@ export function createChart({
|
||||
.setVisibleLogicalRange({ from: -1, to: data.length });
|
||||
}
|
||||
// Delay until chart has applied the range
|
||||
requestAnimationFrame(() => onDataLoaded?.());
|
||||
requestAnimationFrame(() => blueprints.onDataLoaded?.());
|
||||
} else {
|
||||
// Incremental update: only process new data points
|
||||
for (let i = startIdx; i < length; i++) {
|
||||
@@ -723,42 +718,30 @@ export function createChart({
|
||||
// index.onChange.add(setupIndexEffect);
|
||||
// _cleanup = () => index.onChange.delete(setupIndexEffect);
|
||||
|
||||
addPriceScaleSelectorIfNeeded({
|
||||
paneIndex,
|
||||
seriesType,
|
||||
unit,
|
||||
});
|
||||
|
||||
return series;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {string} args.name
|
||||
* @param {Unit} args.unit
|
||||
* @param {number} args.order
|
||||
* @param {AnyMetricPattern} args.metric
|
||||
* @param {number} [args.paneIndex]
|
||||
* @param {[Color, Color]} [args.colors] - [upColor, downColor] for legend
|
||||
* @param {boolean} [args.defaultActive]
|
||||
* @param {boolean} [args.inverse]
|
||||
* @param {CandlestickSeriesPartialOptions} [args.options]
|
||||
*/
|
||||
addCandlestick({
|
||||
metric,
|
||||
name,
|
||||
unit,
|
||||
order,
|
||||
paneIndex = 0,
|
||||
colors: customColors,
|
||||
defaultActive,
|
||||
inverse,
|
||||
options,
|
||||
}) {
|
||||
const defaultGreen = inverse ? colors.red : colors.green;
|
||||
const defaultRed = inverse ? colors.green : colors.red;
|
||||
const upColor = customColors?.[0] ?? defaultGreen;
|
||||
const downColor = customColors?.[1] ?? defaultRed;
|
||||
const upColor = customColors?.[0] ?? colors.green;
|
||||
const downColor = customColors?.[1] ?? colors.red;
|
||||
|
||||
/** @type {CandlestickISeries} */
|
||||
const candlestickISeries = /** @type {any} */ (
|
||||
@@ -788,8 +771,10 @@ export function createChart({
|
||||
|
||||
let active = defaultActive !== false;
|
||||
let highlighted = true;
|
||||
let showLine = visibleBarsCount > 500;
|
||||
let dataLoaded = false;
|
||||
|
||||
/** @param {number} barCount */
|
||||
const shouldShowLine = (barCount) => barCount > 500;
|
||||
let showLine = shouldShowLine(visibleBarsCount);
|
||||
|
||||
function update() {
|
||||
candlestickISeries.applyOptions({
|
||||
@@ -809,8 +794,8 @@ export function createChart({
|
||||
|
||||
/** @type {ZoomChangeCallback} */
|
||||
function handleZoom(count) {
|
||||
if (!dataLoaded) return; // Ignore zoom changes until data is ready
|
||||
const newShowLine = count > 500;
|
||||
if (!series.hasData()) return; // Ignore zoom changes until data is ready
|
||||
const newShowLine = shouldShowLine(count);
|
||||
if (newShowLine === showLine) return;
|
||||
showLine = newShowLine;
|
||||
update();
|
||||
@@ -823,8 +808,6 @@ export function createChart({
|
||||
name,
|
||||
order,
|
||||
paneIndex,
|
||||
seriesType: "Candlestick",
|
||||
unit,
|
||||
defaultActive,
|
||||
metric,
|
||||
setOrder(order) {
|
||||
@@ -855,6 +838,13 @@ export function createChart({
|
||||
candlestickISeries.setData(data);
|
||||
const lineData = data.map((d) => ({ time: d.time, value: d.close }));
|
||||
lineISeries.setData(lineData);
|
||||
requestAnimationFrame(() => {
|
||||
const range = ichart.timeScale().getVisibleLogicalRange();
|
||||
if (range) {
|
||||
showLine = shouldShowLine(range.to - range.from);
|
||||
}
|
||||
update();
|
||||
});
|
||||
},
|
||||
update: (data) => {
|
||||
candlestickISeries.update(data);
|
||||
@@ -867,10 +857,6 @@ export function createChart({
|
||||
ichart.removeSeries(candlestickISeries);
|
||||
ichart.removeSeries(lineISeries);
|
||||
},
|
||||
onDataLoaded: () => {
|
||||
dataLoaded = true;
|
||||
update();
|
||||
},
|
||||
});
|
||||
|
||||
panes.register(paneIndex, series, [candlestickISeries, lineISeries]);
|
||||
@@ -880,7 +866,6 @@ export function createChart({
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {string} args.name
|
||||
* @param {Unit} args.unit
|
||||
* @param {number} args.order
|
||||
* @param {AnyMetricPattern} args.metric
|
||||
* @param {Color | [Color, Color]} [args.color] - Single color or [positive, negative] colors
|
||||
@@ -891,7 +876,6 @@ export function createChart({
|
||||
addHistogram({
|
||||
metric,
|
||||
name,
|
||||
unit,
|
||||
color = [colors.green, colors.red],
|
||||
order,
|
||||
paneIndex = 0,
|
||||
@@ -932,8 +916,6 @@ export function createChart({
|
||||
name,
|
||||
order,
|
||||
paneIndex,
|
||||
seriesType: "Bar",
|
||||
unit,
|
||||
defaultActive,
|
||||
metric,
|
||||
setOrder: (order) => iseries.setSeriesOrder(order),
|
||||
@@ -987,10 +969,9 @@ export function createChart({
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {string} args.name
|
||||
* @param {Unit} args.unit
|
||||
* @param {number} args.order
|
||||
* @param {AnyMetricPattern} args.metric
|
||||
* @param {Color} [args.color]
|
||||
* @param {Color} args.color
|
||||
* @param {number} [args.paneIndex]
|
||||
* @param {boolean} [args.defaultActive]
|
||||
* @param {LineSeriesPartialOptions} [args.options]
|
||||
@@ -998,16 +979,12 @@ export function createChart({
|
||||
addLine({
|
||||
metric,
|
||||
name,
|
||||
unit,
|
||||
order,
|
||||
color: _color,
|
||||
color,
|
||||
paneIndex = 0,
|
||||
defaultActive,
|
||||
options,
|
||||
}) {
|
||||
const color =
|
||||
_color ?? (unit.id === "usd" ? colors.green : colors.orange);
|
||||
|
||||
/** @type {LineISeries} */
|
||||
const iseries = /** @type {any} */ (
|
||||
ichart.addSeries(
|
||||
@@ -1039,8 +1016,6 @@ export function createChart({
|
||||
name,
|
||||
order,
|
||||
paneIndex,
|
||||
seriesType: "Line",
|
||||
unit,
|
||||
defaultActive,
|
||||
metric,
|
||||
setOrder: (order) => iseries.setSeriesOrder(order),
|
||||
@@ -1080,10 +1055,9 @@ export function createChart({
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {string} args.name
|
||||
* @param {Unit} args.unit
|
||||
* @param {number} args.order
|
||||
* @param {AnyMetricPattern} args.metric
|
||||
* @param {Color} [args.color]
|
||||
* @param {Color} args.color
|
||||
* @param {number} [args.paneIndex]
|
||||
* @param {boolean} [args.defaultActive]
|
||||
* @param {LineSeriesPartialOptions} [args.options]
|
||||
@@ -1091,16 +1065,12 @@ export function createChart({
|
||||
addDots({
|
||||
metric,
|
||||
name,
|
||||
unit,
|
||||
order,
|
||||
color: _color,
|
||||
color,
|
||||
paneIndex = 0,
|
||||
defaultActive,
|
||||
options,
|
||||
}) {
|
||||
const color =
|
||||
_color ?? (unit.id === "usd" ? colors.green : colors.orange);
|
||||
|
||||
/** @type {LineISeries} */
|
||||
const iseries = /** @type {any} */ (
|
||||
ichart.addSeries(
|
||||
@@ -1145,8 +1115,6 @@ export function createChart({
|
||||
name,
|
||||
order,
|
||||
paneIndex,
|
||||
seriesType: "Line",
|
||||
unit,
|
||||
defaultActive,
|
||||
metric,
|
||||
setOrder: (order) => iseries.setSeriesOrder(order),
|
||||
@@ -1187,7 +1155,6 @@ export function createChart({
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {string} args.name
|
||||
* @param {Unit} args.unit
|
||||
* @param {number} args.order
|
||||
* @param {AnyMetricPattern} args.metric
|
||||
* @param {number} [args.paneIndex]
|
||||
@@ -1199,7 +1166,6 @@ export function createChart({
|
||||
addBaseline({
|
||||
metric,
|
||||
name,
|
||||
unit,
|
||||
order,
|
||||
paneIndex: _paneIndex,
|
||||
defaultActive,
|
||||
@@ -1249,8 +1215,6 @@ export function createChart({
|
||||
name,
|
||||
order,
|
||||
paneIndex,
|
||||
seriesType: "Baseline",
|
||||
unit,
|
||||
defaultActive,
|
||||
metric,
|
||||
setOrder: (order) => iseries.setSeriesOrder(order),
|
||||
@@ -1290,21 +1254,16 @@ export function createChart({
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {Unit} args.unit
|
||||
* @param {LCSeriesType} args.seriesType
|
||||
* @param {number} args.paneIndex
|
||||
* @param {number} paneIndex
|
||||
* @param {Unit} unit
|
||||
*/
|
||||
function addPriceScaleSelectorIfNeeded({ unit, paneIndex, seriesType }) {
|
||||
function applyScaleForUnit(paneIndex, unit) {
|
||||
const id = `${chartId}-scale`;
|
||||
|
||||
/** @type {"lin" | "log"} */
|
||||
const defaultValue =
|
||||
unit.id === "usd" && seriesType !== "Baseline" ? "log" : "lin";
|
||||
const defaultValue = unit.id === "usd" ? "log" : "lin";
|
||||
|
||||
const persisted = createPersistedValue({
|
||||
defaultValue,
|
||||
storageKey: `${id}-scale-${paneIndex}`,
|
||||
defaultValue: /** @type {"lin" | "log"} */ (defaultValue),
|
||||
storageKey: `${id}-${paneIndex}-${unit.id}`,
|
||||
urlKey: paneIndex === 0 ? "price_scale" : "unit_scale",
|
||||
serialize: (v) => v,
|
||||
deserialize: (s) => /** @type {"lin" | "log"} */ (s),
|
||||
@@ -1312,15 +1271,19 @@ export function createChart({
|
||||
|
||||
/** @param {"lin" | "log"} value */
|
||||
const applyScale = (value) => {
|
||||
try {
|
||||
const pane = ichart.panes().at(paneIndex);
|
||||
pane?.priceScale("right").applyOptions({
|
||||
mode: value === "lin" ? 0 : 1,
|
||||
});
|
||||
} catch {}
|
||||
panes.whenReady(paneIndex, () => {
|
||||
try {
|
||||
ichart
|
||||
.panes()
|
||||
.at(paneIndex)
|
||||
?.priceScale("right")
|
||||
.applyOptions({
|
||||
mode: value === "lin" ? 0 : 1,
|
||||
});
|
||||
} catch {}
|
||||
});
|
||||
};
|
||||
|
||||
// Apply scale immediately
|
||||
applyScale(persisted.value);
|
||||
|
||||
fieldsets.addIfNeeded({
|
||||
@@ -1330,7 +1293,7 @@ export function createChart({
|
||||
createChild() {
|
||||
const field = createChoiceField({
|
||||
choices: /** @type {const} */ (["lin", "log"]),
|
||||
id: stringToId(`${id} ${paneIndex} ${unit}`),
|
||||
id: stringToId(`${id} ${paneIndex}`),
|
||||
initialValue: persisted.value,
|
||||
onChange(value) {
|
||||
persisted.set(value);
|
||||
@@ -1343,11 +1306,206 @@ export function createChart({
|
||||
});
|
||||
}
|
||||
|
||||
const blueprints = {
|
||||
/** @type {{ map: Map<Unit, AnyFetchedSeriesBlueprint[]>, series: AnySeries[], unit: Unit | null, legend: Legend }[]} */
|
||||
panes: [
|
||||
{ map: new Map(), series: [], unit: null, legend: legends.top },
|
||||
{ map: new Map(), series: [], unit: null, legend: legends.bottom },
|
||||
],
|
||||
|
||||
/** @type {VoidFunction | undefined} */
|
||||
onDataLoaded: undefined,
|
||||
|
||||
/** @param {number} paneIndex */
|
||||
rebuildPane(paneIndex) {
|
||||
const pane = this.panes[paneIndex];
|
||||
const { map, series, unit, legend } = pane;
|
||||
|
||||
if (!unit) {
|
||||
series.forEach((s) => s.remove());
|
||||
pane.series = [];
|
||||
legend.removeFrom(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = index.get();
|
||||
legend.removeFrom(0);
|
||||
|
||||
// Store old series to remove AFTER adding new ones
|
||||
// This prevents pane collapse which loses scale settings
|
||||
const oldSeries = [...series];
|
||||
pane.series = [];
|
||||
|
||||
map.get(unit)?.forEach((blueprint, order) => {
|
||||
const options = blueprint.options;
|
||||
const indexes = Object.keys(blueprint.metric.by);
|
||||
|
||||
const defaultColor = unit.id === "usd" ? colors.green : colors.orange;
|
||||
|
||||
if (indexes.includes(idx)) {
|
||||
switch (blueprint.type) {
|
||||
case "Baseline": {
|
||||
pane.series.push(
|
||||
serieses.addBaseline({
|
||||
metric: blueprint.metric,
|
||||
name: blueprint.title,
|
||||
defaultActive: blueprint.defaultActive,
|
||||
paneIndex,
|
||||
options: {
|
||||
...options,
|
||||
topLineColor:
|
||||
blueprint.color?.() ?? blueprint.colors?.[0](),
|
||||
bottomLineColor:
|
||||
blueprint.color?.() ?? blueprint.colors?.[1](),
|
||||
},
|
||||
order,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "Histogram": {
|
||||
pane.series.push(
|
||||
serieses.addHistogram({
|
||||
metric: blueprint.metric,
|
||||
name: blueprint.title,
|
||||
color: blueprint.color,
|
||||
defaultActive: blueprint.defaultActive,
|
||||
paneIndex,
|
||||
options,
|
||||
order,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "Candlestick": {
|
||||
pane.series.push(
|
||||
serieses.addCandlestick({
|
||||
metric: blueprint.metric,
|
||||
name: blueprint.title,
|
||||
colors: blueprint.colors,
|
||||
defaultActive: blueprint.defaultActive,
|
||||
paneIndex,
|
||||
options,
|
||||
order,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "Dots": {
|
||||
pane.series.push(
|
||||
serieses.addDots({
|
||||
metric: blueprint.metric,
|
||||
color: blueprint.color ?? defaultColor,
|
||||
name: blueprint.title,
|
||||
defaultActive: blueprint.defaultActive,
|
||||
paneIndex,
|
||||
options,
|
||||
order,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "Line":
|
||||
case undefined:
|
||||
pane.series.push(
|
||||
serieses.addLine({
|
||||
metric: blueprint.metric,
|
||||
color: blueprint.color ?? defaultColor,
|
||||
name: blueprint.title,
|
||||
defaultActive: blueprint.defaultActive,
|
||||
paneIndex,
|
||||
options,
|
||||
order,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Remove old series AFTER adding new ones to prevent pane collapse
|
||||
oldSeries.forEach((s) => s.remove());
|
||||
|
||||
// Ensure other pane's series are in their correct pane before applying scale
|
||||
// (they may have been collapsed when this pane was empty)
|
||||
const otherPaneIndex = paneIndex === 0 ? 1 : 0;
|
||||
panes.moveTo(otherPaneIndex, otherPaneIndex);
|
||||
|
||||
// Apply scale after series are created and panes are properly separated
|
||||
applyScaleForUnit(paneIndex, unit);
|
||||
},
|
||||
|
||||
rebuild() {
|
||||
this.rebuildPane(0);
|
||||
this.rebuildPane(1);
|
||||
},
|
||||
};
|
||||
|
||||
// Rebuild when index changes
|
||||
index.onChange.add(() => blueprints.rebuild());
|
||||
|
||||
const chart = {
|
||||
index,
|
||||
legends,
|
||||
serieses,
|
||||
addFieldsetIfNeeded: fieldsets.addIfNeeded.bind(fieldsets),
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {Map<Unit, AnyFetchedSeriesBlueprint[]>} args.top
|
||||
* @param {Map<Unit, AnyFetchedSeriesBlueprint[]>} args.bottom
|
||||
* @param {VoidFunction} [args.onDataLoaded]
|
||||
*/
|
||||
setBlueprints({ top, bottom, onDataLoaded }) {
|
||||
blueprints.panes[0].map = top;
|
||||
blueprints.panes[1].map = bottom;
|
||||
blueprints.onDataLoaded = onDataLoaded;
|
||||
|
||||
// Set up unit selectors for each pane
|
||||
[top, bottom].forEach((map, paneIndex) => {
|
||||
const units = Array.from(map.keys());
|
||||
if (!units.length) {
|
||||
blueprints.panes[paneIndex].unit = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultUnit = units[0];
|
||||
const persistedUnit = createPersistedValue({
|
||||
defaultValue: /** @type {string} */ (defaultUnit.id),
|
||||
urlKey: paneIndex === 0 ? "u0" : "u1",
|
||||
serialize: (v) => v,
|
||||
deserialize: (s) => s,
|
||||
});
|
||||
|
||||
// Find unit matching persisted value, or use default
|
||||
const initialUnit =
|
||||
units.find((u) => u.id === persistedUnit.value) ?? defaultUnit;
|
||||
blueprints.panes[paneIndex].unit = initialUnit;
|
||||
|
||||
fieldsets.addIfNeeded({
|
||||
id: `${chartId}-unit`,
|
||||
paneIndex,
|
||||
position: "nw",
|
||||
createChild() {
|
||||
return createChoiceField({
|
||||
choices: units,
|
||||
id: `pane-${paneIndex}-unit`,
|
||||
initialValue: blueprints.panes[paneIndex].unit ?? defaultUnit,
|
||||
toKey: (u) => u.id,
|
||||
toLabel: (u) => u.name,
|
||||
type: "select",
|
||||
sorted: true,
|
||||
onChange(unit) {
|
||||
persistedUnit.set(unit.id);
|
||||
blueprints.panes[paneIndex].unit = unit;
|
||||
blueprints.rebuildPane(paneIndex);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
blueprints.rebuild();
|
||||
|
||||
return blueprints;
|
||||
},
|
||||
|
||||
destroy() {
|
||||
removeThemeListener();
|
||||
clearInterval(refreshInterval);
|
||||
@@ -1355,31 +1513,6 @@ export function createChart({
|
||||
},
|
||||
};
|
||||
|
||||
config?.forEach(({ unit, blueprints }, paneIndex) => {
|
||||
blueprints.forEach((blueprint, order) => {
|
||||
const common = {
|
||||
metric: blueprint.metric,
|
||||
name: blueprint.title,
|
||||
unit,
|
||||
defaultActive: blueprint.defaultActive,
|
||||
paneIndex,
|
||||
options: blueprint.options,
|
||||
order,
|
||||
};
|
||||
if (blueprint.type === "Candlestick") {
|
||||
serieses.addCandlestick({ ...common, colors: blueprint.colors });
|
||||
} else if (blueprint.type === "Baseline") {
|
||||
serieses.addBaseline(common);
|
||||
} else if (blueprint.type === "Histogram") {
|
||||
serieses.addHistogram({ ...common, color: blueprint.color });
|
||||
} else if (blueprint.type === "Dots") {
|
||||
serieses.addDots({ ...common, color: blueprint.color });
|
||||
} else {
|
||||
serieses.addLine({ ...common, color: blueprint.color });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (captureElement && canCapture) {
|
||||
const domain = document.createElement("p");
|
||||
domain.innerText = window.location.host;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
createShadow,
|
||||
createReactiveChoiceField,
|
||||
createChoiceField,
|
||||
createHeader,
|
||||
} from "../utils/dom.js";
|
||||
@@ -9,9 +8,9 @@ import { serdeChartableIndex } from "../utils/serde.js";
|
||||
import { Unit } from "../utils/units.js";
|
||||
import signals from "../signals.js";
|
||||
import { createChart } from "../chart/index.js";
|
||||
import { colors } from "../chart/colors.js";
|
||||
import { webSockets } from "../utils/ws.js";
|
||||
|
||||
const keyPrefix = "chart";
|
||||
const ONE_BTC_IN_SATS = 100_000_000;
|
||||
|
||||
/**
|
||||
@@ -37,361 +36,78 @@ export function init({ option, brk }) {
|
||||
const fieldset = createIndexSelector(option, chart);
|
||||
chartElement.append(fieldset);
|
||||
|
||||
// Bridge chart's index changes into signals system
|
||||
const indexVersion = signals.createSignal(0);
|
||||
chart.index.onChange.add(() => indexVersion.set(indexVersion() + 1));
|
||||
|
||||
const unitChoices = /** @type {const} */ ([Unit.usd, Unit.sats]);
|
||||
/** @type {Signal<Unit>} */
|
||||
const topUnit = signals.createPersistedSignal({
|
||||
defaultValue: /** @type {Unit} */ (Unit.usd),
|
||||
storageKey: `${keyPrefix}-price`,
|
||||
urlKey: "u1",
|
||||
serialize: (u) => u.id,
|
||||
deserialize: (s) =>
|
||||
/** @type {Unit} */ (unitChoices.find((u) => u.id === s) ?? Unit.usd),
|
||||
});
|
||||
const topUnitField = createReactiveChoiceField({
|
||||
defaultValue: Unit.usd,
|
||||
choices: unitChoices,
|
||||
toKey: (u) => u.id,
|
||||
toLabel: (u) => u.name,
|
||||
selected: topUnit,
|
||||
signals,
|
||||
sorted: true,
|
||||
type: "select",
|
||||
});
|
||||
|
||||
chart.addFieldsetIfNeeded({
|
||||
id: "charts-unit-0",
|
||||
paneIndex: 0,
|
||||
position: "nw",
|
||||
createChild() {
|
||||
return topUnitField;
|
||||
},
|
||||
});
|
||||
|
||||
const seriesListTop = /** @type {AnySeries[]} */ ([]);
|
||||
const seriesListBottom = /** @type {AnySeries[]} */ ([]);
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {AnySeries} params.series
|
||||
* @param {Unit} params.unit
|
||||
* @param {IndexName} params.index
|
||||
* Build top blueprints with price series prepended for each unit
|
||||
* @param {Map<Unit, AnyFetchedSeriesBlueprint[]>} optionTop
|
||||
* @returns {Map<Unit, AnyFetchedSeriesBlueprint[]>}
|
||||
*/
|
||||
function printLatest({ series, unit, index }) {
|
||||
const _latest = webSockets.kraken1dCandle.latest();
|
||||
function buildTopBlueprints(optionTop) {
|
||||
/** @type {Map<Unit, AnyFetchedSeriesBlueprint[]>} */
|
||||
const result = new Map();
|
||||
|
||||
if (!_latest) return;
|
||||
// USD price + option blueprints
|
||||
/** @type {FetchedCandlestickSeriesBlueprint} */
|
||||
const usdPrice = {
|
||||
type: "Candlestick",
|
||||
title: "Price",
|
||||
metric: brk.metrics.price.usd.ohlc,
|
||||
};
|
||||
result.set(Unit.usd, [usdPrice, ...(optionTop.get(Unit.usd) ?? [])]);
|
||||
|
||||
const latest = { ..._latest };
|
||||
// Sats price + option blueprints
|
||||
/** @type {FetchedCandlestickSeriesBlueprint} */
|
||||
const satsPrice = {
|
||||
type: "Candlestick",
|
||||
title: "Price",
|
||||
metric: brk.metrics.price.sats.ohlc,
|
||||
colors: [colors.red, colors.green],
|
||||
};
|
||||
result.set(Unit.sats, [satsPrice, ...(optionTop.get(Unit.sats) ?? [])]);
|
||||
|
||||
if (unit === Unit.sats) {
|
||||
latest.open = Math.floor(ONE_BTC_IN_SATS / latest.open);
|
||||
latest.high = Math.floor(ONE_BTC_IN_SATS / latest.high);
|
||||
latest.low = Math.floor(ONE_BTC_IN_SATS / latest.low);
|
||||
latest.close = Math.floor(ONE_BTC_IN_SATS / latest.close);
|
||||
}
|
||||
|
||||
const last_ = series.getData().at(-1);
|
||||
if (!last_) return;
|
||||
const last = { ...last_ };
|
||||
|
||||
if ("close" in last) {
|
||||
last.close = latest.close;
|
||||
}
|
||||
if ("value" in last) {
|
||||
last.value = latest.close;
|
||||
}
|
||||
const date = new Date(/** @type {number} */ (latest.time) * 1000);
|
||||
|
||||
switch (index) {
|
||||
case "height":
|
||||
case "difficultyepoch":
|
||||
case "halvingepoch": {
|
||||
if ("close" in last) {
|
||||
last.low = Math.min(last.low, latest.close);
|
||||
last.high = Math.max(last.high, latest.close);
|
||||
}
|
||||
series.update(last);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (index === "weekindex") {
|
||||
date.setUTCDate(date.getUTCDate() - ((date.getUTCDay() + 6) % 7));
|
||||
} else if (index === "monthindex") {
|
||||
date.setUTCDate(1);
|
||||
} else if (index === "quarterindex") {
|
||||
const month = date.getUTCMonth();
|
||||
date.setUTCMonth(month - (month % 3), 1);
|
||||
} else if (index === "semesterindex") {
|
||||
const month = date.getUTCMonth();
|
||||
date.setUTCMonth(month - (month % 6), 1);
|
||||
} else if (index === "yearindex") {
|
||||
date.setUTCMonth(0, 1);
|
||||
} else if (index === "decadeindex") {
|
||||
date.setUTCFullYear(
|
||||
Math.floor(date.getUTCFullYear() / 10) * 10,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
} else if (index !== "dateindex") {
|
||||
throw Error("Unsupported");
|
||||
}
|
||||
|
||||
const time = date.valueOf() / 1000;
|
||||
|
||||
if (time === last.time) {
|
||||
if ("close" in last) {
|
||||
last.low = Math.min(last.low, latest.low);
|
||||
last.high = Math.max(last.high, latest.high);
|
||||
}
|
||||
series.update(last);
|
||||
} else {
|
||||
last.time = time;
|
||||
series.update(last);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
signals.createScopedEffect(option, (option) => {
|
||||
headingElement.innerHTML = option.title;
|
||||
/** @type {ReturnType<typeof chart.setBlueprints> | null} */
|
||||
let blueprints = null;
|
||||
|
||||
const bottomUnits = Array.from(option.bottom.keys());
|
||||
function updatePriceWithLatest() {
|
||||
const latest = webSockets.kraken1dCandle.latest();
|
||||
if (!latest || !blueprints) return;
|
||||
|
||||
/** @type {Signal<Unit> | undefined} */
|
||||
let bottomUnit;
|
||||
const priceSeries = blueprints.panes[0].series[0];
|
||||
const unit = blueprints.panes[0].unit;
|
||||
if (!priceSeries?.hasData() || !unit) return;
|
||||
|
||||
if (bottomUnits.length) {
|
||||
// Storage key based on unit group (sorted unit IDs) so each group remembers its selection
|
||||
const unitGroupKey = bottomUnits
|
||||
.map((u) => u.id)
|
||||
.sort()
|
||||
.join("-");
|
||||
bottomUnit = signals.createPersistedSignal({
|
||||
defaultValue: bottomUnits[0],
|
||||
storageKey: `${keyPrefix}-unit-${unitGroupKey}`,
|
||||
urlKey: "u2",
|
||||
serialize: (u) => u.id,
|
||||
deserialize: (s) =>
|
||||
bottomUnits.find((u) => u.id === s) ?? bottomUnits[0],
|
||||
});
|
||||
const field = createReactiveChoiceField({
|
||||
defaultValue: bottomUnits[0],
|
||||
choices: bottomUnits,
|
||||
toKey: (u) => u.id,
|
||||
toLabel: (u) => u.name,
|
||||
selected: bottomUnit,
|
||||
signals,
|
||||
sorted: true,
|
||||
type: "select",
|
||||
});
|
||||
chart.addFieldsetIfNeeded({
|
||||
id: "charts-unit-1",
|
||||
paneIndex: 1,
|
||||
position: "nw",
|
||||
createChild() {
|
||||
return field;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Clean up bottom pane when new option has no bottom series
|
||||
seriesListBottom.forEach((series) => series.remove());
|
||||
seriesListBottom.length = 0;
|
||||
chart.legends.bottom.removeFrom(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {Map<Unit, AnyFetchedSeriesBlueprint[]>} args.blueprints
|
||||
* @param {number} args.paneIndex
|
||||
* @param {Unit} args.unit
|
||||
* @param {IndexName} args.idx
|
||||
* @param {AnySeries[]} args.seriesList
|
||||
* @param {number} args.orderStart
|
||||
* @param {Legend} args.legend
|
||||
*/
|
||||
function createSeriesFromBlueprints({
|
||||
blueprints,
|
||||
paneIndex,
|
||||
unit,
|
||||
idx,
|
||||
seriesList,
|
||||
orderStart,
|
||||
legend,
|
||||
}) {
|
||||
legend.removeFrom(orderStart);
|
||||
seriesList.splice(orderStart).forEach((series) => series.remove());
|
||||
|
||||
blueprints.get(unit)?.forEach((blueprint, order) => {
|
||||
order += orderStart;
|
||||
const options = blueprint.options;
|
||||
const indexes = Object.keys(blueprint.metric.by);
|
||||
|
||||
if (indexes.includes(idx)) {
|
||||
switch (blueprint.type) {
|
||||
case "Baseline": {
|
||||
seriesList.push(
|
||||
chart.serieses.addBaseline({
|
||||
metric: blueprint.metric,
|
||||
name: blueprint.title,
|
||||
unit,
|
||||
defaultActive: blueprint.defaultActive,
|
||||
paneIndex,
|
||||
options: {
|
||||
...options,
|
||||
topLineColor:
|
||||
blueprint.color?.() ?? blueprint.colors?.[0](),
|
||||
bottomLineColor:
|
||||
blueprint.color?.() ?? blueprint.colors?.[1](),
|
||||
},
|
||||
order,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "Histogram": {
|
||||
seriesList.push(
|
||||
chart.serieses.addHistogram({
|
||||
metric: blueprint.metric,
|
||||
name: blueprint.title,
|
||||
unit,
|
||||
color: blueprint.color,
|
||||
defaultActive: blueprint.defaultActive,
|
||||
paneIndex,
|
||||
options,
|
||||
order,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "Candlestick": {
|
||||
seriesList.push(
|
||||
chart.serieses.addCandlestick({
|
||||
metric: blueprint.metric,
|
||||
name: blueprint.title,
|
||||
unit,
|
||||
colors: blueprint.colors,
|
||||
defaultActive: blueprint.defaultActive,
|
||||
paneIndex,
|
||||
options,
|
||||
order,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "Dots": {
|
||||
seriesList.push(
|
||||
chart.serieses.addDots({
|
||||
metric: blueprint.metric,
|
||||
color: blueprint.color,
|
||||
name: blueprint.title,
|
||||
unit,
|
||||
defaultActive: blueprint.defaultActive,
|
||||
paneIndex,
|
||||
options,
|
||||
order,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "Line":
|
||||
case undefined:
|
||||
seriesList.push(
|
||||
chart.serieses.addLine({
|
||||
metric: blueprint.metric,
|
||||
color: blueprint.color,
|
||||
name: blueprint.title,
|
||||
unit,
|
||||
defaultActive: blueprint.defaultActive,
|
||||
paneIndex,
|
||||
options,
|
||||
order,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Price series + top pane blueprints: react to topUnit and index changes
|
||||
signals.createScopedEffect(
|
||||
() => ({ unit: topUnit(), _: indexVersion() }),
|
||||
({ unit }) => {
|
||||
// Create price series BEFORE removing old one to prevent pane collapse
|
||||
/** @type {AnySeries | undefined} */
|
||||
let series;
|
||||
switch (unit) {
|
||||
case Unit.usd: {
|
||||
series = chart.serieses.addCandlestick({
|
||||
metric: brk.metrics.price.usd.ohlc,
|
||||
name: "Price",
|
||||
unit,
|
||||
order: 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case Unit.sats: {
|
||||
series = chart.serieses.addCandlestick({
|
||||
metric: brk.metrics.price.sats.ohlc,
|
||||
name: "Price",
|
||||
unit,
|
||||
inverse: true,
|
||||
order: 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!series) throw Error("Unreachable");
|
||||
|
||||
seriesListTop[0]?.remove();
|
||||
seriesListTop[0] = series;
|
||||
|
||||
// Live price update effect
|
||||
signals.createEffect(
|
||||
() => ({
|
||||
latest: webSockets.kraken1dCandle.latest(),
|
||||
hasData: series.hasData(),
|
||||
}),
|
||||
({ latest, hasData }) => {
|
||||
if (!series || !latest || !hasData) return;
|
||||
printLatest({ series, unit, index: chart.index.get() });
|
||||
},
|
||||
);
|
||||
|
||||
// Top pane blueprint series
|
||||
createSeriesFromBlueprints({
|
||||
blueprints: option.top,
|
||||
paneIndex: 0,
|
||||
unit,
|
||||
idx: chart.index.get(),
|
||||
seriesList: seriesListTop,
|
||||
orderStart: 1,
|
||||
legend: chart.legends.top,
|
||||
});
|
||||
},
|
||||
const last = /** @type {CandlestickData | undefined} */ (
|
||||
priceSeries.getData().at(-1)
|
||||
);
|
||||
if (!last) return;
|
||||
|
||||
// Bottom pane blueprints: react to bottomUnit and index changes
|
||||
if (bottomUnit) {
|
||||
signals.createScopedEffect(
|
||||
() => ({ unit: bottomUnit(), _: indexVersion() }),
|
||||
({ unit }) => {
|
||||
createSeriesFromBlueprints({
|
||||
blueprints: option.bottom,
|
||||
paneIndex: 1,
|
||||
unit,
|
||||
idx: chart.index.get(),
|
||||
seriesList: seriesListBottom,
|
||||
orderStart: 0,
|
||||
legend: chart.legends.bottom,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
// Convert to sats if needed
|
||||
const close =
|
||||
unit === Unit.sats
|
||||
? Math.floor(ONE_BTC_IN_SATS / latest.close)
|
||||
: latest.close;
|
||||
|
||||
priceSeries.update({ ...last, close });
|
||||
}
|
||||
|
||||
// When option changes, update heading and rebuild blueprints
|
||||
signals.createEffect(option, (opt) => {
|
||||
headingElement.innerHTML = opt.title;
|
||||
|
||||
blueprints = chart.setBlueprints({
|
||||
top: buildTopBlueprints(opt.top),
|
||||
bottom: opt.bottom,
|
||||
onDataLoaded: updatePriceWithLatest,
|
||||
});
|
||||
});
|
||||
|
||||
// Live price update listener
|
||||
signals.createEffect(
|
||||
() => webSockets.kraken1dCandle.latest(),
|
||||
updatePriceWithLatest,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -365,16 +365,22 @@ export function createReactiveChoiceField({
|
||||
* @param {(choice: T) => string} [args.toKey]
|
||||
* @param {(choice: T) => string} [args.toLabel]
|
||||
* @param {"radio" | "select"} [args.type]
|
||||
* @param {boolean} [args.sorted]
|
||||
*/
|
||||
export function createChoiceField({
|
||||
id,
|
||||
choices,
|
||||
choices: unsortedChoices,
|
||||
initialValue,
|
||||
onChange,
|
||||
sorted,
|
||||
toKey = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
|
||||
toLabel = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
|
||||
type = "radio",
|
||||
}) {
|
||||
const choices = sorted
|
||||
? unsortedChoices.toSorted((a, b) => toLabel(a).localeCompare(toLabel(b)))
|
||||
: unsortedChoices;
|
||||
|
||||
const field = window.document.createElement("div");
|
||||
field.classList.add("field");
|
||||
|
||||
@@ -387,7 +393,11 @@ export function createChoiceField({
|
||||
const fromKey = (key) =>
|
||||
choices.find((c) => toKey(c) === key) ?? initialValue;
|
||||
|
||||
if (type === "select") {
|
||||
if (choices.length === 1) {
|
||||
const span = window.document.createElement("span");
|
||||
span.textContent = toLabel(choices[0]);
|
||||
div.append(span);
|
||||
} else if (type === "select") {
|
||||
const select = window.document.createElement("select");
|
||||
select.id = id ?? "";
|
||||
select.name = id ?? "";
|
||||
@@ -407,6 +417,13 @@ export function createChoiceField({
|
||||
});
|
||||
|
||||
div.append(select);
|
||||
|
||||
const remaining = choices.length - 1;
|
||||
if (remaining > 0) {
|
||||
const small = window.document.createElement("small");
|
||||
small.textContent = ` +${remaining}`;
|
||||
field.append(small);
|
||||
}
|
||||
} else {
|
||||
const fieldId = id ?? "";
|
||||
choices.forEach((choice) => {
|
||||
|
||||
Reference in New Issue
Block a user