website: snapshot

This commit is contained in:
nym21
2026-01-22 15:12:56 +01:00
parent bf13249003
commit b557477770
14 changed files with 221 additions and 2389 deletions

View File

@@ -1,37 +1,111 @@
import { ios, canShare } from "../utils/env.js";
import { domToBlob } from "../modules/modern-screenshot/4.6.7/dist/index.mjs";
import { style } from "../utils/elements.js";
import { colors } from "./colors.js";
export const canCapture = !ios || canShare;
/**
* @param {Object} args
* @param {Element} args.element
* @param {string} args.name
* @param {HTMLCanvasElement} args.screenshot
* @param {number} args.chartWidth
* @param {HTMLElement} args.parent
* @param {{ top: { element: HTMLElement }, bottom: { element: HTMLElement } }} args.legends
*/
export async function capture({ element, name }) {
const blob = await domToBlob(element, {
scale: 2,
});
export function capture({ screenshot, chartWidth, parent, legends }) {
const dpr = screenshot.width / chartWidth;
const pad = Math.round(16 * dpr);
const fontSize = Math.round(14 * dpr);
const titleFontSize = Math.round(20 * dpr);
const circleRadius = Math.round(5 * dpr);
const legendHeight = Math.round(28 * dpr);
const titleHeight = Math.round(36 * dpr);
if (ios) {
const file = new File(
[blob],
`bitview-${name}-${new Date().toJSON().split(".")[0]}.png`,
{ type: "image/png" },
);
const title = (parent.querySelector("h1")?.textContent ?? "").toUpperCase();
const hasTitle = title.length > 0;
const hasTopLegend = legends.top.element.children.length > 0;
const hasBottomLegend = legends.bottom.element.children.length > 0;
const titleOffset = hasTitle ? titleHeight : 0;
const topLegendOffset = hasTopLegend ? legendHeight : 0;
const bottomOffset = hasBottomLegend ? legendHeight : 0;
try {
await navigator.share({
files: [file],
title: `${name} on ${window.document.location.hostname}`,
});
return;
} catch (err) {
console.log(err);
const canvas = document.createElement("canvas");
canvas.width = screenshot.width + pad * 2;
canvas.height =
screenshot.height + pad * 2 + titleOffset + topLegendOffset + bottomOffset;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Background
const bodyBg = getComputedStyle(document.body).backgroundColor;
const htmlBg = getComputedStyle(document.documentElement).backgroundColor;
ctx.fillStyle = bodyBg === "rgba(0, 0, 0, 0)" ? htmlBg : bodyBg;
ctx.fillRect(0, 0, canvas.width, canvas.height);
/** @param {HTMLElement} legendEl @param {number} y */
const drawLegend = (legendEl, y) => {
ctx.font = `${fontSize}px ${style.fontFamily}`;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
let x = pad;
for (const div of legendEl.children) {
const label = div.querySelector("label");
if (!label) continue;
const input = label.querySelector("input");
if (input && !input.checked) continue;
// Draw color circles
const colorSpans = label.querySelectorAll(".colors span");
for (const span of colorSpans) {
ctx.fillStyle = /** @type {HTMLElement} */ (span).style.backgroundColor;
ctx.beginPath();
ctx.arc(x + circleRadius, y, circleRadius, 0, Math.PI * 2);
ctx.fill();
x += circleRadius * 2 + Math.round(2 * dpr);
}
// Draw name
const name = label.querySelector(".name")?.textContent ?? "";
ctx.fillStyle = colors.default();
ctx.fillText(name, x + Math.round(4 * dpr), y);
x += ctx.measureText(name).width + Math.round(20 * dpr);
}
};
// Title
if (hasTitle) {
ctx.font = `${titleFontSize}px ${style.fontFamily}`;
ctx.fillStyle = colors.default();
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(title, pad, pad + titleHeight / 2);
}
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
setTimeout(() => URL.revokeObjectURL(url), 100);
// Top legend
if (hasTopLegend) {
drawLegend(legends.top.element, pad + titleOffset + topLegendOffset / 2);
}
// Chart
ctx.drawImage(screenshot, pad, pad + titleOffset + topLegendOffset);
// Bottom legend
if (hasBottomLegend) {
drawLegend(
legends.bottom.element,
pad + titleOffset + topLegendOffset + screenshot.height + legendHeight / 2,
);
}
// Watermark
ctx.fillStyle = colors.gray();
ctx.font = `${fontSize}px ${style.fontFamily}`;
ctx.textAlign = "right";
ctx.textBaseline = "bottom";
ctx.fillText(window.location.host, canvas.width - pad, canvas.height - pad / 2);
// Open in new tab
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
setTimeout(() => URL.revokeObjectURL(url), 100);
}, "image/png");
}

View File

@@ -7,7 +7,7 @@ import {
// } from "../modules/lightweight-charts/5.1.0/dist/lightweight-charts.standalone.development.mjs";
} from "../modules/lightweight-charts/5.1.0/dist/lightweight-charts.standalone.production.mjs";
import { createLegend } from "./legend.js";
import { capture, canCapture } from "./capture.js";
import { capture } from "./capture.js";
import { colors } from "./colors.js";
import { createChoiceField } from "../utils/dom.js";
import { createPersistedValue } from "../utils/persisted.js";
@@ -76,14 +76,12 @@ const lineWidth = /** @type {any} */ (1.5);
* @param {HTMLElement} args.parent
* @param {BrkClient} args.brk
* @param {true} [args.fitContent]
* @param {HTMLElement} [args.captureElement]
*/
export function createChart({
parent,
id: chartId,
brk,
fitContent,
captureElement,
}) {
const baseUrl = brk.baseUrl.replace(/\/$/, "");
@@ -1513,33 +1511,19 @@ export function createChart({
},
};
if (captureElement && canCapture) {
const domain = document.createElement("p");
domain.innerText = window.location.host;
domain.id = "domain";
fieldsets.addIfNeeded({
id: "capture",
paneIndex: 0,
position: "ne",
createChild() {
const button = document.createElement("button");
button.id = "capture";
button.innerText = "capture";
button.title = "Capture chart as image";
button.addEventListener("click", async () => {
captureElement.dataset.screenshot = "true";
captureElement.append(domain);
try {
await capture({ element: captureElement, name: chartId });
} catch {}
captureElement.removeChild(domain);
captureElement.dataset.screenshot = "false";
});
return button;
},
const captureButton = document.createElement("button");
captureButton.className = "capture";
captureButton.innerText = "capture";
captureButton.title = "Capture chart as image";
captureButton.addEventListener("click", () => {
capture({
screenshot: ichart.takeScreenshot(),
chartWidth: elements.chart.clientWidth,
parent,
legends,
});
}
});
elements.chart.append(captureButton);
return chart;
}

View File

@@ -5,11 +5,7 @@ import signals from "./signals.js";
import { BrkClient } from "./modules/brk-client/index.js";
import { initOptions } from "./options/full.js";
import ufuzzy from "./modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.mjs";
import * as leanQr from "./modules/lean-qr/2.7.1/index.mjs";
import { init as initExplorer } from "./panes/_explorer.js";
import { init as initChart } from "./panes/chart.js";
import { init as initTable } from "./panes/table.js";
import { init as initSimulation } from "./panes/_simulation.js";
import { next } from "./utils/timing.js";
import { replaceHistory } from "./utils/url.js";
import { removeStored, writeToStorage } from "./utils/storage.js";
@@ -18,7 +14,6 @@ import {
asideLabelElement,
bodyElement,
chartElement,
explorerElement,
frameSelectorsElement,
mainElement,
navElement,
@@ -26,9 +21,7 @@ import {
searchElement,
searchInput,
searchResultsElement,
simulationElement,
style,
tableElement,
} from "./utils/elements.js";
function initFrameSelectors() {
@@ -120,13 +113,9 @@ signals.createRoot(() => {
console.log(`VERSION = ${brk.VERSION}`);
const qrcode = signals.createSignal(/** @type {string | null} */ (null));
signals.createEffect(webSockets.kraken1dCandle.latest, (latest) => {
if (latest) {
console.log("close:", latest.close);
window.document.title = `${latest.close.toLocaleString("en-us")} | ${window.location.host}`;
}
webSockets.kraken1dCandle.onLatest((latest) => {
console.log("close:", latest.close);
window.document.title = `${latest.close.toLocaleString("en-us")} | ${window.location.host}`;
});
// function createLastHeightResource() {
@@ -149,7 +138,6 @@ signals.createRoot(() => {
const options = initOptions({
signals,
brk,
qrcode,
});
window.addEventListener("popstate", (_event) => {
@@ -185,31 +173,15 @@ signals.createRoot(() => {
const chartOption = signals.createSignal(
/** @type {ChartOption | null} */ (null),
);
const simOption = signals.createSignal(
/** @type {SimulationOption | null} */ (null),
);
let previousElement = /** @type {HTMLElement | undefined} */ (undefined);
let firstTimeLoadingChart = true;
let firstTimeLoadingTable = true;
let firstTimeLoadingSimulation = true;
let firstTimeLoadingExplorer = true;
signals.createScopedEffect(options.selected, (option) => {
/** @type {HTMLElement} */
/** @type {HTMLElement | undefined} */
let element;
switch (option.kind) {
case "explorer": {
element = explorerElement;
if (firstTimeLoadingExplorer) {
signals.runWithOwner(owner, () => initExplorer());
}
firstTimeLoadingExplorer = false;
break;
}
case "chart": {
element = chartElement;
@@ -227,33 +199,13 @@ signals.createRoot(() => {
break;
}
case "table": {
element = tableElement;
if (firstTimeLoadingTable) {
signals.runWithOwner(owner, () => initTable());
}
firstTimeLoadingTable = false;
break;
}
case "simulation": {
element = simulationElement;
simOption.set(option);
if (firstTimeLoadingSimulation) {
signals.runWithOwner(owner, () => initSimulation());
}
firstTimeLoadingSimulation = false;
break;
}
case "url": {
case "link": {
return;
}
}
if (!element) throw "Element should be set";
if (element !== previousElement) {
if (previousElement) previousElement.hidden = true;
element.hidden = false;
@@ -505,7 +457,6 @@ signals.createRoot(() => {
const element = options.createOptionElement({
option,
name: title,
qrcode,
});
if (element) {
@@ -522,60 +473,6 @@ signals.createRoot(() => {
searchInput.addEventListener("input", inputEvent);
});
function initShare() {
const shareDiv = getElementById("share-div");
const shareContentDiv = getElementById("share-content-div");
const shareButton = getElementById("share-button");
shareButton.addEventListener("click", () => {
qrcode.set(window.location.href);
});
shareDiv.addEventListener("click", () => {
qrcode.set(null);
});
shareContentDiv.addEventListener("click", (event) => {
event.stopPropagation();
event.preventDefault();
});
signals.runWithOwner(owner, () => {
const imgQrcode = /** @type {HTMLImageElement} */ (
getElementById("share-img")
);
const anchor = /** @type {HTMLAnchorElement} */ (
getElementById("share-anchor")
);
signals.createEffect(qrcode, (qrcode) => {
if (!qrcode) {
shareDiv.hidden = true;
return;
}
const href = qrcode;
anchor.href = href;
anchor.innerText =
(href.startsWith("http")
? href.split("//").at(-1)
: href.split(":").at(-1)) || "";
imgQrcode.src =
leanQr.generate(/** @type {any} */ (href))?.toDataURL({
// @ts-ignore
padX: 0,
padY: 0,
}) || "";
shareDiv.hidden = false;
});
});
}
initShare();
function initDesktopResizeBar() {
const resizeBar = getElementById("resize-bar");
let resize = false;

View File

@@ -1,20 +1,17 @@
import { createPartialOptions } from "./partial.js";
import {
createButtonElement,
createAnchorElement,
} from "../utils/dom.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 {Object} args
* @param {Signals} args.signals
* @param {BrkClient} args.brk
* @param {Signal<string | null>} args.qrcode
*/
export function initOptions({ signals, brk, qrcode }) {
export function initOptions({ signals, brk }) {
collect(brk.metrics);
const LS_SELECTED_KEY = `selected_path`;
@@ -93,12 +90,11 @@ export function initOptions({ signals, brk, qrcode }) {
/**
* @param {Object} args
* @param {Option} args.option
* @param {Signal<string | null>} args.qrcode
* @param {string} [args.name]
*/
function createOptionElement({ option, name, qrcode }) {
function createOptionElement({ option, name }) {
const title = option.title;
if (option.kind === "url") {
if (option.kind === "link") {
const href = option.url();
if (option.qrcode) {
@@ -106,7 +102,7 @@ export function initOptions({ signals, brk, qrcode }) {
inside: option.name,
title,
onClick: () => {
qrcode.set(option.url);
setQr(option.url());
},
});
} else {
@@ -215,7 +211,7 @@ export function initOptions({ signals, brk, qrcode }) {
Object.assign(
option,
/** @satisfies {UrlOption} */ ({
kind: "url",
kind: "link",
path,
name,
title: name,
@@ -318,7 +314,6 @@ export function initOptions({ signals, brk, qrcode }) {
} else {
const element = createOptionElement({
option: node.option,
qrcode,
});
li.append(element);
}

View File

@@ -98,7 +98,7 @@
* @typedef {Required<PartialSimulationOption> & ProcessedOptionAddons} SimulationOption
*
* @typedef {Object} PartialUrlOptionSpecific
* @property {"url"} [kind]
* @property {"link"} [kind]
* @property {() => string} url
* @property {string} title
* @property {boolean} [qrcode]

View File

@@ -29,7 +29,6 @@ export function init({ option, brk }) {
parent: chartElement,
id: "charts",
brk,
captureElement: chartElement,
});
// Create index selector using chart's index state
@@ -104,10 +103,7 @@ export function init({ option, brk }) {
});
// Live price update listener
signals.createEffect(
() => webSockets.kraken1dCandle.latest(),
updatePriceWithLatest,
);
webSockets.kraken1dCandle.onLatest(updatePriceWithLatest);
}
/**
@@ -154,10 +150,6 @@ function createIndexSelector(option, chart) {
fieldset.id = "interval";
fieldset.dataset.size = "sm";
const screenshotSpan = window.document.createElement("span");
screenshotSpan.innerText = "interval:";
fieldset.append(screenshotSpan);
// Track user's preferred index (only updated on explicit selection)
let preferredIndex = chart.index.name.value;

View File

@@ -0,0 +1,45 @@
import { getElementById } from "../utils/dom.js";
import * as leanQr from "../modules/lean-qr/2.7.1/index.mjs";
const shareDiv = getElementById("share-div");
const shareContentDiv = getElementById("share-content-div");
const shareButton = getElementById("share-button");
const imgQrcode = /** @type {HTMLImageElement} */ (getElementById("share-img"));
const anchor = /** @type {HTMLAnchorElement} */ (
getElementById("share-anchor")
);
/** @param {string | null} url */
export function setQr(url) {
if (!url) {
shareDiv.hidden = true;
return;
}
anchor.href = url;
anchor.innerText =
(url.startsWith("http") ? url.split("//").at(-1) : url.split(":").at(-1)) ||
"";
imgQrcode.src =
leanQr.generate(/** @type {any} */ (url))?.toDataURL({
// @ts-ignore
padX: 0,
padY: 0,
}) || "";
shareDiv.hidden = false;
}
shareButton.addEventListener("click", () => {
setQr(window.location.href);
});
shareDiv.addEventListener("click", () => {
setQr(null);
});
shareContentDiv.addEventListener("click", (event) => {
event.stopPropagation();
event.preventDefault();
});

View File

@@ -212,149 +212,6 @@ export function importStyle(href) {
return link;
}
/**
* @template T
* @param {Object} args
* @param {T} args.defaultValue - Fallback when selected value is no longer in choices
* @param {string} [args.id]
* @param {readonly T[] | Accessor<readonly T[]>} args.choices
* @param {boolean} [args.sorted]
* @param {Signals} args.signals
* @param {Signal<T>} args.selected
* @param {(choice: T) => string} [args.toKey] - Extract string key (defaults to identity for strings)
* @param {(choice: T) => string} [args.toLabel] - Extract display label (defaults to identity for strings)
* @param {"radio" | "select"} [args.type] - Render as radio buttons or select dropdown
*/
export function createReactiveChoiceField({
id,
choices: unsortedChoices,
defaultValue,
signals,
selected,
sorted,
toKey = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
toLabel = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
type = /** @type {const} */ ("radio"),
}) {
const defaultKey = toKey(defaultValue);
const choices = signals.createMemo(() => {
/** @type {readonly T[]} */
let c;
if (typeof unsortedChoices === "function") {
c = unsortedChoices();
} else {
c = unsortedChoices;
}
return sorted
? /** @type {readonly T[]} */ (
/** @type {any} */ (
c.toSorted((a, b) => toLabel(a).localeCompare(toLabel(b)))
)
)
: c;
});
/** @param {string} key */
const fromKey = (key) =>
choices().find((c) => toKey(c) === key) ?? defaultValue;
const field = window.document.createElement("div");
field.classList.add("field");
const div = window.document.createElement("div");
field.append(div);
/** @type {HTMLElement | null} */
let remainingSmall = null;
if (type === "select") {
remainingSmall = window.document.createElement("small");
field.append(remainingSmall);
}
signals.createScopedEffect(choices, (choices) => {
const s = selected();
const sKey = toKey(s);
const keys = choices.map(toKey);
if (!keys.includes(sKey)) {
if (keys.includes(defaultKey)) {
selected.set(() => defaultValue);
} else if (choices.length) {
selected.set(() => choices[0]);
}
}
div.innerHTML = "";
if (choices.length === 1) {
const span = window.document.createElement("span");
span.textContent = toLabel(choices[0]);
div.append(span);
if (remainingSmall) {
remainingSmall.hidden = true;
}
} else if (type === "select") {
const select = window.document.createElement("select");
select.id = id ?? "";
select.name = id ?? "";
choices.forEach((choice) => {
const option = window.document.createElement("option");
option.value = toKey(choice);
option.textContent = toLabel(choice);
if (toKey(choice) === sKey) {
option.selected = true;
}
select.append(option);
});
select.addEventListener("change", () => {
selected.set(() => fromKey(select.value));
});
div.append(select);
if (remainingSmall) {
const remaining = choices.length - 1;
if (remaining > 0) {
remainingSmall.textContent = ` +${remaining}`;
remainingSmall.hidden = false;
} else {
remainingSmall.hidden = true;
}
}
} else {
const fieldId = id ?? "";
choices.forEach((choice) => {
const choiceKey = toKey(choice);
const choiceLabel = toLabel(choice);
const { label } = createLabeledInput({
inputId: `${fieldId}-${choiceKey.toLowerCase()}`,
inputName: fieldId,
inputValue: choiceKey,
inputChecked: choiceKey === sKey,
// title: choiceLabel,
type: "radio",
});
const text = window.document.createTextNode(choiceLabel);
label.append(text);
div.append(label);
});
field.addEventListener("change", (event) => {
// @ts-ignore
const value = event.target.value;
selected.set(() => fromKey(value));
});
}
});
return field;
}
/**
* @template T
* @param {Object} args

View File

@@ -1,14 +1,14 @@
import signals from "../signals.js";
/**
* @template T
* @param {(callback: (value: T) => void) => WebSocket} creator
*/
function createWebsocket(creator) {
let ws = /** @type {WebSocket | null} */ (null);
let _live = false;
let _latest = /** @type {T | null} */ (null);
const live = signals.createSignal(false);
const latest = signals.createSignal(/** @type {T | null} */ (null));
/** @type {Set<(value: T) => void>} */
const listeners = new Set();
function reinitWebSocket() {
if (!ws || ws.readyState === ws.CLOSED) {
@@ -22,19 +22,31 @@ function createWebsocket(creator) {
}
const resource = {
live,
latest,
live() {
return _live;
},
latest() {
return _latest;
},
/** @param {(value: T) => void} callback */
onLatest(callback) {
listeners.add(callback);
return () => listeners.delete(callback);
},
open() {
ws = creator((value) => latest.set(() => value));
ws = creator((value) => {
_latest = value;
listeners.forEach((cb) => cb(value));
});
ws.addEventListener("open", () => {
console.log("ws: open");
live.set(true);
_live = true;
});
ws.addEventListener("close", () => {
console.log("ws: close");
live.set(false);
_live = false;
});
window.document.addEventListener(
@@ -51,7 +63,7 @@ function createWebsocket(creator) {
reinitWebSocketIfDocumentNotHidden,
);
window.document.removeEventListener("online", reinitWebSocket);
live.set(false);
_live = false;
ws = null;
},
};