mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
The per-video Popularity modal now plots a second, orange zaps-over-time line beside the red views line, with a legend and — new — VISIBLE x-axis date ticks (the old chart only put the range in the aria-label). - buildZapSatsTimeSeries: buckets cumulative SATS from 9735 receipts + verified bitvid tallies, deduped by payment_hash (same rule as the badge store) so the chart and badge agree. - buildPopularityChartSvg: two token-colored cumulative lines on a shared time x-axis, each with its OWN y scale (independent per-series — D5), plus date-tick <text> labels. Views = .text-accent, zaps = .text-zap (new --color-zap utility). Legend is plain DOM in the modal; headline gains "· N sats zapped". - js/zapEventsFacade.js: lists a pointer's [9735, ZAP_TALLY_KIND] events via the L1 SubscriptionManager for the chart; modal re-renders on onZapTotalsChanged. - All flag-gated (FEATURE_ZAP_TALLY); the existing views-only chart + its test are untouched (buildViewCountChartSvg kept). Verified live earlier that imports resolve (app builds/runs). Tests: tests/zap-time-series-chart.test.mjs (5) + existing view-count-chart green. Lint + build green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
652 lines
22 KiB
JavaScript
652 lines
22 KiB
JavaScript
// js/viewCountChart.js
|
|
//
|
|
// Public "Popularity" view: a views-over-time chart for a video, opened from the
|
|
// ⋯ menu. View data is public (kind-30079 events), so any viewer can see it.
|
|
// The chart buckets view events by the same dedupe window the counter uses (one
|
|
// view per viewer per day), and updates live as more view events stream in.
|
|
|
|
import {
|
|
listVideoViewEventsWithDefaultClient,
|
|
subscribeVideoViewEventsWithDefaultClient,
|
|
} from "./nostrViewEventsFacade.js";
|
|
import { resolveVideoPointer } from "./utils/videoPointer.js";
|
|
import {
|
|
formatViewCount,
|
|
subscribeToVideoViewCount,
|
|
unsubscribeFromVideoViewCount,
|
|
} from "./viewCounter.js";
|
|
import { listVideoZapEventsWithDefaultClient } from "./zapEventsFacade.js";
|
|
import {
|
|
getVideoZapTotalSnapshot,
|
|
requestVideoZapTotal,
|
|
onZapTotalsChanged,
|
|
ZAP_RECEIPT_KIND,
|
|
} from "./zapTotals.js";
|
|
import { ZAP_TALLY_KIND } from "./nostrEventSchemas.js";
|
|
import {
|
|
verifyBitvidZapTally,
|
|
extractBolt11Fields,
|
|
} from "./payments/zapReceiptValidator.js";
|
|
import { VIEW_COUNT_BACKFILL_MAX_DAYS } from "./config.js";
|
|
import { FEATURE_ZAP_TALLY } from "./constants.js";
|
|
import { devLogger } from "./utils/logger.js";
|
|
|
|
export const VIEW_CHART_WINDOW_SECONDS = 86400; // 1 day, matches the counter dedupe window
|
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
|
|
// ---- Pure data ---------------------------------------------------------------
|
|
|
|
// Bucket raw view events into a cumulative time series. Dedupes by
|
|
// (viewer pubkey, day bucket) so it reflects the same "one view per viewer per
|
|
// window" semantics the counter displays. Returns ordered buckets + the total.
|
|
export function buildViewCountTimeSeries(
|
|
events,
|
|
{ windowSeconds = VIEW_CHART_WINDOW_SECONDS } = {},
|
|
) {
|
|
const w = Math.max(1, Number(windowSeconds) || VIEW_CHART_WINDOW_SECONDS);
|
|
const seen = new Set();
|
|
const perBucket = new Map();
|
|
for (const event of Array.isArray(events) ? events : []) {
|
|
const createdAt = Number(event?.created_at);
|
|
const pubkey = typeof event?.pubkey === "string" ? event.pubkey : "";
|
|
if (!Number.isFinite(createdAt) || createdAt <= 0 || !pubkey) {
|
|
continue;
|
|
}
|
|
const bucket = Math.floor(createdAt / w);
|
|
const key = `${pubkey}:${bucket}`;
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
perBucket.set(bucket, (perBucket.get(bucket) || 0) + 1);
|
|
}
|
|
const buckets = Array.from(perBucket.keys()).sort((a, b) => a - b);
|
|
const series = [];
|
|
let cumulative = 0;
|
|
for (const bucket of buckets) {
|
|
const count = perBucket.get(bucket) || 0;
|
|
cumulative += count;
|
|
series.push({ bucketStart: bucket * w, count, cumulative });
|
|
}
|
|
return { series, total: cumulative };
|
|
}
|
|
|
|
// Read the authoritative sats for one zap event (9735 receipt or bitvid tally),
|
|
// plus a dedup key (payment_hash preferred, else event id). Returns null when
|
|
// the event isn't a countable zap (invalid tally, no amount, …).
|
|
function readZapEvent(event, tools) {
|
|
const getSats = tools?.nip57?.getSatoshisAmountFromBolt11;
|
|
if (event?.kind === ZAP_TALLY_KIND) {
|
|
const verdict = verifyBitvidZapTally(event, { getSats });
|
|
if (!verdict?.ok || !(verdict.sats > 0)) {
|
|
return null;
|
|
}
|
|
return { sats: verdict.sats, dedup: verdict.paymentHash || event.id };
|
|
}
|
|
if (event?.kind === ZAP_RECEIPT_KIND && event?.id) {
|
|
const bolt11 =
|
|
(Array.isArray(event.tags)
|
|
? event.tags.find((t) => Array.isArray(t) && t[0] === "bolt11")?.[1]
|
|
: "") || "";
|
|
let sats = 0;
|
|
try {
|
|
const parsed = typeof getSats === "function" ? Number(getSats(bolt11)) : NaN;
|
|
sats = Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0;
|
|
} catch (error) {
|
|
sats = 0;
|
|
}
|
|
if (sats <= 0) {
|
|
return null;
|
|
}
|
|
const dedup = extractBolt11Fields(bolt11).paymentHash || event.id;
|
|
return { sats, dedup };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Bucket zap events into a cumulative SATS-over-time series, deduped by
|
|
// payment_hash across 9735s + tallies (same rule as the zap-totals store), so
|
|
// the chart and the badge agree. Returns ordered buckets + the total.
|
|
export function buildZapSatsTimeSeries(
|
|
events,
|
|
{ windowSeconds = VIEW_CHART_WINDOW_SECONDS, tools = null } = {},
|
|
) {
|
|
const w = Math.max(1, Number(windowSeconds) || VIEW_CHART_WINDOW_SECONDS);
|
|
const seenPayments = new Set();
|
|
const perBucket = new Map();
|
|
for (const event of Array.isArray(events) ? events : []) {
|
|
const createdAt = Number(event?.created_at);
|
|
if (!Number.isFinite(createdAt) || createdAt <= 0) {
|
|
continue;
|
|
}
|
|
const read = readZapEvent(event, tools);
|
|
if (!read || seenPayments.has(read.dedup)) {
|
|
continue;
|
|
}
|
|
seenPayments.add(read.dedup);
|
|
const bucket = Math.floor(createdAt / w);
|
|
perBucket.set(bucket, (perBucket.get(bucket) || 0) + read.sats);
|
|
}
|
|
const buckets = Array.from(perBucket.keys()).sort((a, b) => a - b);
|
|
const series = [];
|
|
let cumulative = 0;
|
|
for (const bucket of buckets) {
|
|
const sats = perBucket.get(bucket) || 0;
|
|
cumulative += sats;
|
|
series.push({ bucketStart: bucket * w, sats, cumulative });
|
|
}
|
|
return { series, total: cumulative };
|
|
}
|
|
|
|
function formatDay(seconds) {
|
|
try {
|
|
return new Date(seconds * 1000).toLocaleDateString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
} catch (error) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// ---- SVG chart ---------------------------------------------------------------
|
|
|
|
// Cumulative views-over-time as a token-colored SVG area+line chart. Uses
|
|
// currentColor (set via a text-* utility on the container) so it honors theme
|
|
// tokens — no raw colors.
|
|
export function buildViewCountChartSvg(doc, series, { width = 320, height = 120 } = {}) {
|
|
const pad = { top: 8, right: 8, bottom: 18, left: 8 };
|
|
const svg = doc.createElementNS(SVG_NS, "svg");
|
|
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
|
svg.setAttribute("width", "100%");
|
|
svg.setAttribute("role", "img");
|
|
svg.setAttribute("preserveAspectRatio", "none");
|
|
svg.classList.add("text-accent");
|
|
|
|
const points = Array.isArray(series) ? series : [];
|
|
if (points.length === 0) {
|
|
svg.setAttribute("aria-label", "No views yet");
|
|
return svg;
|
|
}
|
|
|
|
const innerW = Math.max(1, width - pad.left - pad.right);
|
|
const innerH = Math.max(1, height - pad.top - pad.bottom);
|
|
const maxY = points[points.length - 1].cumulative || 1;
|
|
const minX = points[0].bucketStart;
|
|
const maxX = points[points.length - 1].bucketStart;
|
|
const spanX = maxX - minX || 1;
|
|
|
|
const x = (bucketStart) => pad.left + ((bucketStart - minX) / spanX) * innerW;
|
|
const y = (cumulative) => pad.top + (1 - cumulative / maxY) * innerH;
|
|
|
|
// A single bucket can't draw a line — draw a flat baseline to its value.
|
|
const coords =
|
|
points.length === 1
|
|
? [
|
|
[pad.left, y(points[0].cumulative)],
|
|
[pad.left + innerW, y(points[0].cumulative)],
|
|
]
|
|
: points.map((p) => [x(p.bucketStart), y(p.cumulative)]);
|
|
|
|
const linePoints = coords.map(([px, py]) => `${px.toFixed(1)},${py.toFixed(1)}`).join(" ");
|
|
const baseY = pad.top + innerH;
|
|
const areaPoints =
|
|
`${coords[0][0].toFixed(1)},${baseY.toFixed(1)} ` +
|
|
linePoints +
|
|
` ${coords[coords.length - 1][0].toFixed(1)},${baseY.toFixed(1)}`;
|
|
|
|
const area = doc.createElementNS(SVG_NS, "polygon");
|
|
area.setAttribute("points", areaPoints);
|
|
area.setAttribute("fill", "currentColor");
|
|
area.setAttribute("fill-opacity", "0.15");
|
|
area.setAttribute("stroke", "none");
|
|
svg.appendChild(area);
|
|
|
|
const line = doc.createElementNS(SVG_NS, "polyline");
|
|
line.setAttribute("points", linePoints);
|
|
line.setAttribute("fill", "none");
|
|
line.setAttribute("stroke", "currentColor");
|
|
line.setAttribute("stroke-width", "2");
|
|
line.setAttribute("stroke-linejoin", "round");
|
|
line.setAttribute("stroke-linecap", "round");
|
|
svg.appendChild(line);
|
|
|
|
svg.setAttribute(
|
|
"aria-label",
|
|
`${maxY} total views from ${formatDay(minX)} to ${formatDay(maxX)}`,
|
|
);
|
|
return svg;
|
|
}
|
|
|
|
// Draw one cumulative series (area + line) into `svg` using a color class on a
|
|
// wrapping <g> (currentColor). x()/y() map data → pixels. Independent per-series
|
|
// Y scaling (D5): the caller passes this series' own maxY.
|
|
function drawSeries(doc, svg, points, { x, y, baseY, colorClass }) {
|
|
if (!points.length) return;
|
|
const coords =
|
|
points.length === 1
|
|
? [
|
|
[x(points[0].bucketStart), y(points[0].cumulative)],
|
|
[x(points[points.length - 1].bucketStart) + 1, y(points[0].cumulative)],
|
|
]
|
|
: points.map((p) => [x(p.bucketStart), y(p.cumulative)]);
|
|
const linePoints = coords.map(([px, py]) => `${px.toFixed(1)},${py.toFixed(1)}`).join(" ");
|
|
const areaPoints =
|
|
`${coords[0][0].toFixed(1)},${baseY.toFixed(1)} ` +
|
|
linePoints +
|
|
` ${coords[coords.length - 1][0].toFixed(1)},${baseY.toFixed(1)}`;
|
|
|
|
const group = doc.createElementNS(SVG_NS, "g");
|
|
if (colorClass) group.classList.add(colorClass);
|
|
|
|
const area = doc.createElementNS(SVG_NS, "polygon");
|
|
area.setAttribute("points", areaPoints);
|
|
area.setAttribute("fill", "currentColor");
|
|
area.setAttribute("fill-opacity", "0.15");
|
|
area.setAttribute("stroke", "none");
|
|
group.appendChild(area);
|
|
|
|
const line = doc.createElementNS(SVG_NS, "polyline");
|
|
line.setAttribute("points", linePoints);
|
|
line.setAttribute("fill", "none");
|
|
line.setAttribute("stroke", "currentColor");
|
|
line.setAttribute("stroke-width", "2");
|
|
line.setAttribute("stroke-linejoin", "round");
|
|
line.setAttribute("stroke-linecap", "round");
|
|
group.appendChild(line);
|
|
|
|
svg.appendChild(group);
|
|
}
|
|
|
|
// Popularity chart with TWO cumulative series on a shared TIME x-axis, each with
|
|
// its OWN y scale (views in counts, zaps in sats — independent per-series
|
|
// scaling shows each trend honestly; the legend names the units). Adds VISIBLE
|
|
// x-axis date ticks. Views = accent (red), zaps = --color-zap (orange).
|
|
export function buildPopularityChartSvg(
|
|
doc,
|
|
{ views = [], zaps = [] } = {},
|
|
{ width = 320, height = 132 } = {},
|
|
) {
|
|
const pad = { top: 8, right: 8, bottom: 26, left: 8 };
|
|
const svg = doc.createElementNS(SVG_NS, "svg");
|
|
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
|
svg.setAttribute("width", "100%");
|
|
svg.setAttribute("role", "img");
|
|
svg.setAttribute("preserveAspectRatio", "none");
|
|
|
|
const viewsPts = Array.isArray(views) ? views : [];
|
|
const zapsPts = Array.isArray(zaps) ? zaps : [];
|
|
if (!viewsPts.length && !zapsPts.length) {
|
|
svg.setAttribute("aria-label", "No views or zaps yet");
|
|
return svg;
|
|
}
|
|
|
|
const innerW = Math.max(1, width - pad.left - pad.right);
|
|
const innerH = Math.max(1, height - pad.top - pad.bottom);
|
|
const baseY = pad.top + innerH;
|
|
|
|
// Shared time domain across both series.
|
|
const allX = [...viewsPts, ...zapsPts].map((p) => p.bucketStart);
|
|
const minX = Math.min(...allX);
|
|
const maxX = Math.max(...allX);
|
|
const spanX = maxX - minX || 1;
|
|
const x = (bucketStart) => pad.left + ((bucketStart - minX) / spanX) * innerW;
|
|
|
|
// Per-series y scale (each fills the height against its own max).
|
|
const makeY = (pts) => {
|
|
const maxY = (pts[pts.length - 1]?.cumulative) || 1;
|
|
return (cumulative) => pad.top + (1 - cumulative / maxY) * innerH;
|
|
};
|
|
|
|
drawSeries(doc, svg, viewsPts, {
|
|
x,
|
|
y: makeY(viewsPts),
|
|
baseY,
|
|
colorClass: "text-accent",
|
|
});
|
|
drawSeries(doc, svg, zapsPts, {
|
|
x,
|
|
y: makeY(zapsPts),
|
|
baseY,
|
|
colorClass: "text-zap",
|
|
});
|
|
|
|
// Visible x-axis date ticks (min → max, a few evenly spaced) so the reader
|
|
// knows the time range — the old chart only put dates in the aria-label.
|
|
const tickCount = spanX > 1 ? 3 : 1;
|
|
for (let i = 0; i < tickCount; i += 1) {
|
|
const t = tickCount === 1 ? minX : minX + (spanX * i) / (tickCount - 1);
|
|
const label = doc.createElementNS(SVG_NS, "text");
|
|
const px = x(t);
|
|
label.setAttribute("x", px.toFixed(1));
|
|
label.setAttribute("y", (height - 8).toFixed(1));
|
|
label.setAttribute("font-size", "9");
|
|
label.setAttribute("fill", "currentColor");
|
|
label.setAttribute("fill-opacity", "0.6");
|
|
label.setAttribute(
|
|
"text-anchor",
|
|
i === 0 ? "start" : i === tickCount - 1 ? "end" : "middle",
|
|
);
|
|
label.textContent = formatDay(t);
|
|
svg.appendChild(label);
|
|
}
|
|
|
|
const viewsTotal = viewsPts[viewsPts.length - 1]?.cumulative || 0;
|
|
const zapsTotal = zapsPts[zapsPts.length - 1]?.cumulative || 0;
|
|
svg.setAttribute(
|
|
"aria-label",
|
|
`${viewsTotal} views and ${zapsTotal} sats zapped from ${formatDay(minX)} to ${formatDay(maxX)}`,
|
|
);
|
|
return svg;
|
|
}
|
|
|
|
// ---- Modal -------------------------------------------------------------------
|
|
|
|
function pointerForVideo(video) {
|
|
if (!video || typeof video !== "object") {
|
|
return null;
|
|
}
|
|
const tags = Array.isArray(video.tags) ? video.tags : [];
|
|
const dTag = tags.find((t) => Array.isArray(t) && t[0] === "d");
|
|
const dValue = dTag && typeof dTag[1] === "string" ? dTag[1] : "";
|
|
return resolveVideoPointer({
|
|
kind: video.kind,
|
|
pubkey: video.pubkey,
|
|
videoRootId: video.videoRootId,
|
|
dTag: dValue,
|
|
fallbackEventId: video.id,
|
|
relay: video.relay,
|
|
});
|
|
}
|
|
|
|
function el(doc, tag, className, text) {
|
|
const node = doc.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (typeof text === "string") node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
// At most one popularity modal open at a time.
|
|
let activePopularityModal = null;
|
|
|
|
// Opens the popularity modal for a video. Dependencies are injectable for tests.
|
|
export function openPopularityModal({
|
|
document: doc = typeof document !== "undefined" ? document : null,
|
|
video,
|
|
listViewEvents = listVideoViewEventsWithDefaultClient,
|
|
subscribeViewEvents = subscribeVideoViewEventsWithDefaultClient,
|
|
listZapEvents = listVideoZapEventsWithDefaultClient,
|
|
getTools = () =>
|
|
(typeof globalThis !== "undefined" &&
|
|
(globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ || globalThis.NostrTools)) ||
|
|
null,
|
|
mount = null,
|
|
} = {}) {
|
|
if (!doc) return null;
|
|
const pointerInfo = pointerForVideo(video);
|
|
if (!pointerInfo?.pointer) {
|
|
return null;
|
|
}
|
|
const pointer = pointerInfo.pointer;
|
|
|
|
// Close any already-open popularity modal (proper cleanup of its subscription
|
|
// + listeners) so re-triggering — or two menu paths firing for one click —
|
|
// can't stack duplicates.
|
|
if (activePopularityModal) {
|
|
try {
|
|
activePopularityModal.close();
|
|
} catch (error) {
|
|
// ignore
|
|
}
|
|
activePopularityModal = null;
|
|
}
|
|
|
|
const root = mount || doc.getElementById("uiOverlay") || doc.body;
|
|
const backdrop = el(doc, "div", "ds-overlay-backdrop popularity-modal__backdrop");
|
|
const dialog = el(doc, "div", "popularity-modal card p-md");
|
|
dialog.setAttribute("role", "dialog");
|
|
dialog.setAttribute("aria-modal", "true");
|
|
dialog.setAttribute("aria-label", "Video popularity");
|
|
|
|
const header = el(doc, "div", "flex items-center justify-between gap-3 mb-2");
|
|
header.appendChild(el(doc, "h2", "text-lg font-semibold text-text", "Popularity"));
|
|
const closeBtn = el(doc, "button", "icon-button", "");
|
|
closeBtn.type = "button";
|
|
closeBtn.setAttribute("aria-label", "Close");
|
|
closeBtn.textContent = "✕";
|
|
header.appendChild(closeBtn);
|
|
dialog.appendChild(header);
|
|
|
|
const totalLine = el(doc, "p", "text-sm text-muted mb-3", "Loading views…");
|
|
dialog.appendChild(totalLine);
|
|
|
|
const chartHost = el(doc, "div", "popularity-modal__chart");
|
|
dialog.appendChild(chartHost);
|
|
|
|
// Legend: red = Views, orange = Zaps (sats). Token colors via .text-accent /
|
|
// .text-zap on the swatch (currentColor). Only shown when the feature is on.
|
|
const legend = el(doc, "div", "popularity-legend");
|
|
const legendItem = (colorClass, label) => {
|
|
const item = el(doc, "span", "popularity-legend__item");
|
|
item.appendChild(el(doc, "span", `popularity-legend__swatch ${colorClass}`));
|
|
item.appendChild(el(doc, "span", "", label));
|
|
return item;
|
|
};
|
|
legend.appendChild(legendItem("text-accent", "Views"));
|
|
if (FEATURE_ZAP_TALLY) {
|
|
legend.appendChild(legendItem("text-zap", "Zaps (sats)"));
|
|
}
|
|
dialog.appendChild(legend);
|
|
|
|
const note = el(
|
|
doc,
|
|
"p",
|
|
"text-2xs text-muted mt-2",
|
|
FEATURE_ZAP_TALLY
|
|
? "Public view + zap data · updates as more load."
|
|
: "Public view data · updates as more views load.",
|
|
);
|
|
dialog.appendChild(note);
|
|
|
|
backdrop.appendChild(dialog);
|
|
root.appendChild(backdrop);
|
|
|
|
// ---- data + render ----
|
|
const eventsById = new Map();
|
|
const zapEventsById = new Map();
|
|
let closed = false;
|
|
let unsubscribe = null;
|
|
let unsubscribeZapTotals = null;
|
|
let countToken = null;
|
|
let counterTotal = null;
|
|
let rerenderTimer = null;
|
|
|
|
const addEvent = (event) => {
|
|
const id = typeof event?.id === "string" ? event.id : "";
|
|
if (!id || eventsById.has(id)) return false;
|
|
eventsById.set(id, event);
|
|
return true;
|
|
};
|
|
|
|
const addZapEvent = (event) => {
|
|
const id = typeof event?.id === "string" ? event.id : "";
|
|
if (!id || zapEventsById.has(id)) return false;
|
|
zapEventsById.set(id, event);
|
|
return true;
|
|
};
|
|
|
|
// The headline number must match what the card / counter shows everywhere
|
|
// (the authoritative, deduped, cached viewCounter total) — NOT a separate count
|
|
// re-derived from a one-off relay list (which varies by relay response and
|
|
// window). The chart below shows only the time DISTRIBUTION of recorded views.
|
|
const renderHeadline = () => {
|
|
if (closed) return;
|
|
const total = Number.isFinite(counterTotal)
|
|
? counterTotal
|
|
: buildViewCountTimeSeries([...eventsById.values()]).total;
|
|
if (!Number.isFinite(total)) {
|
|
return;
|
|
}
|
|
const viewsText =
|
|
total > 0
|
|
? `${formatViewCount(total)} ${total === 1 ? "view" : "views"}`
|
|
: "No views recorded yet.";
|
|
// Zap total from the shared store (matches the badge), appended when on.
|
|
if (FEATURE_ZAP_TALLY) {
|
|
let zapSats = 0;
|
|
try {
|
|
zapSats = getVideoZapTotalSnapshot(pointer) || 0;
|
|
} catch (error) {
|
|
zapSats = 0;
|
|
}
|
|
totalLine.textContent =
|
|
zapSats > 0 ? `${viewsText} · ${formatViewCount(zapSats)} sats zapped` : viewsText;
|
|
} else {
|
|
totalLine.textContent = viewsText;
|
|
}
|
|
};
|
|
|
|
const renderChart = () => {
|
|
if (closed) return;
|
|
const { series: views } = buildViewCountTimeSeries([...eventsById.values()]);
|
|
const { series: zaps } = FEATURE_ZAP_TALLY
|
|
? buildZapSatsTimeSeries([...zapEventsById.values()], { tools: getTools() })
|
|
: { series: [] };
|
|
chartHost.textContent = "";
|
|
chartHost.appendChild(buildPopularityChartSvg(doc, { views, zaps }));
|
|
renderHeadline();
|
|
};
|
|
|
|
const scheduleRender = () => {
|
|
if (rerenderTimer) return;
|
|
rerenderTimer = setTimeout(() => {
|
|
rerenderTimer = null;
|
|
renderChart();
|
|
}, 250);
|
|
};
|
|
|
|
const close = () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
if (rerenderTimer) {
|
|
clearTimeout(rerenderTimer);
|
|
rerenderTimer = null;
|
|
}
|
|
if (typeof unsubscribe === "function") {
|
|
try {
|
|
unsubscribe();
|
|
} catch (error) {
|
|
// best effort
|
|
}
|
|
}
|
|
if (countToken != null) {
|
|
try {
|
|
unsubscribeFromVideoViewCount(pointer, countToken);
|
|
} catch (error) {
|
|
// best effort
|
|
}
|
|
}
|
|
if (typeof unsubscribeZapTotals === "function") {
|
|
try {
|
|
unsubscribeZapTotals();
|
|
} catch (error) {
|
|
// best effort
|
|
}
|
|
}
|
|
doc.removeEventListener("keydown", onKeydown, true);
|
|
backdrop.remove();
|
|
if (activePopularityModal === handle) {
|
|
activePopularityModal = null;
|
|
}
|
|
};
|
|
|
|
function onKeydown(event) {
|
|
if (event.key === "Escape") {
|
|
event.stopPropagation();
|
|
close();
|
|
}
|
|
}
|
|
|
|
closeBtn.addEventListener("click", close);
|
|
backdrop.addEventListener("click", (event) => {
|
|
if (event.target === backdrop) close();
|
|
});
|
|
doc.addEventListener("keydown", onKeydown, true);
|
|
|
|
// Headline total: subscribe to the same counter the card uses (stable + cached).
|
|
try {
|
|
countToken = subscribeToVideoViewCount(
|
|
pointer,
|
|
({ total } = {}) => {
|
|
if (closed) return;
|
|
if (Number.isFinite(total)) {
|
|
counterTotal = Number(total);
|
|
renderHeadline();
|
|
}
|
|
},
|
|
{ exact: true },
|
|
);
|
|
} catch (error) {
|
|
devLogger.warn("[popularity] Failed to subscribe to view count:", error);
|
|
}
|
|
|
|
// Chart shape: list the recorded view events within the counter's backfill
|
|
// window so the distribution lines up with the headline's window.
|
|
const since =
|
|
Math.floor(Date.now() / 1000) - VIEW_COUNT_BACKFILL_MAX_DAYS * 86400;
|
|
Promise.resolve()
|
|
.then(() => listViewEvents(pointer, { since }))
|
|
.then((events) => {
|
|
if (closed) return;
|
|
(Array.isArray(events) ? events : []).forEach(addEvent);
|
|
renderChart();
|
|
})
|
|
.catch((error) => {
|
|
devLogger.warn("[popularity] Failed to load view events:", error);
|
|
});
|
|
|
|
try {
|
|
unsubscribe = subscribeViewEvents(pointer, {
|
|
onEvent: (event) => {
|
|
if (closed) return;
|
|
if (addEvent(event)) {
|
|
scheduleRender();
|
|
}
|
|
},
|
|
});
|
|
} catch (error) {
|
|
devLogger.warn("[popularity] Failed to subscribe to view events:", error);
|
|
}
|
|
|
|
// Zaps-over-time (orange line): fetch the pointer's zap events (9735 + tally)
|
|
// for the chart, and (headline) request the shared total + re-render when the
|
|
// zap-totals store changes. Gated by the feature flag.
|
|
if (FEATURE_ZAP_TALLY) {
|
|
try {
|
|
requestVideoZapTotal(pointer); // primes the store for the headline
|
|
unsubscribeZapTotals = onZapTotalsChanged(() => {
|
|
if (!closed) renderHeadline();
|
|
});
|
|
} catch (error) {
|
|
devLogger.warn("[popularity] Failed to subscribe to zap totals:", error);
|
|
}
|
|
Promise.resolve()
|
|
.then(() => listZapEvents(pointer, { since }))
|
|
.then((events) => {
|
|
if (closed) return;
|
|
let added = false;
|
|
for (const event of Array.isArray(events) ? events : []) {
|
|
if (addZapEvent(event)) added = true;
|
|
}
|
|
if (added) renderChart();
|
|
})
|
|
.catch((error) => {
|
|
devLogger.warn("[popularity] Failed to load zap events:", error);
|
|
});
|
|
}
|
|
|
|
const handle = { close };
|
|
activePopularityModal = handle;
|
|
return handle;
|
|
}
|