experiments: added events

This commit is contained in:
nym21
2026-08-07 18:56:10 +02:00
parent 9f2744941a
commit 31748a712d
2 changed files with 1364 additions and 25 deletions
File diff suppressed because it is too large Load Diff
+191 -25
View File
@@ -264,13 +264,19 @@
createChart,
} from "https://unpkg.com/lightweight-charts@5.2.0/dist/lightweight-charts.standalone.production.mjs";
const API = "https://bitview.space/api/series";
const API_ROOT = "https://bitview.space/api";
const API = `${API_ROOT}/series`;
const LIVE_PRICE_URL = `${API_ROOT}/oracle/price`;
const DAY_ZERO = Date.UTC(2009, 0, 1);
const DAY_SECONDS = 24 * 60 * 60;
const LIVE_PRICE_POLL_MS = 1000;
const LIVE_PRICE_TIMEOUT_MS = 1500;
const SERIES_POLL_MS = 60 * 1000;
const PRICE_CANDLE_ENTER_BARS = 500;
const PRICE_CANDLE_EXIT_BARS = 600;
const PRICE_SCALE_MIN_WIDTH = 64;
const PRICE_PANE_STRETCH = 5;
const PRICE_SCALE_MARGINS = { top: 0.2, bottom: 0.02 };
const PRICE_PANE_STRETCH = 8;
const POSITION_PANE_STRETCH = 1;
const COLORS = Object.freeze({
@@ -315,8 +321,16 @@
const statusElement = document.getElementById("status");
const sentimentElement = document.getElementById("sentiment");
const rowsByTime = new Map();
const etags = new Map();
const seriesCache = {};
const NOT_MODIFIED = Symbol("not-modified");
let rows = [];
let latestRow = null;
let chartState = null;
let lastLivePrice = null;
let livePriceInFlight = false;
let seriesRefreshInFlight = false;
function rgba(hex, alpha) {
const value = Number.parseInt(hex.slice(1), 16);
@@ -359,12 +373,39 @@
return SENTIMENT[score] ?? null;
}
function formatPrice(value) {
if (!isPrice(value)) return "--";
if (value >= 100000) return `$${Math.round(value / 1000)}k`;
if (value >= 10000) return `$${(value / 1000).toFixed(1)}k`;
if (value >= 1000) return `$${Math.round(value).toLocaleString("en-US")}`;
return `$${value.toFixed(2)}`;
function numberToUSNumber(value, digits) {
return value.toLocaleString("en-US", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
function formatPrice(value, digits) {
const absoluteValue = Math.abs(value);
if (!Number.isFinite(value)) return "";
if (absoluteValue < 10) {
return numberToUSNumber(value, Math.min(3, digits || 10));
}
if (absoluteValue < 1000) {
return numberToUSNumber(value, Math.min(2, digits || 10));
}
if (absoluteValue < 10000) {
return numberToUSNumber(value, Math.min(1, digits || 10));
}
if (absoluteValue < 1000000) {
return numberToUSNumber(value, 0);
}
if (absoluteValue >= 1e27) return "Inf.";
const log = Math.floor(Math.log10(absoluteValue) - 6);
const suffixes = ["M", "B", "T", "P", "E", "Z", "Y"];
const suffixIndex = Math.floor(log / 3);
const divisor = 1000000 * 1000 ** suffixIndex;
const suffix = suffixes[suffixIndex];
const suffixDigits = log % 3 === 0 ? 3 : log % 3 === 1 ? 2 : 1;
return `${numberToUSNumber(value / divisor, suffixDigits)}${suffix}`;
}
function buildRows({
@@ -411,17 +452,42 @@
return rows;
}
async function fetchSeries(name, url) {
const response = await fetch(url, { cache: "no-cache" });
async function fetchSeries(name, url, conditional = false) {
const headers = {};
const etag = etags.get(name);
if (conditional && etag) headers["If-None-Match"] = etag;
const response = await fetch(url, { cache: "no-cache", headers });
if (response.status === 304) return NOT_MODIFIED;
if (!response.ok) throw new Error(`${name}: ${response.status}`);
const series = await response.json();
if (!Number.isFinite(series.start) || !Array.isArray(series.data)) {
throw new Error(`${name}: invalid data`);
}
const nextEtag = response.headers.get("etag");
if (nextEtag) etags.set(name, nextEtag);
return series;
}
async function fetchAllSeries(conditional = false) {
const entries = await Promise.all(
Object.entries(ENDPOINTS).map(async ([name, url]) => [
name,
await fetchSeries(name, url, conditional),
]),
);
let changed = false;
for (const [name, series] of entries) {
if (series === NOT_MODIFIED) continue;
seriesCache[name] = series;
changed = true;
}
return conditional && !changed ? null : seriesCache;
}
function areaPoint(row) {
const color = sentimentFor(row.score).color;
return {
@@ -466,6 +532,108 @@
sentimentElement.style.setProperty("--sentiment-color", sentiment.color);
}
function indexRows(nextRows) {
rows = nextRows;
rowsByTime.clear();
for (const row of rows) rowsByTime.set(row.time, row);
latestRow = rows.at(-1) ?? null;
setReadout(latestRow);
}
function applyRows(nextRows) {
const visibleRange =
chartState?.chart.timeScale().getVisibleLogicalRange() ?? null;
indexRows(nextRows);
if (!chartState) return;
chartState.area.setData(rows.map(areaPoint));
chartState.price.setData(
rows.map((row) => ({ time: row.time, value: row.price })),
);
chartState.candles.setData(rows.map(candlePoint));
chartState.position.setData(rows.map(positionPoint));
for (let index = 0; index < REFERENCES.length; index += 1) {
const definition = REFERENCES[index];
chartState.references[index].setData(
rows.map((row) => referencePoint(row, definition.key)),
);
}
if (visibleRange) {
chartState.chart.timeScale().setVisibleLogicalRange(visibleRange);
}
}
function applyLivePrice(value, force = false) {
if (!chartState || !latestRow || !isPrice(value)) return;
if (!force && value === lastLivePrice) return;
lastLivePrice = value;
const row = {
...latestRow,
price: value,
high: Math.max(latestRow.high, value),
low: Math.min(latestRow.low, value),
close: value,
};
rows[rows.length - 1] = row;
latestRow = row;
rowsByTime.set(row.time, row);
chartState.area.update(areaPoint(row));
chartState.price.update({ time: row.time, value });
chartState.candles.update(candlePoint(row));
}
async function refreshLivePrice() {
if (livePriceInFlight) return;
livePriceInFlight = true;
const controller = new AbortController();
const timeout = window.setTimeout(
() => controller.abort(),
LIVE_PRICE_TIMEOUT_MS,
);
try {
const response = await fetch(LIVE_PRICE_URL, {
cache: "no-store",
signal: controller.signal,
});
if (!response.ok) throw new Error(`live price: ${response.status}`);
applyLivePrice(Number(await response.json()));
} catch (error) {
console.warn(error);
} finally {
window.clearTimeout(timeout);
livePriceInFlight = false;
}
}
async function refreshSeries() {
if (seriesRefreshInFlight) return;
seriesRefreshInFlight = true;
try {
const series = await fetchAllSeries(true);
if (!series) return;
const nextRows = buildRows(series);
if (!nextRows.length) throw new Error("No aligned sentiment data");
applyRows(nextRows);
if (isPrice(lastLivePrice)) applyLivePrice(lastLivePrice, true);
} catch (error) {
console.warn(error);
} finally {
seriesRefreshInFlight = false;
}
}
function startPolling() {
void refreshLivePrice();
window.setInterval(refreshLivePrice, LIVE_PRICE_POLL_MS);
window.setInterval(refreshSeries, SERIES_POLL_MS);
}
function createSentimentChart(rows) {
const chart = createChart(chartElement, {
autoSize: true,
@@ -504,7 +672,7 @@
borderColor: COLORS.faint,
borderVisible: true,
minimumWidth: PRICE_SCALE_MIN_WIDTH,
scaleMargins: { top: 0.16, bottom: 0.12 },
scaleMargins: PRICE_SCALE_MARGINS,
},
timeScale: {
borderColor: COLORS.faint,
@@ -543,6 +711,7 @@
tickmarksFormatter: (values) => values.map(formatPrice),
},
priceLineVisible: false,
priceLineColor: COLORS.foreground,
lastValueVisible: true,
crosshairMarkerVisible: false,
});
@@ -563,6 +732,7 @@
tickmarksFormatter: (values) => values.map(formatPrice),
},
priceLineVisible: false,
priceLineColor: COLORS.foreground,
lastValueVisible: true,
});
@@ -578,7 +748,8 @@
tickmarksFormatter: (values) => values.map(formatPrice),
},
priceLineVisible: false,
lastValueVisible: false,
priceLineColor: rgba(definition.color, 0.75),
lastValueVisible: true,
crosshairMarkerVisible: false,
});
reference.setData(
@@ -675,23 +846,18 @@
schedulePriceVisibility,
);
updatePriceVisibility(chart.timeScale().getVisibleLogicalRange());
return { chart, area, price, candles, references, position };
}
async function load() {
const series = Object.fromEntries(await Promise.all(
Object.entries(ENDPOINTS).map(([name, url]) =>
fetchSeries(name, url).then((data) => [name, data]),
),
));
const series = await fetchAllSeries();
const nextRows = buildRows(series);
if (!nextRows.length) throw new Error("No aligned sentiment data");
const rows = buildRows(series);
if (!rows.length) throw new Error("No aligned sentiment data");
for (const row of rows) rowsByTime.set(row.time, row);
latestRow = rows.at(-1);
setReadout(latestRow);
createSentimentChart(rows);
indexRows(nextRows);
chartState = createSentimentChart(rows);
startPolling();
chartElement.setAttribute("aria-busy", "false");
statusElement.textContent = "";