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 { collect, markUsed, logUnused } from "./unused.js"; import { setQr } from "../panes/share.js"; /** * @param {BrkClient} brk */ export function initOptions(brk) { collect(brk.metrics); const LS_SELECTED_KEY = `selected_path`; const urlPath_ = window.document.location.pathname .split("/") .filter((v) => v); const urlPath = urlPath_.length ? urlPath_ : undefined; const savedPath = /** @type {string[]} */ ( JSON.parse(readStored(LS_SELECTED_KEY) || "[]") || [] ).filter((v) => v); console.log(savedPath); const partialOptions = createPartialOptions({ brk, }); /** @type {Option[]} */ const list = []; /** @type {Map} */ const liByPath = new Map(); /** @type {Set<(option: Option) => void>} */ const selectedListeners = new Set(); /** * @param {Option | undefined} sel */ function updateHighlight(sel) { if (!sel) return; liByPath.forEach((li) => { delete li.dataset.highlight; }); for (let i = 1; i <= sel.path.length; i++) { const pathKey = sel.path.slice(0, i).join("/"); const li = liByPath.get(pathKey); if (li) li.dataset.highlight = ""; } } 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]) ); } /** * @param {AnyFetchedSeriesBlueprint[]} [arr] */ function arrayToMap(arr = []) { /** @type {Map} */ const map = new Map(); for (const blueprint of arr || []) { if (!blueprint.metric) { throw new Error( `Blueprint missing metric: ${JSON.stringify(blueprint)}`, ); } if (!blueprint.unit) { throw new Error(`Blueprint missing unit: ${blueprint.title}`); } markUsed(blueprint.metric); const unit = blueprint.unit; if (!map.has(unit)) { map.set(unit, []); } map.get(unit)?.push(blueprint); } 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[]; count: number; children: ProcessedNode[] }} ProcessedGroup * @typedef {{ type: "option"; option: Option; path: string[] }} ProcessedOption * @typedef {ProcessedGroup | ProcessedOption} ProcessedNode */ /** * @param {PartialOptionsTree} partialTree * @param {string[]} parentPath * @returns {ProcessedNode[]} */ function processPartialTree(partialTree, parentPath = []) { /** @type {ProcessedNode[]} */ const nodes = []; for (const anyPartial of partialTree) { if ("tree" in anyPartial) { const serName = stringToId(anyPartial.name); const path = [...parentPath, serName]; const children = processPartialTree(anyPartial.tree, path); // Compute count from children const count = children.reduce( (sum, child) => sum + (child.type === "group" ? child.count : 1), 0, ); // Skip groups with no children if (count === 0) continue; nodes.push({ type: "group", name: anyPartial.name, serName, path, count, children, }); } else { const option = /** @type {Option} */ (anyPartial); const name = option.name; const path = [...parentPath, stringToId(option.name)]; // Transform partial to full option if ("kind" in anyPartial && anyPartial.kind === "explorer") { Object.assign( option, /** @satisfies {ExplorerOption} */ ({ kind: anyPartial.kind, path, name, title: option.title, }), ); } else if ("kind" in anyPartial && anyPartial.kind === "table") { Object.assign( option, /** @satisfies {TableOption} */ ({ kind: anyPartial.kind, path, name, title: option.title, }), ); } else if ("kind" in anyPartial && anyPartial.kind === "simulation") { Object.assign( option, /** @satisfies {SimulationOption} */ ({ kind: anyPartial.kind, path, name, title: anyPartial.title, }), ); } 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 || option.name; Object.assign( option, /** @satisfies {ChartOption} */ ({ kind: "chart", name, title, path, top: arrayToMap(anyPartial.top), bottom: arrayToMap(anyPartial.bottom), }), ); } list.push(option); // Check if this matches URL or saved path if (urlPath) { const sameAsURLPath = urlPath.length === path.length && urlPath.every((val, i) => val === path[i]); if (sameAsURLPath) { selected.set(option); } } else if (savedPath) { const sameAsSavedPath = savedPath.length === path.length && savedPath.every((val, i) => val === path[i]); if (sameAsSavedPath) { savedOption = option; } } nodes.push({ type: "option", option, path, }); } } return nodes; } const processedTree = processPartialTree(partialOptions); logUnused(); /** * @param {ProcessedNode[]} nodes * @param {HTMLElement} parentEl */ function buildTreeDOM(nodes, parentEl) { const ul = window.document.createElement("ul"); parentEl.append(ul); for (const node of nodes) { const li = window.document.createElement("li"); ul.append(li); const pathKey = node.path.join("/"); liByPath.set(pathKey, li); if (isOnSelectedPath(node.path)) { li.dataset.highlight = ""; } 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 supCount = window.document.createElement("sup"); supCount.innerHTML = node.count.toLocaleString("en-us"); summary.append(supCount); let built = false; details.addEventListener("toggle", () => { if (details.open && !built) { built = true; buildTreeDOM(node.children, details); } }); } else { const element = createOptionElement({ option: node.option, }); li.append(element); } } } /** @type {HTMLElement | null} */ let parentEl = null; /** * @param {HTMLElement} el */ function setParent(el) { if (parentEl) return; parentEl = el; buildTreeDOM(processedTree, el); } if (!selected.value) { const option = savedOption || list.find((option) => option.kind === "chart"); if (option) { selected.set(option); } } return { selected, list, tree: /** @type {OptionsTree} */ (partialOptions), setParent, createOptionElement, selectOption, }; } /** @typedef {ReturnType} Options */