| import { currentVersion, migrate, type IonJSON, type Func, type SampleCounts } from "../src/iongraph.js"; |
| import { must } from "../src/utils.js"; |
| import { E } from "../src/dom.js"; |
| import { GraphViewer } from "../src/GraphViewer.js"; |
| |
| const searchParams = new URL(window.location.toString()).searchParams; |
| |
| const initialFuncIndex = searchParams.has("func") ? parseInt(searchParams.get("func")!, 10) : undefined; |
| const initialPass = searchParams.has("pass") ? parseInt(searchParams.get("pass")!, 10) : undefined; |
| |
| interface MenuBarProps { |
| browse?: boolean, |
| export?: boolean, |
| funcSelected: (func: Func | null) => void, |
| } |
| |
| class MenuBar { |
| root: HTMLElement; |
| funcSelector: HTMLElement; |
| funcSelectorNone: HTMLElement; |
| funcTier: HTMLElement; |
| funcOSR: HTMLElement; |
| funcName: HTMLElement; |
| exportButton: HTMLButtonElement | null; |
| |
| ionjson: IonJSON | null; |
| funcIndex: number; |
| funcSelected: (func: Func | null) => void; |
| |
| constructor(props: MenuBarProps) { |
| this.exportButton = null; |
| |
| this.ionjson = null; |
| this.funcIndex = initialFuncIndex ?? 0; |
| this.funcSelected = props.funcSelected; |
| |
| this.funcSelector = E("div", [], () => { }, [ |
| "Function", |
| E("input", ["ig-w3"], input => { |
| input.type = "number"; |
| input.min = "1"; |
| input.addEventListener("input", () => { |
| this.switchFunc(parseInt(input.value, 10) - 1); |
| }); |
| }, []), |
| " / ", |
| E("span", ["num-functions"]), |
| ]); |
| this.funcSelectorNone = E("div", [], () => { }, ["No functions to display."]); |
| this.funcTier = E("div", ["ig-tier-badge"]); |
| this.funcOSR = E("div", ["ig-osr-badge"], () => { }, ["OSR"]); |
| this.funcName = E("div"); |
| this.root = E("div", ["ig-bb", "ig-flex", "ig-bg-white"], () => { }, [ |
| E("div", ["ig-pv2", "ig-ph3", "ig-flex", "ig-g2", "ig-items-center", "ig-br", "ig-hide-if-empty"], () => { }, [ |
| props.browse && E("div", [], () => { }, [ |
| E("input", [], input => { |
| input.type = "file"; |
| input.addEventListener("change", e => { |
| const input = e.target as HTMLInputElement; |
| if (!input.files?.length) { |
| return; |
| } |
| this.fileSelected(input.files[0]); |
| }); |
| }), |
| ]), |
| this.funcSelector, |
| this.funcSelectorNone, |
| ]), |
| E("div", ["ig-flex-grow-1", "ig-pv2", "ig-ph3", "ig-flex", "ig-g2", "ig-items-center"], () => { }, [ |
| this.funcTier, |
| this.funcOSR, |
| this.funcName, |
| E("div", ["ig-flex-grow-1"]), |
| props.export && E("div", [], () => { }, [ |
| E("button", [], button => { |
| this.exportButton = button; |
| button.addEventListener("click", () => { |
| this.exportStandalone(); |
| }); |
| }, ["Export"]), |
| ]), |
| ]), |
| ]); |
| |
| this.update(); |
| } |
| |
| async fileSelected(file: File) { |
| const newJSON = JSON.parse(await file.text()); |
| this.ionjson = migrate(newJSON); |
| this.switchFunc(0); |
| this.update(); |
| } |
| |
| switchIonJSON(ionjson: IonJSON) { |
| this.ionjson = ionjson; |
| this.switchFunc(this.funcIndex); |
| } |
| |
| switchFunc(funcIndex: number) { |
| funcIndex = Math.max(0, Math.min(this.numFunctions() - 1, funcIndex)); |
| this.funcIndex = isNaN(funcIndex) ? 0 : funcIndex; |
| this.funcSelected(this.ionjson?.functions[this.funcIndex] ?? null); |
| this.update(); |
| } |
| |
| numFunctions() { |
| return this.ionjson?.functions.length ?? 0; |
| } |
| |
| update() { |
| const funcIndexValid = 0 <= this.funcIndex && this.funcIndex < this.numFunctions(); |
| |
| this.funcSelector.hidden = this.numFunctions() <= 1; |
| this.funcSelectorNone.hidden = !(this.ionjson && this.numFunctions() === 0); |
| |
| const funcInput = this.funcSelector.querySelector("input")!; |
| funcInput.max = `${this.numFunctions()}`; |
| funcInput.value = `${this.funcIndex + 1}`; |
| this.funcSelector.querySelector(".num-functions")!.innerHTML = `${this.numFunctions()}`; |
| |
| this.funcName.hidden = !funcIndexValid; |
| this.funcName.innerText = `${this.ionjson?.functions[this.funcIndex].name ?? ""}`; |
| |
| const func = funcIndexValid ? this.ionjson?.functions[this.funcIndex] : null; |
| const tier = func?.tier ?? null; |
| this.funcTier.hidden = !tier; |
| this.funcTier.innerText = tier ?? ""; |
| this.funcTier.dataset.tier = tier ?? ""; |
| this.funcOSR.hidden = !(func?.osr); |
| |
| if (this.exportButton) { |
| this.exportButton.disabled = !this.ionjson || !funcIndexValid; |
| } |
| } |
| |
| async exportStandalone() { |
| const ion = must(this.ionjson); |
| const func = ion.functions[this.funcIndex]; |
| const name = func.name; |
| const tierSuffix = func.tier ? (func.osr ? `-${func.tier}-OSR` : `-${func.tier}`) : ""; |
| const result: IonJSON = { version: currentVersion, functions: [func] }; |
| |
| // Use embedded template if available (file:// mode), otherwise fetch |
| const template = (window as any).__standaloneTemplate |
| ?? await (await fetch("./standalone.html")).text(); |
| const output = template.replace(/\{\{\s*IONJSON\s*\}\}/, JSON.stringify(result)); |
| const url = URL.createObjectURL(new Blob([output], { type: "text/html;charset=utf-8" })); |
| const a = document.createElement("a"); |
| a.href = url; |
| a.download = `iongraph-${name}${tierSuffix}.html`; |
| document.body.appendChild(a); |
| a.click(); |
| a.remove(); |
| URL.revokeObjectURL(url); |
| } |
| } |
| |
| export class WebUI { |
| root: HTMLElement; |
| menuBar: MenuBar; |
| graphContainer: HTMLElement; |
| |
| func: Func | null; |
| sampleCountsFromFile: SampleCounts | undefined; |
| graph: GraphViewer | null; |
| |
| constructor() { |
| this.menuBar = new MenuBar({ |
| browse: true, |
| export: true, |
| funcSelected: f => this.switchFunc(f), |
| }); |
| |
| this.func = null; |
| this.sampleCountsFromFile = undefined; |
| this.graph = null; |
| |
| this.loadStuffFromQueryParams(); |
| |
| this.graphContainer = E("div", ["ig-relative", "ig-flex-basis-0", "ig-flex-grow-1", "ig-overflow-hidden"]); |
| this.root = E("div", ["ig-absolute", "ig-absolute-fill", "ig-flex", "ig-flex-column"], root => { |
| root.addEventListener("dragenter", e => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| }); |
| |
| root.addEventListener("dragover", e => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| }); |
| |
| root.addEventListener("drop", e => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| |
| const files = e.dataTransfer?.files; |
| if (files && files.length > 0) { |
| this.menuBar.fileSelected(files[0]); |
| } |
| }); |
| }, [ |
| this.menuBar.root, |
| this.graphContainer, |
| ]); |
| |
| this.update(); |
| } |
| |
| update() { |
| if (this.graph) { |
| this.graph.destroy(); |
| } |
| if (this.func) { |
| this.graph = new GraphViewer(this.graphContainer, { |
| func: this.func, |
| pass: initialPass, |
| sampleCounts: this.sampleCountsFromFile, |
| }); |
| } |
| } |
| |
| loadStuffFromQueryParams() { |
| (async () => { |
| const searchFile = searchParams.get("file"); |
| if (searchFile) { |
| const res = await fetch(searchFile); |
| const json = await res.json(); |
| |
| const migrated = migrate(json); |
| this.menuBar.switchIonJSON(migrated); // will call funcSelected |
| } |
| })(); |
| (async () => { |
| const sampleCountsFile = searchParams.get("sampleCounts"); |
| if (sampleCountsFile) { |
| const res = await fetch(sampleCountsFile); |
| const json = await res.json(); |
| this.sampleCountsFromFile = { |
| selfLineHits: new Map(json["selfLineHits"]), |
| totalLineHits: new Map(json["totalLineHits"]), |
| }; |
| this.update(); |
| } |
| })(); |
| } |
| |
| switchFunc(func: Func | null) { |
| this.func = func; |
| this.update(); |
| } |
| } |
| |
| export class StandaloneUI { |
| root: HTMLElement; |
| menuBar: MenuBar; |
| graphContainer: HTMLElement; |
| |
| func: Func | null; |
| graph: GraphViewer | null; |
| |
| constructor() { |
| this.menuBar = new MenuBar({ |
| funcSelected: f => this.switchFunc(f), |
| }); |
| |
| this.func = null; |
| this.graph = null; |
| |
| this.graphContainer = E("div", ["ig-relative", "ig-flex-basis-0", "ig-flex-grow-1", "ig-overflow-hidden"]); |
| this.root = E("div", ["ig-absolute", "ig-absolute-fill", "ig-flex", "ig-flex-column"], () => { }, [ |
| this.menuBar.root, |
| this.graphContainer, |
| ]); |
| } |
| |
| update() { |
| if (this.graph) { |
| this.graph.destroy(); |
| } |
| if (this.func) { |
| this.graph = new GraphViewer(this.graphContainer, { |
| func: this.func, |
| pass: initialPass, |
| }); |
| } |
| } |
| |
| setIonJSON(ion: IonJSON) { |
| this.menuBar.switchIonJSON(ion) |
| } |
| |
| switchFunc(func: Func | null) { |
| this.func = func; |
| this.update(); |
| } |
| } |