mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-04-25 07:09:59 -07:00
465 lines
12 KiB
JavaScript
465 lines
12 KiB
JavaScript
import { createPartialOptions } from "./partial.js";
|
|
import { createButtonElement, createAnchorElement } from "../utils/dom.js";
|
|
import { pushHistory, resetParams } from "../utils/url.js";
|
|
import { readStored, writeToStorage } from "../utils/storage.js";
|
|
import { stringToId } from "../utils/format.js";
|
|
import { logUnused } from "./unused.js";
|
|
import { setQr } from "../panes/share.js";
|
|
import { getConstant } from "./constants.js";
|
|
import { colors } from "../utils/colors.js";
|
|
import { Unit } from "../utils/units.js";
|
|
import { brk } from "../utils/client.js";
|
|
|
|
export function initOptions() {
|
|
const LS_SELECTED_KEY = `selected_path`;
|
|
|
|
const savedPath = /** @type {string[]} */ (
|
|
JSON.parse(readStored(LS_SELECTED_KEY) || "[]") || []
|
|
).filter((v) => v);
|
|
|
|
const partialOptions = createPartialOptions();
|
|
|
|
/** @type {Option[]} */
|
|
const list = [];
|
|
|
|
/** @type {Map<string, HTMLLIElement>} */
|
|
const liByPath = new Map();
|
|
|
|
/** @type {Set<(option: Option) => void>} */
|
|
const selectedListeners = new Set();
|
|
|
|
/** @type {HTMLLIElement[]} */
|
|
let highlightedLis = [];
|
|
|
|
/**
|
|
* @param {Option | undefined} sel
|
|
*/
|
|
function updateHighlight(sel) {
|
|
if (!sel) return;
|
|
for (const li of highlightedLis) {
|
|
delete li.dataset.highlight;
|
|
}
|
|
highlightedLis = [];
|
|
let pathKey = "";
|
|
for (const segment of sel.path) {
|
|
pathKey = pathKey ? `${pathKey}/${segment}` : segment;
|
|
const li = liByPath.get(pathKey);
|
|
if (li) {
|
|
li.dataset.highlight = "";
|
|
highlightedLis.push(li);
|
|
}
|
|
}
|
|
}
|
|
|
|
const selected = {
|
|
/** @type {Option | undefined} */
|
|
value: undefined,
|
|
/** @param {Option} v */
|
|
set(v) {
|
|
this.value = v;
|
|
updateHighlight(v);
|
|
selectedListeners.forEach((cb) => cb(v));
|
|
},
|
|
/** @param {(option: Option) => void} cb */
|
|
onChange(cb) {
|
|
selectedListeners.add(cb);
|
|
if (this.value) cb(this.value);
|
|
return () => selectedListeners.delete(cb);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @param {string[]} nodePath
|
|
*/
|
|
function isOnSelectedPath(nodePath) {
|
|
const selectedPath = selected.value?.path;
|
|
return (
|
|
selectedPath &&
|
|
nodePath.length <= selectedPath.length &&
|
|
nodePath.every((v, i) => v === selectedPath[i])
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @template T
|
|
* @param {() => T} fn
|
|
* @returns {() => T}
|
|
*/
|
|
function lazy(fn) {
|
|
/** @type {T | undefined} */
|
|
let cached;
|
|
let computed = false;
|
|
return () => {
|
|
if (!computed) {
|
|
computed = true;
|
|
cached = fn();
|
|
}
|
|
return /** @type {T} */ (cached);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {(AnyFetchedSeriesBlueprint | FetchedPriceSeriesBlueprint)[]} [arr]
|
|
*/
|
|
function arrayToMap(arr) {
|
|
/** @type {Map<Unit, AnyFetchedSeriesBlueprint[]>} */
|
|
const map = new Map();
|
|
/** @type {Map<Unit, Set<number>>} */
|
|
const priceLines = new Map();
|
|
|
|
if (!arr) return map;
|
|
|
|
// Cache arrays for common units outside loop
|
|
/** @type {AnyFetchedSeriesBlueprint[] | undefined} */
|
|
let usdArr;
|
|
/** @type {AnyFetchedSeriesBlueprint[] | undefined} */
|
|
let satsArr;
|
|
|
|
for (let i = 0; i < arr.length; i++) {
|
|
const blueprint = arr[i];
|
|
|
|
// Check for undefined series
|
|
if (!blueprint.series) {
|
|
throw new Error(`Blueprint has undefined series: ${blueprint.title}`);
|
|
}
|
|
|
|
// Check for price pattern blueprint (has usd/sats sub-series)
|
|
// Use unknown cast for safe property access check
|
|
const maybePriceSeries =
|
|
/** @type {{ usd?: AnySeriesPattern, sats?: AnySeriesPattern }} */ (
|
|
/** @type {unknown} */ (blueprint.series)
|
|
);
|
|
if (maybePriceSeries.usd?.by && maybePriceSeries.sats?.by) {
|
|
const { usd, sats } = maybePriceSeries;
|
|
if (!usdArr) map.set(Unit.usd, (usdArr = []));
|
|
usdArr.push({ ...blueprint, series: usd, unit: Unit.usd });
|
|
|
|
if (!satsArr) map.set(Unit.sats, (satsArr = []));
|
|
satsArr.push({ ...blueprint, series: sats, unit: Unit.sats });
|
|
continue;
|
|
}
|
|
|
|
// After continue, we know this is a regular series blueprint
|
|
const regularBlueprint = /** @type {AnyFetchedSeriesBlueprint} */ (
|
|
blueprint
|
|
);
|
|
const s = regularBlueprint.series;
|
|
const unit = regularBlueprint.unit;
|
|
if (!unit) continue;
|
|
|
|
let unitArr = map.get(unit);
|
|
if (!unitArr) map.set(unit, (unitArr = []));
|
|
unitArr.push(regularBlueprint);
|
|
|
|
// Track baseline base values for auto price lines
|
|
const type = regularBlueprint.type;
|
|
if (type === "Baseline") {
|
|
let priceSet = priceLines.get(unit);
|
|
if (!priceSet) priceLines.set(unit, (priceSet = new Set()));
|
|
priceSet.add(regularBlueprint.options?.baseValue?.price ?? 0);
|
|
} else if (!type || type === "Line") {
|
|
// Check if manual price line - avoid Object.values() array allocation
|
|
const by = s.by;
|
|
for (const k in by) {
|
|
if (by[/** @type {Index} */ (k)]?.path?.includes("constant_")) {
|
|
priceLines.get(unit)?.delete(parseFloat(regularBlueprint.title));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add price lines at end for remaining values
|
|
for (const [unit, values] of priceLines) {
|
|
const arr = map.get(unit);
|
|
if (!arr) continue;
|
|
for (const baseValue of values) {
|
|
const s = getConstant(brk.series.constants, baseValue);
|
|
arr.push({
|
|
series: s,
|
|
title: `${baseValue}`,
|
|
color: colors.gray,
|
|
unit,
|
|
options: {
|
|
lineStyle: 4,
|
|
lastValueVisible: false,
|
|
crosshairMarkerVisible: false,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* @param {Option} option
|
|
*/
|
|
function selectOption(option) {
|
|
if (selected.value === option) return;
|
|
pushHistory(option.path);
|
|
resetParams(option);
|
|
writeToStorage(LS_SELECTED_KEY, JSON.stringify(option.path));
|
|
selected.set(option);
|
|
}
|
|
|
|
/**
|
|
* @param {Object} args
|
|
* @param {Option} args.option
|
|
* @param {string} [args.name]
|
|
*/
|
|
function createOptionElement({ option, name }) {
|
|
const title = option.title;
|
|
if (option.kind === "link") {
|
|
const href = option.url();
|
|
|
|
if (option.qrcode) {
|
|
return createButtonElement({
|
|
inside: option.name,
|
|
title,
|
|
onClick: () => {
|
|
setQr(option.url());
|
|
},
|
|
});
|
|
} else {
|
|
return createAnchorElement({
|
|
href,
|
|
blank: true,
|
|
text: option.name,
|
|
title,
|
|
});
|
|
}
|
|
} else {
|
|
return createAnchorElement({
|
|
href: `/${option.path.join("/")}`,
|
|
title,
|
|
text: name || option.name,
|
|
onClick: () => {
|
|
selectOption(option);
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
/** @type {Option | undefined} */
|
|
let savedOption;
|
|
|
|
/**
|
|
* @typedef {{ type: "group"; name: string; serName: string; path: string[]; pathKey: string; count: number; children: ProcessedNode[] }} ProcessedGroup
|
|
* @typedef {{ type: "option"; option: Option; path: string[]; pathKey: string }} ProcessedOption
|
|
* @typedef {ProcessedGroup | ProcessedOption} ProcessedNode
|
|
*/
|
|
|
|
const savedPathStr = savedPath?.join("/");
|
|
|
|
/**
|
|
* @param {PartialOptionsTree} partialTree
|
|
* @param {string[]} parentPath
|
|
* @param {string} parentPathStr
|
|
* @returns {{ nodes: ProcessedNode[], count: number }}
|
|
*/
|
|
function processPartialTree(
|
|
partialTree,
|
|
parentPath = [],
|
|
parentPathStr = "",
|
|
) {
|
|
/** @type {ProcessedNode[]} */
|
|
const nodes = [];
|
|
let totalCount = 0;
|
|
|
|
for (let i = 0; i < partialTree.length; i++) {
|
|
const anyPartial = partialTree[i];
|
|
if ("tree" in anyPartial) {
|
|
const serName = stringToId(anyPartial.name);
|
|
const pathStr = parentPathStr ? `${parentPathStr}/${serName}` : serName;
|
|
const path = parentPath.concat(serName);
|
|
const { nodes: children, count } = processPartialTree(
|
|
anyPartial.tree,
|
|
path,
|
|
pathStr,
|
|
);
|
|
|
|
// Skip groups with no children
|
|
if (count === 0) continue;
|
|
|
|
totalCount += count;
|
|
nodes.push({
|
|
type: "group",
|
|
name: anyPartial.name,
|
|
serName,
|
|
path,
|
|
pathKey: pathStr,
|
|
count,
|
|
children,
|
|
});
|
|
} else {
|
|
const option = /** @type {Option} */ (anyPartial);
|
|
const name = option.name;
|
|
const serName = stringToId(name);
|
|
const pathStr = parentPathStr ? `${parentPathStr}/${serName}` : serName;
|
|
const path = parentPath.concat(serName);
|
|
|
|
// Transform partial to full option
|
|
if ("kind" in anyPartial && anyPartial.kind === "explorer") {
|
|
option.kind = anyPartial.kind;
|
|
option.path = [];
|
|
option.name = name;
|
|
} else if ("url" in anyPartial) {
|
|
Object.assign(
|
|
option,
|
|
/** @satisfies {UrlOption} */ ({
|
|
kind: "link",
|
|
path,
|
|
name,
|
|
title: name,
|
|
qrcode: !!anyPartial.qrcode,
|
|
url: anyPartial.url,
|
|
}),
|
|
);
|
|
} else {
|
|
const title = option.title || name;
|
|
const topArr = anyPartial.top;
|
|
const bottomArr = anyPartial.bottom;
|
|
const topFn = lazy(() => arrayToMap(topArr));
|
|
const bottomFn = lazy(() => arrayToMap(bottomArr));
|
|
Object.assign(
|
|
option,
|
|
/** @satisfies {ChartOption} */ ({
|
|
kind: "chart",
|
|
name,
|
|
title,
|
|
path,
|
|
top: topFn,
|
|
bottom: bottomFn,
|
|
}),
|
|
);
|
|
}
|
|
|
|
list.push(option);
|
|
totalCount++;
|
|
|
|
if (savedPathStr && pathStr === savedPathStr) {
|
|
savedOption = option;
|
|
}
|
|
|
|
nodes.push({
|
|
type: "option",
|
|
option,
|
|
path,
|
|
pathKey: pathStr,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { nodes, count: totalCount };
|
|
}
|
|
|
|
logUnused(brk.series, partialOptions);
|
|
const { nodes: processedTree } = processPartialTree(partialOptions);
|
|
|
|
/**
|
|
* @param {ProcessedNode[]} nodes
|
|
* @param {HTMLElement} parentEl
|
|
*/
|
|
function buildTreeDOM(nodes, parentEl) {
|
|
const ul = window.document.createElement("ul");
|
|
|
|
for (const node of nodes) {
|
|
const li = window.document.createElement("li");
|
|
ul.append(li);
|
|
|
|
liByPath.set(node.pathKey, li);
|
|
|
|
const onSelectedPath = isOnSelectedPath(node.path);
|
|
|
|
if (node.type === "group") {
|
|
const details = window.document.createElement("details");
|
|
details.dataset.name = node.serName;
|
|
li.appendChild(details);
|
|
|
|
const summary = window.document.createElement("summary");
|
|
details.append(summary);
|
|
summary.append(node.name);
|
|
|
|
const count = window.document.createElement("small");
|
|
count.textContent = `(${node.count.toLocaleString("en-us")})`;
|
|
summary.append(count);
|
|
|
|
let built = false;
|
|
if (onSelectedPath) {
|
|
built = true;
|
|
details.open = true;
|
|
buildTreeDOM(node.children, details);
|
|
}
|
|
details.addEventListener("toggle", () => {
|
|
if (details.open && !built) {
|
|
built = true;
|
|
buildTreeDOM(node.children, details);
|
|
}
|
|
});
|
|
} else {
|
|
const element = createOptionElement({
|
|
option: node.option,
|
|
});
|
|
li.append(element);
|
|
}
|
|
}
|
|
|
|
parentEl.append(ul);
|
|
}
|
|
|
|
/** @type {HTMLElement | null} */
|
|
let parentEl = null;
|
|
|
|
/**
|
|
* @param {HTMLElement} el
|
|
*/
|
|
function setParent(el) {
|
|
if (parentEl) return;
|
|
parentEl = el;
|
|
buildTreeDOM(processedTree, el);
|
|
updateHighlight(selected.value);
|
|
}
|
|
|
|
const tree = /** @type {OptionsTree} */ (partialOptions);
|
|
|
|
function resolveUrl() {
|
|
const segments = window.location.pathname.split("/").filter((v) => v);
|
|
let folder = tree;
|
|
for (let i = 0; i < segments.length; i++) {
|
|
const match = folder.find((v) => segments[i] === stringToId(v.name));
|
|
if (!match) break;
|
|
if (i < segments.length - 1) {
|
|
if (!("tree" in match)) break;
|
|
folder = match.tree;
|
|
} else if (!("tree" in match)) {
|
|
selected.set(match);
|
|
return;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
selected.set(!segments.length && savedOption ? savedOption : list[0]);
|
|
}
|
|
|
|
resolveUrl();
|
|
|
|
if (!selected.value) {
|
|
const option = savedOption || list[0];
|
|
if (option) {
|
|
selected.set(option);
|
|
}
|
|
}
|
|
|
|
return {
|
|
selected,
|
|
list,
|
|
tree,
|
|
setParent,
|
|
createOptionElement,
|
|
selectOption,
|
|
resolveUrl,
|
|
};
|
|
}
|
|
/** @typedef {ReturnType<typeof initOptions>} Options */
|