mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-08-12 02:03:09 -07:00
experiments: added events
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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 = "";
|
||||
|
||||
Reference in New Issue
Block a user