global: snapshot

This commit is contained in:
nym21
2026-01-20 15:04:00 +01:00
parent 486871379c
commit 9613fce919
53 changed files with 1811 additions and 4081 deletions

View File

@@ -1,5 +1,6 @@
import {
createChart as _createChart,
createSeriesMarkers,
CandlestickSeries,
HistogramSeries,
LineSeries,
@@ -35,15 +36,15 @@ import { resources } from "../resources.js";
* @template T
* @typedef {Object} Series
* @property {string} id
* @property {() => ISeries} inner
* @property {number} paneIndex
* @property {Signal<boolean>} active
* @property {Signal<boolean>} highlighted
* @property {Signal<boolean>} hasData
* @property {Signal<string | null>} url
* @property {() => Record<string, any>} getOptions
* @property {(options: Record<string, any>) => void} applyOptions
* @property {() => readonly T[]} getData
* @property {(data: T) => void} update
* @property {(markers: TimeSeriesMarker[]) => void} setMarkers
* @property {VoidFunction} clearMarkers
* @property {VoidFunction} remove
*/
@@ -97,6 +98,10 @@ export function createChartElement({
div.classList.add("chart");
parent.append(div);
// Registry for shared legend signals (same name = linked across panes)
/** @type {Map<string, Signal<boolean>>} */
const sharedActiveSignals = new Map();
const legendTop = createLegend(signals);
div.append(legendTop.element);
@@ -165,7 +170,10 @@ export function createChartElement({
});
};
const seriesList = signals.createSignal(/** @type {Set<AnySeries>} */ (new Set()), { equals: false });
const seriesList = signals.createSignal(
/** @type {Set<AnySeries>} */ (new Set()),
{ equals: false },
);
const seriesCount = signals.createMemo(() => seriesList().size);
const markers = createMinMaxMarkers({
chart: ichart,
@@ -186,7 +194,7 @@ export function createChartElement({
() => visibleBarsCountBucket() >= 2,
);
const shouldUpdateMarkers = signals.createMemo(
() => visibleBarsCount() * seriesCount() <= 5000,
() => visibleBarsCount() * seriesCount() <= 20_000,
);
signals.createEffect(shouldUpdateMarkers, (should) => {
@@ -337,12 +345,20 @@ export function createChartElement({
paneIndex,
position: "sw",
createChild(pane) {
const { field, selected } = createChoiceField({
const defaultValue =
unit.id === "usd" && seriesType !== "Baseline" ? "log" : "lin";
const selected = signals.createPersistedSignal({
defaultValue,
storageKey: `${id}-scale-${paneIndex}`,
urlKey: paneIndex === 0 ? "price_scale" : "unit_scale",
serialize: (v) => v,
deserialize: (s) => /** @type {"lin" | "log"} */ (s),
});
const field = createChoiceField({
choices: /** @type {const} */ (["lin", "log"]),
id: stringToId(`${id} ${paneIndex} ${unit}`),
defaultValue:
unit.id === "usd" && seriesType !== "Baseline" ? "log" : "lin",
key: `${id}-price-scale-${paneIndex}`,
defaultValue,
selected,
signals,
});
@@ -366,21 +382,19 @@ export function createChartElement({
* @param {number} args.order
* @param {Color[]} args.colors
* @param {LCSeriesType} args.seriesType
* @param {() => ISeries} args.inner
* @param {AnyMetricPattern} [args.metric]
* @param {Accessor<WhitespaceData[]>} [args.data]
* @param {number} args.paneIndex
* @param {boolean} [args.defaultActive]
* @param {(ctx: { active: Signal<boolean> }) => void} args.setup
* @param {(ctx: { active: Signal<boolean>, highlighted: Signal<boolean> }) => void} args.setup
* @param {() => readonly any[]} args.getData
* @param {(data: any[]) => void} args.setData
* @param {(data: any) => void} args.update
* @param {() => Record<string, any>} args.getOptions
* @param {(options: Record<string, any>) => void} args.applyOptions
* @param {(markers: TimeSeriesMarker[]) => void} args.setMarkers
* @param {VoidFunction} args.clearMarkers
* @param {() => void} args.onRemove
*/
function addSeries({
inner,
metric,
name,
unit,
@@ -394,22 +408,34 @@ export function createChartElement({
getData,
setData,
update,
getOptions,
applyOptions,
setMarkers,
clearMarkers,
onRemove,
}) {
return signals.createRoot((dispose) => {
const id = `${stringToId(name)}-${paneIndex}`;
const urlId = stringToId(name);
const active = signals.createSignal(defaultActive ?? true, {
save: {
keyPrefix: "",
key: id,
// Reuse existing signal if same name (links legends across panes)
let active = sharedActiveSignals.get(urlId);
if (!active) {
active = signals.createPersistedSignal({
defaultValue: defaultActive ?? true,
storageKey: id,
urlKey: urlId,
...serdeBool,
},
});
});
sharedActiveSignals.set(urlId, active);
}
setup({ active });
const highlighted = signals.createSignal(true);
setup({ active, highlighted });
// Update markers when active changes
signals.createEffect(active, () => {
if (shouldUpdateMarkers()) markers.scheduleUpdate();
});
const hasData = signals.createSignal(false);
let lastTime = -Infinity;
@@ -420,15 +446,15 @@ export function createChartElement({
/** @type {AnySeries} */
const series = {
active,
highlighted,
hasData,
id,
inner,
paneIndex,
url: signals.createSignal(/** @type {string | null} */ (null)),
getOptions,
applyOptions,
getData,
update,
setMarkers,
clearMarkers,
remove() {
dispose();
onRemove();
@@ -705,8 +731,10 @@ export function createChartElement({
)
);
// Marker plugin always on candlestick (has true min/max via high/low)
const markerPlugin = createSeriesMarkers(candlestickISeries, [], { autoScale: false });
const series = addSeries({
inner: () => (showLine ? lineISeries : candlestickISeries),
colors: [upColor, downColor],
name,
order,
@@ -716,22 +744,34 @@ export function createChartElement({
data,
defaultActive,
metric,
setup: ({ active }) => {
setup: ({ active, highlighted }) => {
candlestickISeries.setSeriesOrder(order);
lineISeries.setSeriesOrder(order);
signals.createEffect(
() => ({
shouldShow: shouldShowLine(),
active: active(),
highlighted: highlighted(),
barsCount: visibleBarsCount(),
}),
({ shouldShow, active, barsCount }) => {
({ shouldShow, active, highlighted, barsCount }) => {
if (barsCount === Infinity) return;
const wasLine = showLine;
showLine = shouldShow;
candlestickISeries.applyOptions({ visible: active && !showLine });
// Use transparent when showing the other mode, otherwise use highlight
const up = showLine ? "transparent" : upColor.highlight(highlighted);
const down = showLine ? "transparent" : downColor.highlight(highlighted);
const line = showLine ? colors.default.highlight(highlighted) : "transparent";
candlestickISeries.applyOptions({
visible: active,
upColor: up,
downColor: down,
wickUpColor: up,
wickDownColor: down,
});
lineISeries.applyOptions({
visible: active && showLine,
visible: active,
color: line,
priceLineVisible: active && showLine,
});
if (wasLine !== showLine && shouldUpdateMarkers())
@@ -749,12 +789,8 @@ export function createChartElement({
lineISeries.update({ time: data.time, value: data.close });
},
getData: () => candlestickISeries.data(),
getOptions: () =>
showLine ? lineISeries.options() : candlestickISeries.options(),
applyOptions: (options) =>
showLine
? lineISeries.applyOptions(options)
: candlestickISeries.applyOptions(options),
setMarkers: (m) => markerPlugin.setMarkers(m),
clearMarkers: () => markerPlugin.setMarkers([]),
onRemove: () => {
ichart.removeSeries(candlestickISeries);
ichart.removeSeries(lineISeries);
@@ -803,8 +839,9 @@ export function createChartElement({
)
);
const markerPlugin = createSeriesMarkers(iseries, [], { autoScale: false });
const series = addSeries({
inner: () => iseries,
colors: isDualColor ? [positiveColor, negativeColor] : [positiveColor],
name,
order,
@@ -814,10 +851,16 @@ export function createChartElement({
data,
defaultActive,
metric,
setup: ({ active }) => {
setup: ({ active, highlighted }) => {
iseries.setSeriesOrder(order);
signals.createEffect(active, (active) =>
iseries.applyOptions({ visible: active }),
signals.createEffect(
() => ({ active: active(), highlighted: highlighted() }),
({ active, highlighted }) => {
iseries.applyOptions({
visible: active,
color: positiveColor.highlight(highlighted),
});
},
);
},
setData: (data) => {
@@ -837,8 +880,8 @@ export function createChartElement({
},
update: (data) => iseries.update(data),
getData: () => iseries.data(),
getOptions: () => iseries.options(),
applyOptions: (options) => iseries.applyOptions(options),
setMarkers: (m) => markerPlugin.setMarkers(m),
clearMarkers: () => markerPlugin.setMarkers([]),
onRemove: () => ichart.removeSeries(iseries),
});
return series;
@@ -883,8 +926,9 @@ export function createChartElement({
)
);
const markerPlugin = createSeriesMarkers(iseries, [], { autoScale: false });
const series = addSeries({
inner: () => iseries,
colors: [color],
name,
order,
@@ -894,17 +938,23 @@ export function createChartElement({
data,
defaultActive,
metric,
setup: ({ active }) => {
setup: ({ active, highlighted }) => {
iseries.setSeriesOrder(order);
signals.createEffect(active, (active) =>
iseries.applyOptions({ visible: active }),
signals.createEffect(
() => ({ active: active(), highlighted: highlighted() }),
({ active, highlighted }) => {
iseries.applyOptions({
visible: active,
color: color.highlight(highlighted),
});
},
);
},
setData: (data) => iseries.setData(data),
update: (data) => iseries.update(data),
getData: () => iseries.data(),
getOptions: () => iseries.options(),
applyOptions: (options) => iseries.applyOptions(options),
setMarkers: (m) => markerPlugin.setMarkers(m),
clearMarkers: () => markerPlugin.setMarkers([]),
onRemove: () => ichart.removeSeries(iseries),
});
return series;
@@ -951,8 +1001,9 @@ export function createChartElement({
)
);
const markerPlugin = createSeriesMarkers(iseries, [], { autoScale: false });
const series = addSeries({
inner: () => iseries,
colors: [color],
name,
order,
@@ -962,10 +1013,16 @@ export function createChartElement({
data,
defaultActive,
metric,
setup: ({ active }) => {
setup: ({ active, highlighted }) => {
iseries.setSeriesOrder(order);
signals.createEffect(active, (active) =>
iseries.applyOptions({ visible: active }),
signals.createEffect(
() => ({ active: active(), highlighted: highlighted() }),
({ active, highlighted }) => {
iseries.applyOptions({
visible: active,
color: color.highlight(highlighted),
});
},
);
signals.createEffect(visibleBarsCountBucket, (bucket) => {
const radius = bucket === 3 ? 1 : bucket >= 1 ? 1.5 : 2;
@@ -975,8 +1032,8 @@ export function createChartElement({
setData: (data) => iseries.setData(data),
update: (data) => iseries.update(data),
getData: () => iseries.data(),
getOptions: () => iseries.options(),
applyOptions: (options) => iseries.applyOptions(options),
setMarkers: (m) => markerPlugin.setMarkers(m),
clearMarkers: () => markerPlugin.setMarkers([]),
onRemove: () => ichart.removeSeries(iseries),
});
return series;
@@ -990,6 +1047,8 @@ export function createChartElement({
* @param {AnyMetricPattern} [args.metric]
* @param {number} [args.paneIndex]
* @param {boolean} [args.defaultActive]
* @param {Color} [args.topColor]
* @param {Color} [args.bottomColor]
* @param {BaselineSeriesPartialOptions} [args.options]
*/
addBaselineSeries({
@@ -1000,6 +1059,8 @@ export function createChartElement({
paneIndex: _paneIndex,
defaultActive,
data,
topColor = colors.green,
bottomColor = colors.red,
options,
}) {
const paneIndex = _paneIndex ?? 0;
@@ -1015,8 +1076,8 @@ export function createChartElement({
price: options?.baseValue?.price ?? 0,
},
...options,
topLineColor: options?.topLineColor ?? colors.green(),
bottomLineColor: options?.bottomLineColor ?? colors.red(),
topLineColor: topColor(),
bottomLineColor: bottomColor(),
priceLineVisible: false,
bottomFillColor1: "transparent",
bottomFillColor2: "transparent",
@@ -1028,12 +1089,10 @@ export function createChartElement({
)
);
const markerPlugin = createSeriesMarkers(iseries, [], { autoScale: false });
const series = addSeries({
inner: () => iseries,
colors: [
() => options?.topLineColor ?? colors.green(),
() => options?.bottomLineColor ?? colors.red(),
],
colors: [topColor, bottomColor],
name,
order,
paneIndex,
@@ -1042,17 +1101,24 @@ export function createChartElement({
data,
defaultActive,
metric,
setup: ({ active }) => {
setup: ({ active, highlighted }) => {
iseries.setSeriesOrder(order);
signals.createEffect(active, (active) =>
iseries.applyOptions({ visible: active }),
signals.createEffect(
() => ({ active: active(), highlighted: highlighted() }),
({ active, highlighted }) => {
iseries.applyOptions({
visible: active,
topLineColor: topColor.highlight(highlighted),
bottomLineColor: bottomColor.highlight(highlighted),
});
},
);
},
setData: (data) => iseries.setData(data),
update: (data) => iseries.update(data),
getData: () => iseries.data(),
getOptions: () => iseries.options(),
applyOptions: (options) => iseries.applyOptions(options),
setMarkers: (m) => markerPlugin.setMarkers(m),
clearMarkers: () => markerPlugin.setMarkers([]),
onRemove: () => ichart.removeSeries(iseries),
});
return series;

View File

@@ -1,9 +1,6 @@
import { createLabeledInput, createSpanName } from "../utils/dom.js";
import { stringToId } from "../utils/format.js";
/** @param {string} color */
const tameColor = (color) => `${color.slice(0, -1)} / 50%)`;
/**
* @param {Signals} signals
*/
@@ -52,6 +49,11 @@ export function createLegend(signals) {
type: "checkbox",
});
// Sync checkbox with signal (for shared signals across panes)
signals.createEffect(series.active, (active) => {
input.checked = active;
});
const spanMain = window.document.createElement("span");
spanMain.classList.add("main");
label.append(spanMain);
@@ -72,6 +74,11 @@ export function createLegend(signals) {
const shouldHighlight = () => !hovered() || hovered() === series;
// Update series highlighted state
signals.createEffect(shouldHighlight, (shouldHighlight) => {
series.highlighted.set(shouldHighlight);
});
const spanColors = window.document.createElement("span");
spanColors.classList.add("colors");
spanMain.prepend(spanColors);
@@ -80,45 +87,13 @@ export function createLegend(signals) {
spanColors.append(spanColor);
signals.createEffect(
() => ({
color: color(),
shouldHighlight: shouldHighlight(),
}),
({ color, shouldHighlight }) => {
if (shouldHighlight) {
spanColor.style.backgroundColor = color;
} else {
spanColor.style.backgroundColor = tameColor(color);
}
() => color.highlight(shouldHighlight()),
(c) => {
spanColor.style.backgroundColor = c;
},
);
});
const initialColors = /** @type {Record<string, any>} */ ({});
const darkenedColors = /** @type {Record<string, any>} */ ({});
const seriesOptions = series.getOptions();
if (!seriesOptions) return;
Object.entries(seriesOptions).forEach(([k, v]) => {
if (k.toLowerCase().includes("color") && typeof v === "string") {
if (!v.startsWith("oklch")) return;
initialColors[k] = v;
darkenedColors[k] = tameColor(v);
} else if (k === "lastValueVisible" && v) {
initialColors[k] = true;
darkenedColors[k] = false;
}
});
signals.createEffect(shouldHighlight, (shouldHighlight) => {
if (shouldHighlight) {
series.applyOptions(initialColors);
} else {
series.applyOptions(darkenedColors);
}
});
const anchor = window.document.createElement("a");
signals.createEffect(series.url, (url) => {

View File

@@ -1,4 +1,3 @@
import { createSeriesMarkers } from "../modules/lightweight-charts/5.1.0/dist/lightweight-charts.standalone.production.mjs";
import { throttle } from "../utils/timing.js";
/**
@@ -9,20 +8,7 @@ import { throttle } from "../utils/timing.js";
* @param {(value: number) => string} args.formatValue
*/
export function createMinMaxMarkers({ chart, seriesList, colors, formatValue }) {
/** @type {WeakMap<ISeries, SeriesMarkersPlugin>} */
const pluginCache = new WeakMap();
/** @param {ISeries} iseries */
function getOrCreatePlugin(iseries) {
let plugin = pluginCache.get(iseries);
if (!plugin) {
plugin = createSeriesMarkers(iseries, [], { autoScale: false });
pluginCache.set(iseries, plugin);
}
return plugin;
}
/** @type {Set<ISeries>} */
/** @type {Set<AnySeries>} */
const prevMarkerSeries = new Set();
function update() {
@@ -37,7 +23,7 @@ export function createMinMaxMarkers({ chart, seriesList, colors, formatValue })
const t1 = /** @type {number} */ (tRight ?? range.to);
const color = colors.gray();
/** @type {Map<number, { minV: number, minT: Time, minS: ISeries, maxV: number, maxT: Time, maxS: ISeries }>} */
/** @type {Map<number, { minV: number, minT: Time, minS: AnySeries, maxV: number, maxT: Time, maxS: AnySeries }>} */
const byPane = new Map();
for (const series of seriesList()) {
@@ -57,16 +43,15 @@ export function createMinMaxMarkers({ chart, seriesList, colors, formatValue })
if (lo >= len) continue;
const paneIndex = series.paneIndex;
const iseries = series.inner();
let pane = byPane.get(paneIndex);
if (!pane) {
pane = {
minV: Infinity,
minT: /** @type {Time} */ (0),
minS: iseries,
minS: series,
maxV: -Infinity,
maxT: /** @type {Time} */ (0),
maxS: iseries,
maxS: series,
};
byPane.set(paneIndex, pane);
}
@@ -79,17 +64,18 @@ export function createMinMaxMarkers({ chart, seriesList, colors, formatValue })
if (v && v < pane.minV) {
pane.minV = v;
pane.minT = pt.time;
pane.minS = iseries;
pane.minS = series;
}
if (h && h > pane.maxV) {
pane.maxV = h;
pane.maxT = pt.time;
pane.maxS = iseries;
pane.maxS = series;
}
}
}
// Set new markers
/** @type {Set<AnySeries>} */
const used = new Set();
for (const { minV, minT, minS, maxV, maxT, maxS } of byPane.values()) {
if (!Number.isFinite(minV) || !Number.isFinite(maxV) || minT === maxT)
@@ -115,23 +101,23 @@ export function createMinMaxMarkers({ chart, seriesList, colors, formatValue })
used.add(minS);
used.add(maxS);
if (minS === maxS) {
getOrCreatePlugin(minS).setMarkers([minM, maxM]);
minS.setMarkers([minM, maxM]);
} else {
getOrCreatePlugin(minS).setMarkers([minM]);
getOrCreatePlugin(maxS).setMarkers([maxM]);
minS.setMarkers([minM]);
maxS.setMarkers([maxM]);
}
}
// Clear stale
for (const s of prevMarkerSeries) {
if (!used.has(s)) getOrCreatePlugin(s).setMarkers([]);
if (!used.has(s)) s.clearMarkers();
}
prevMarkerSeries.clear();
for (const s of used) prevMarkerSeries.add(s);
}
function clear() {
for (const s of prevMarkerSeries) getOrCreatePlugin(s).setMarkers([]);
for (const s of prevMarkerSeries) s.clearMarkers();
prevMarkerSeries.clear();
}

View File

@@ -1,96 +1,59 @@
import {
readParam,
readNumberParam,
writeParam,
} from "../utils/url.js";
import { readParam, writeParam } from "../utils/url.js";
import { readStored, writeToStorage } from "../utils/storage.js";
/**
* @typedef {{ from: number | null, to: number | null }} Range
*/
const INDEX_KEY = "chart-index";
const RANGES_KEY = "chart-ranges";
const RANGE_SEP = "_";
/**
* @param {Signals} signals
*/
export function createChartState(signals) {
const index = signals.createPersistedSignal({
storageKey: "chart-index",
urlKey: "index",
defaultValue: /** @type {ChartableIndexName} */ ("date"),
serialize: (v) => v,
deserialize: (s) => /** @type {ChartableIndexName} */ (s),
});
// Ranges stored per-index in localStorage only
/** @type {Record<string, Range>} */
let ranges = {};
try {
const stored = localStorage.getItem(RANGES_KEY);
const stored = readStored(RANGES_KEY);
if (stored) ranges = JSON.parse(stored);
} catch {}
const saveRanges = () => {
try {
localStorage.setItem(RANGES_KEY, JSON.stringify(ranges));
} catch {}
};
// Read index: URL > localStorage > default
/** @type {ChartableIndexName} */
const defaultIndex = "date";
const urlIndex = readParam("index");
/** @type {ChartableIndexName} */
let initialIndex = defaultIndex;
if (urlIndex) {
initialIndex = /** @type {ChartableIndexName} */ (urlIndex);
} else {
try {
const stored = localStorage.getItem(INDEX_KEY);
if (stored) initialIndex = /** @type {ChartableIndexName} */ (stored);
} catch {}
// Initialize from URL if present
const urlRange = readParam("range");
if (urlRange) {
const [from, to] = urlRange.split(RANGE_SEP).map(Number);
if (!isNaN(from) && !isNaN(to)) {
ranges[index()] = { from, to };
writeToStorage(RANGES_KEY, JSON.stringify(ranges));
}
}
// Read range: URL > localStorage (per index)
const urlFrom = readNumberParam("from");
const urlTo = readNumberParam("to");
const storedRange = ranges[initialIndex] ?? { from: null, to: null };
const initialRange = {
from: urlFrom ?? storedRange.from,
to: urlTo ?? storedRange.to,
};
// Save URL range to localStorage if present
if (urlFrom !== null || urlTo !== null) {
ranges[initialIndex] = initialRange;
saveRanges();
}
const index = signals.createSignal(/** @type {ChartableIndexName} */ (initialIndex));
const currentRange = signals.createSignal(initialRange);
// Save index changes to localStorage + URL
signals.createEffect(index, (value) => {
try {
localStorage.setItem(INDEX_KEY, value);
} catch {}
writeParam("index", value !== defaultIndex ? value : null);
});
// When index changes, switch to that index's saved range
signals.createEffect(index, (i) => {
const range = ranges[i] ?? { from: null, to: null };
currentRange.set(range);
// Update URL with new range
writeParam("from", range.from !== null ? String(range.from) : null);
writeParam("to", range.to !== null ? String(range.to) : null);
});
return {
index,
/** @type {Accessor<Range>} */
range: currentRange,
/**
* @param {Range} value
*/
/** @returns {Range} */
range: () => ranges[index()] ?? { from: null, to: null },
/** @param {Range} value */
setRange(value) {
const i = index();
ranges[i] = value;
currentRange.set(value);
saveRanges();
writeParam("from", value.from !== null ? String(value.from) : null);
writeParam("to", value.to !== null ? String(value.to) : null);
ranges[index()] = value;
writeToStorage(RANGES_KEY, JSON.stringify(ranges));
if (value.from !== null && value.to !== null) {
// Round to 2 decimals for cleaner URLs
const f = Math.floor(value.from * 100) / 100;
const t = Math.floor(value.to * 100) / 100;
writeParam("range", `${f}${RANGE_SEP}${t}`);
} else {
writeParam("range", null);
}
},
};
}

View File

@@ -7,10 +7,10 @@ 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 initExplorer } from "./panes/_explorer.js";
import { init as initChart } from "./panes/chart/index.js";
import { init as initTable } from "./panes/table.js";
import { init as initSimulation } from "./panes/simulation.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";

View File

@@ -12,6 +12,7 @@ import { createChartElement } from "../../chart/index.js";
import { createChartState } from "../../chart/state.js";
import { webSockets } from "../../utils/ws.js";
import { screenshot } from "./screenshot.js";
import { debounce } from "../../utils/timing.js";
const keyPrefix = "chart";
const ONE_BTC_IN_SATS = 100_000_000;
@@ -89,20 +90,34 @@ export function init({ colors, option, brk }) {
});
}
// Sync chart → state.range on user pan/zoom
// Debounce to avoid rapid URL updates while panning
const debouncedSetRange = debounce(
(/** @type {{ from: number, to: number }} */ range) => state.setRange(range),
500,
);
chart.onVisibleLogicalRangeChange((t) => {
if (!t) return;
state.setRange({ from: t.from, to: t.to });
if (!t || t.from >= t.to) return;
debouncedSetRange({ from: t.from, to: t.to });
});
chartElement.append(fieldset);
const { field: topUnitField, selected: topUnit } = createChoiceField({
const unitChoices = /** @type {const} */ ([Unit.usd, Unit.sats]);
/** @type {Signal<Unit>} */
const topUnit = signals.createPersistedSignal({
defaultValue: /** @type {Unit} */ (Unit.usd),
storageKey: `${keyPrefix}-price`,
urlKey: "price",
serialize: (u) => u.id,
deserialize: (s) => /** @type {Unit} */ (unitChoices.find((u) => u.id === s) ?? Unit.usd),
});
const topUnitField = createChoiceField({
defaultValue: Unit.usd,
keyPrefix,
key: "unit-0",
choices: [Unit.usd, Unit.sats],
choices: unitChoices,
toKey: (u) => u.id,
toLabel: (u) => u.name,
selected: topUnit,
signals,
sorted: true,
type: "select",
@@ -207,23 +222,28 @@ export function init({ colors, option, brk }) {
const bottomUnits = Array.from(option.bottom.keys());
/** @type {{ field: HTMLDivElement, selected: Accessor<Unit> } | undefined} */
/** @type {{ field: HTMLDivElement, selected: Signal<Unit> } | undefined} */
let bottomUnitSelector;
if (bottomUnits.length) {
bottomUnitSelector = createChoiceField({
const selected = signals.createPersistedSignal({
defaultValue: bottomUnits[0],
storageKey: `${keyPrefix}-unit`,
urlKey: "unit",
serialize: (u) => u.id,
deserialize: (s) => bottomUnits.find((u) => u.id === s) ?? bottomUnits[0],
});
const field = createChoiceField({
defaultValue: bottomUnits[0],
keyPrefix,
key: "unit-1",
choices: bottomUnits,
toKey: (u) => u.id,
toLabel: (u) => u.name,
selected,
signals,
sorted: true,
type: "select",
});
const field = bottomUnitSelector.field;
bottomUnitSelector = { field, selected };
chart.addFieldsetIfNeeded({
id: "charts-unit-1",
paneIndex: 1,
@@ -471,9 +491,9 @@ function createIndexSelector(option, state) {
/** @type {ChartableIndexName} */
const defaultIndex = "date";
const { field } = createChoiceField({
const field = createChoiceField({
defaultValue: defaultIndex,
signal: state.index,
selected: state.index,
choices,
id: "index",
signals,

View File

@@ -24,6 +24,8 @@ import {
onCleanup,
} from "./modules/solidjs-signals/0.6.3/dist/prod.js";
import { debounce } from "./utils/timing.js";
import { writeParam, readParam } from "./utils/url.js";
import { readStored, writeToStorage } from "./utils/storage.js";
let effectCount = 0;
@@ -68,14 +70,11 @@ const signals = {
/**
* @template T
* @param {T} initialValue
* @param {SignalOptions<T> & {save?: {keyPrefix: string | Accessor<string>; key: string; serialize: (v: T) => string; deserialize: (v: string) => T; serializeParam?: boolean; saveDefaultValue?: boolean}}} [options]
* @param {SignalOptions<T>} [options]
* @returns {Signal<T>}
*/
createSignal(initialValue, options) {
const [get, set] = this.createSolidSignal(
/** @type {any} */ (initialValue),
options,
);
const [get, set] = this.createSolidSignal(/** @type {any} */ (initialValue), options);
// @ts-ignore
get.set = set;
@@ -83,148 +82,70 @@ const signals = {
// @ts-ignore
get.reset = () => set(initialValue);
if (options?.save) {
const save = options.save;
const paramKey = save.key;
const storageKey = this.createMemo(
() =>
`${
typeof save.keyPrefix === "string"
? save.keyPrefix
: save.keyPrefix()
}-${paramKey}`,
);
// /** @type { ((this: Window, ev: PopStateEvent) => any) | undefined} */
// let popstateCallback;
let serialized = /** @type {string | null} */ (null);
if (options.save.serializeParam !== false) {
serialized = new URLSearchParams(window.location.search).get(paramKey);
// popstateCallback =
// /** @type {(this: Window, ev: PopStateEvent) => any} */ (
// (_) => {
// serialized = new URLSearchParams(window.location.search).get(
// paramKey,
// );
// set(() =>
// serialized ? save.deserialize(serialized) : initialValue,
// );
// }
// );
// if (!popstateCallback) throw "Unreachable";
// window.addEventListener("popstate", popstateCallback);
// signals.onCleanup(() => {
// if (popstateCallback)
// window.removeEventListener("popstate", popstateCallback);
// });
}
if (serialized === null) {
try {
serialized = localStorage.getItem(storageKey());
} catch (_) {}
}
if (serialized) {
set(() => (serialized ? save.deserialize(serialized) : initialValue));
}
let firstRun1 = true;
this.createEffect(storageKey, (storageKey) => {
if (!firstRun1) {
try {
serialized = localStorage.getItem(storageKey);
set(() =>
serialized ? save.deserialize(serialized) : initialValue,
);
} catch (_) {}
}
firstRun1 = false;
});
let firstRun2 = true;
const debouncedSave = debounce(
/** @param {T} value */ (value) => {
try {
if (
value !== undefined &&
value !== null &&
(initialValue === undefined ||
initialValue === null ||
save.saveDefaultValue ||
save.serialize(value) !== save.serialize(initialValue))
) {
localStorage.setItem(storageKey(), save.serialize(value));
writeParam(paramKey, save.serialize(value));
} else {
localStorage.removeItem(storageKey());
removeParam(paramKey);
}
} catch (_) {}
},
250,
);
this.createEffect(get, (value) => {
if (!save) return;
if (firstRun2) {
// First run: sync URL params immediately
if (
value !== undefined &&
value !== null &&
(initialValue === undefined ||
initialValue === null ||
save.saveDefaultValue ||
save.serialize(value) !== save.serialize(initialValue))
) {
writeParam(paramKey, save.serialize(value));
} else {
removeParam(paramKey);
}
firstRun2 = false;
} else {
// Subsequent runs: debounce
debouncedSave(value);
}
});
}
// @ts-ignore
return get;
},
/**
* @template T
* @param {Object} args
* @param {T} args.defaultValue
* @param {string} args.storageKey
* @param {string} [args.urlKey]
* @param {(v: T) => string} args.serialize
* @param {(s: string) => T} args.deserialize
* @param {boolean} [args.saveDefaultValue]
* @returns {Signal<T>}
*/
createPersistedSignal({
defaultValue,
storageKey,
urlKey,
serialize,
deserialize,
saveDefaultValue = false,
}) {
const defaultSerialized = serialize(defaultValue);
// Read: URL > localStorage > default
let serialized = urlKey ? readParam(urlKey) : null;
if (serialized === null) {
serialized = readStored(storageKey);
}
const initialValue = serialized !== null ? deserialize(serialized) : defaultValue;
const signal = this.createSignal(initialValue);
/** @param {T} value */
const write = (value) => {
const s = serialize(value);
const isDefault = s === defaultSerialized;
if (!isDefault || saveDefaultValue) {
writeToStorage(storageKey, s);
} else {
writeToStorage(storageKey, null);
}
if (urlKey) {
writeParam(urlKey, !isDefault || saveDefaultValue ? s : null);
}
};
const debouncedWrite = debounce(write, 250);
let firstRun = true;
this.createEffect(signal, (value) => {
if (firstRun) {
write(value);
firstRun = false;
} else {
debouncedWrite(value);
}
});
return signal;
},
};
/** @typedef {typeof signals} Signals */
/**
* @param {string} key
* @param {string | undefined} value
*/
function writeParam(key, value) {
const urlParams = new URLSearchParams(window.location.search);
if (value !== undefined) {
urlParams.set(key, String(value));
} else {
urlParams.delete(key);
}
try {
window.history.replaceState(
null,
"",
`${window.location.pathname}?${urlParams.toString()}`,
);
} catch (_) {}
}
/**
* @param {string} key
*/
function removeParam(key) {
writeParam(key, undefined);
}
export default signals;

View File

@@ -1,70 +1,45 @@
/**
* Reduce color opacity to 50% for dimming effect
* @param {string} color - oklch color string
*/
export function tameColor(color) {
if (color === "transparent") return color;
return `${color.slice(0, -1)} / 50%)`;
}
/**
* @typedef {Object} ColorMethods
* @property {() => string} tame - Returns tamed (50% opacity) version
* @property {(highlighted: boolean) => string} highlight - Returns normal if highlighted, tamed otherwise
*/
/**
* @typedef {(() => string) & ColorMethods} Color
*/
/**
* Creates a Color object that is callable and has utility methods
* @param {() => string} getter
* @returns {Color}
*/
function createColor(getter) {
const color = /** @type {Color} */ (() => getter());
color.tame = () => tameColor(getter());
color.highlight = (highlighted) => highlighted ? getter() : tameColor(getter());
return color;
}
/**
* @param {Accessor<boolean>} dark
*/
export function createColors(dark) {
const globalComputedStyle = getComputedStyle(window.document.documentElement);
/**
* @param {string} color
* @param {string} name
*/
function getColor(color) {
return globalComputedStyle.getPropertyValue(`--${color}`);
}
function red() {
return getColor("red");
}
function orange() {
return getColor("orange");
}
function amber() {
return getColor("amber");
}
function yellow() {
return getColor("yellow");
}
function avocado() {
return getColor("avocado");
}
function lime() {
return getColor("lime");
}
function green() {
return getColor("green");
}
function emerald() {
return getColor("emerald");
}
function teal() {
return getColor("teal");
}
function cyan() {
return getColor("cyan");
}
function sky() {
return getColor("sky");
}
function blue() {
return getColor("blue");
}
function indigo() {
return getColor("indigo");
}
function violet() {
return getColor("violet");
}
function purple() {
return getColor("purple");
}
function fuchsia() {
return getColor("fuchsia");
}
function pink() {
return getColor("pink");
}
function rose() {
return getColor("rose");
}
function gray() {
return getColor("gray");
function getColor(name) {
return globalComputedStyle.getPropertyValue(`--${name}`);
}
/**
@@ -76,41 +51,33 @@ export function createColors(dark) {
return dark() ? _dark : light;
}
function textColor() {
return getLightDarkValue("--color");
}
function borderColor() {
return getLightDarkValue("--border-color");
}
return {
default: textColor,
gray,
border: borderColor,
default: createColor(() => getLightDarkValue("--color")),
gray: createColor(() => getColor("gray")),
border: createColor(() => getLightDarkValue("--border-color")),
red,
orange,
amber,
yellow,
avocado,
lime,
green,
emerald,
teal,
cyan,
sky,
blue,
indigo,
violet,
purple,
fuchsia,
pink,
rose,
red: createColor(() => getColor("red")),
orange: createColor(() => getColor("orange")),
amber: createColor(() => getColor("amber")),
yellow: createColor(() => getColor("yellow")),
avocado: createColor(() => getColor("avocado")),
lime: createColor(() => getColor("lime")),
green: createColor(() => getColor("green")),
emerald: createColor(() => getColor("emerald")),
teal: createColor(() => getColor("teal")),
cyan: createColor(() => getColor("cyan")),
sky: createColor(() => getColor("sky")),
blue: createColor(() => getColor("blue")),
indigo: createColor(() => getColor("indigo")),
violet: createColor(() => getColor("violet")),
purple: createColor(() => getColor("purple")),
fuchsia: createColor(() => getColor("fuchsia")),
pink: createColor(() => getColor("pink")),
rose: createColor(() => getColor("rose")),
};
}
/**
* @typedef {ReturnType<typeof createColors>} Colors
* @typedef {Colors["orange"]} Color
* @typedef {keyof Colors} ColorName
*/

View File

@@ -215,15 +215,13 @@ export function importStyle(href) {
/**
* @template T
* @param {Object} args
* @param {T} args.defaultValue
* @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 {string} [args.keyPrefix]
* @param {string} [args.key]
* @param {boolean} [args.sorted]
* @param {Signals} args.signals
* @param {Signal<T>} [args.signal] - Optional external signal to use instead of creating one
* @param {(choice: T) => string} [args.toKey] - Extract string key for storage (defaults to identity for strings)
* @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
*/
@@ -231,10 +229,8 @@ export function createChoiceField({
id,
choices: unsortedChoices,
defaultValue,
keyPrefix,
key,
signals,
signal: externalSignal,
selected,
sorted,
toKey = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
toLabel = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
@@ -260,25 +256,8 @@ export function createChoiceField({
: c;
});
/**
* @param {string} storedKey
* @returns {T}
*/
function fromKey(storedKey) {
const found = choices().find((c) => toKey(c) === storedKey);
return found ?? defaultValue;
}
/** @type {Signal<T>} */
const selected = externalSignal ?? signals.createSignal(defaultValue, {
save: {
serialize: (v) => toKey(v),
deserialize: (s) => fromKey(s),
keyPrefix: keyPrefix ?? "",
key: key ?? "",
saveDefaultValue: true,
},
});
/** @param {string} key */
const fromKey = (key) => choices().find((c) => toKey(c) === key) ?? defaultValue;
const field = window.document.createElement("div");
field.classList.add("field");
@@ -317,8 +296,8 @@ export function createChoiceField({
}
} else if (type === "select") {
const select = window.document.createElement("select");
select.id = id ?? key ?? "";
select.name = id ?? key ?? "";
select.id = id ?? "";
select.name = id ?? "";
choices.forEach((choice) => {
const option = window.document.createElement("option");
@@ -346,7 +325,7 @@ export function createChoiceField({
}
}
} else {
const fieldId = id ?? key ?? "";
const fieldId = id ?? "";
choices.forEach((choice) => {
const choiceKey = toKey(choice);
const choiceLabel = toLabel(choice);
@@ -372,7 +351,7 @@ export function createChoiceField({
}
});
return { field, selected };
return field;
}
/**

View File

@@ -104,10 +104,8 @@ export const serdeBool = {
deserialize(v) {
if (v === "true" || v === "1") {
return true;
} else if (v === "false" || v === "0") {
return false;
} else {
throw "deser bool err";
return false;
}
},
};

View File

@@ -1,25 +1,3 @@
/**
* @param {string} key
*/
export function readStoredNumber(key) {
const saved = readStored(key);
if (saved) {
return Number(saved);
}
return null;
}
/**
* @param {string} key
*/
export function readStoredBool(key) {
const saved = readStored(key);
if (saved) {
return saved === "true" || saved === "1";
}
return null;
}
/**
* @param {string} key
*/

View File

@@ -3,10 +3,12 @@
*/
function processPathname(pathname) {
pathname ||= window.location.pathname;
return Array.isArray(pathname) ? pathname.join("/") : pathname;
const result = Array.isArray(pathname) ? pathname.join("/") : pathname;
// Strip leading slash to avoid double slashes when prepending /
return result.startsWith("/") ? result.slice(1) : result;
}
const chartParamsWhitelist = ["from", "to"];
const chartParamsWhitelist = ["range"];
/**
* @param {string | string[]} pathname
@@ -16,7 +18,6 @@ export function pushHistory(pathname) {
pathname = processPathname(pathname);
try {
const url = `/${pathname}?${urlParams.toString()}`;
console.log(`push history: ${url}`);
window.history.pushState(null, "", url);
} catch (_) {}
}
@@ -31,7 +32,6 @@ export function replaceHistory({ urlParams, pathname }) {
pathname = processPathname(pathname);
try {
const url = `/${pathname}?${urlParams.toString()}`;
console.log(`replace history: ${url}`);
window.history.replaceState(null, "", url);
} catch (_) {}
}