// Compact serialization of charts/dashboards for shareable URLs, mirroring // searchQuery.ts: short keys, ids dropped (regenerated on parse), enums // validated on the way back in with safe fallbacks. The encoded string is // stored verbatim in a URL param (?chart= for one chart, ?dash= for a board). import { PLATFORM_VALUES, type Platform } from "./platform"; import { newChart, nextChartId, type ChartConfig, type ChartFilters, type ChartType, type Dashboard, type SeriesGroupBy, type TimeBin, type TimeField, type XAxisField, type YMetric, } from "./chartConfig"; // The presentation-only description of the search page's single chart. Unlike a // ChartConfig it owns NO query (that lives in `qt=`) and NO filters (those live // in the search share params) — only how to plot the current search/metadata. export type ChartShape = { dataSource: "search" | "metadata"; type: ChartType; x: XAxisField; y: YMetric; // only meaningful when dataSource === "metadata" groupBy: SeriesGroupBy; cumulative: boolean; metric: "matchedVideos" | "totalHits"; // only meaningful when dataSource === "search" }; const TYPES: ChartType[] = ["line", "bar", "area", "stackedBar", "pie"]; const BINS: TimeBin[] = ["week", "month", "quarter", "year"]; const GROUPS: SeriesGroupBy[] = [ "none", "channel", "platform", "language", "mediaType", "status", "site", ]; type SC = Record; function str(v: unknown): string | undefined { return typeof v === "string" ? v : undefined; } function oneOf(v: unknown, allowed: T[], fallback: T): T { return typeof v === "string" && (allowed as string[]).includes(v) ? (v as T) : fallback; } // ─── Filters ─── function encFilters(f: ChartFilters): SC | undefined { const out: SC = {}; if (f.dateFrom) out.df = f.dateFrom; if (f.dateTo) out.dt = f.dateTo; if (f.channels?.length) out.ch = f.channels; if (f.platforms?.length) out.pl = f.platforms; if (f.hasTranscriptOnly) out.ht = 1; return Object.keys(out).length ? out : undefined; } function decFilters(raw: unknown): ChartFilters { if (!raw || typeof raw !== "object") return {}; const r = raw as SC; const out: ChartFilters = {}; if (str(r.df)) out.dateFrom = str(r.df); if (str(r.dt)) out.dateTo = str(r.dt); if (Array.isArray(r.ch)) out.channels = r.ch.filter((c): c is string => typeof c === "string"); if (Array.isArray(r.pl)) { out.platforms = r.pl.filter((p): p is Platform => (PLATFORM_VALUES as readonly string[]).includes(p as string), ); } if (r.ht === 1) out.hasTranscriptOnly = true; return out; } // ─── X axis ─── const TIME_FIELDS: TimeField[] = ["uploadDate", "downloadedDate", "transcribedDate"]; function encX(x: XAxisField): SC { if (x.kind === "time") { const out: SC = { k: "t", b: x.bin }; // Omit the default so legacy/short URLs stay unchanged. if (x.field && x.field !== "uploadDate") out.f = x.field; return out; } if (x.kind === "numericBucket") return { k: "n", f: x.field, s: x.bucketSize }; return { k: "c", f: x.field }; } function decX(raw: unknown): XAxisField { const r = (raw && typeof raw === "object" ? raw : {}) as SC; if (r.k === "n") { return { kind: "numericBucket", field: "duration", bucketSize: typeof r.s === "number" && r.s > 0 ? r.s : 300, }; } if (r.k === "c") { return { kind: "category", field: oneOf( r.f, [ "channel", "platform", "language", "category", "type", "hasTranscript", "tag", "mediaType", "status", ], "channel", ), }; } return { kind: "time", bin: oneOf(r.b, BINS, "month"), field: oneOf(r.f, TIME_FIELDS, "uploadDate"), }; } // ─── Y metric ─── function encY(y: YMetric): SC { return y.agg === "count" ? { a: "count" } : { a: y.agg, f: y.field }; } function decY(raw: unknown): YMetric { const r = (raw && typeof raw === "object" ? raw : {}) as SC; if (r.a === "count" || r.a === undefined) return { agg: "count" }; const agg = oneOf(r.a, ["sum", "avg", "median", "max", "min"], "sum"); const field = oneOf( r.f, ["duration", "viewCount", "likeCount", "commentCount", "channelFollowerCount", "cueCount", "coverage"], "viewCount", ); return { agg, field }; } // ─── Chart ─── function encChart(c: ChartConfig): SC { const out: SC = { t: c.title, ty: c.type, x: encX(c.x), y: encY(c.y) }; if (c.groupBy !== "none") out.g = c.groupBy; if (c.cumulative) out.cu = 1; const f = encFilters(c.filters); if (f) out.f = f; if (c.search) out.s = { q: c.search.queryTree, m: c.search.metric }; return out; } function decChart(raw: unknown): ChartConfig { const r = (raw && typeof raw === "object" ? raw : {}) as SC; const search = r.s && typeof r.s === "object" && str((r.s as SC).q) ? { queryTree: str((r.s as SC).q)!, metric: oneOf((r.s as SC).m, ["matchedVideos", "totalHits"], "matchedVideos"), } : undefined; return newChart({ id: nextChartId(), title: str(r.t) ?? "Untitled chart", type: oneOf(r.ty, TYPES, "line"), x: decX(r.x), y: decY(r.y), groupBy: oneOf(r.g, GROUPS, "none"), cumulative: r.cu === 1, filters: decFilters(r.f), search, }); } // ─── Public API ─── export function stringifyChart(c: ChartConfig): string { return JSON.stringify(encChart(c)); } export function parseChart(s: string): ChartConfig | null { try { return decChart(JSON.parse(s)); } catch { return null; } } export function stringifyDashboard(d: Dashboard): string { const out: SC = { v: 1, t: d.title, c: d.charts.map(encChart) }; const f = encFilters(d.filters); if (f) out.f = f; return JSON.stringify(out); } export function parseDashboard(s: string): Dashboard | null { let raw: unknown; try { raw = JSON.parse(s); } catch { return null; } if (!raw || typeof raw !== "object") return null; const r = raw as SC; const charts = Array.isArray(r.c) ? r.c.map(decChart) : []; return { version: 1, title: str(r.t) ?? "Shared dashboard", filters: decFilters(r.f), charts, }; } // ─── Chart shape (search-page single chart) ─── // Reuses the chart enc/dec helpers above; the `cs=` URL param holds this. export function defaultChartShape(dataSource: "search" | "metadata"): ChartShape { return { dataSource, type: "line", x: { kind: "time", bin: "month" }, y: { agg: "count" }, groupBy: "none", cumulative: false, metric: "matchedVideos", }; } export function stringifyChartShape(s: ChartShape): string { const out: SC = { ds: s.dataSource, ty: s.type, x: encX(s.x), y: encY(s.y) }; if (s.groupBy !== "none") out.g = s.groupBy; if (s.cumulative) out.cu = 1; if (s.metric !== "matchedVideos") out.m = s.metric; return JSON.stringify(out); } export function parseChartShape(s: string): ChartShape { let raw: unknown; try { raw = JSON.parse(s); } catch { return defaultChartShape("search"); } const r = (raw && typeof raw === "object" ? raw : {}) as SC; return { dataSource: oneOf(r.ds, ["search", "metadata"], "search"), type: oneOf(r.ty, TYPES, "line"), x: decX(r.x), y: decY(r.y), groupBy: oneOf(r.g, GROUPS, "none"), cumulative: r.cu === 1, metric: oneOf(r.m, ["matchedVideos", "totalHits"], "matchedVideos"), }; }