mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
The chart showed a different (higher, fluctuating) total than the card's view count because it re-derived its own total from a one-off, unwindowed relay listVideoViewEvents — which varies by relay response and counts a wider set than the counter. Now the modal headline subscribes to the same viewCounter the card uses (the deduped, cached, authoritative total), so it always matches the number shown everywhere and is stable. The chart below is purely the time DISTRIBUTION of recorded views, listed within the counter's backfill window (VIEW_COUNT_BACKFILL_MAX_DAYS) so it lines up with the headline's window. The count subscription is cleaned up on close. Co-Authored-By: Claude Opus 4.8 <[email protected]>
365 lines
12 KiB
JavaScript
365 lines
12 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 { VIEW_COUNT_BACKFILL_MAX_DAYS } from "./config.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 };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// ---- 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,
|
|
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);
|
|
|
|
const note = el(
|
|
doc,
|
|
"p",
|
|
"text-2xs text-muted mt-2",
|
|
"Public view data · updates as more views load.",
|
|
);
|
|
dialog.appendChild(note);
|
|
|
|
backdrop.appendChild(dialog);
|
|
root.appendChild(backdrop);
|
|
|
|
// ---- data + render ----
|
|
const eventsById = new Map();
|
|
let closed = false;
|
|
let unsubscribe = 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;
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
totalLine.textContent =
|
|
total > 0
|
|
? `${formatViewCount(total)} ${total === 1 ? "view" : "views"}`
|
|
: "No views recorded yet.";
|
|
};
|
|
|
|
const renderChart = () => {
|
|
if (closed) return;
|
|
const { series } = buildViewCountTimeSeries([...eventsById.values()]);
|
|
chartHost.textContent = "";
|
|
chartHost.appendChild(buildViewCountChartSvg(doc, series));
|
|
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
|
|
}
|
|
}
|
|
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();
|
|
}
|
|
});
|
|
} 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);
|
|
}
|
|
|
|
const handle = { close };
|
|
activePopularityModal = handle;
|
|
return handle;
|
|
}
|