// Advertising banner — the server-driven rotation engine plus a tiny markdown // linkifier. The scheduler and rotator are DOM-agnostic (the host measures overflow // and applies the visual effects through callbacks), so their timing and fairness are // unit-testable with fake timers. The campaigns + timings come from the profile's // banner block (see lib/model, ARCHITECTURE §10); the host is AdBanner.svelte. import type { BannerCampaign, BannerColors, BannerTimings } from './model'; /** Fallback display timings, matching the seeded ad_settings defaults. */ export const defaultBannerTimings: BannerTimings = { holdMs: 60_000, edgePauseMs: 5_000, scrollPxPerSec: 40, fadeOutMs: 1_000, gapMs: 250, fadeInMs: 1_000, }; /** BannerItem is one scheduled message plus its campaign's colour override (for the strip). */ export interface BannerItem { md: string; colors: BannerColors; } /** Scheduler yields the next message to show, fairly across weighted campaigns. */ export interface Scheduler { next(): BannerItem; /** Total messages across all (non-empty, positive-weight) campaigns. */ readonly total: number; } /** * createScheduler builds a smooth weighted round-robin over campaigns: each call to * next() picks the campaign whose running weight is highest (then decremented by the * total weight), so over one cycle of `sum(weights)` picks every campaign appears * exactly its weight share, evenly interleaved rather than clustered (the nginx/envoy * algorithm). Within a campaign its messages advance round-robin. Campaigns with no * messages or a non-positive weight are dropped. */ export function createScheduler(campaigns: BannerCampaign[]): Scheduler { const cs = campaigns.filter((c) => c.weight > 0 && c.messages.length > 0); const colors: BannerColors[] = cs.map((c) => ({ all: c.overrideAll ?? null, dark: c.overrideDark ?? null })); const current = cs.map(() => 0); // SWRR running weights const cursor = cs.map(() => 0); // per-campaign round-robin position const totalWeight = cs.reduce((sum, c) => sum + c.weight, 0); const total = cs.reduce((sum, c) => sum + c.messages.length, 0); const empty: BannerColors = { all: null, dark: null }; return { total, next(): BannerItem { if (cs.length === 0) return { md: '', colors: empty }; let best = 0; for (let i = 0; i < cs.length; i++) { current[i] += cs[i].weight; if (current[i] > current[best]) best = i; } current[best] -= totalWeight; const c = cs[best]; const md = c.messages[cursor[best] % c.messages.length]; cursor[best]++; return { md, colors: colors[best] }; }, }; } /** The host the rotator drives; AdBanner.svelte supplies the DOM measurements and effects. */ export interface BannerHost { /** Render md and fade it in (over fadeInMs), with the scroll reset to the start. colors is the * source campaign's colour override, applied to the strip (its neutral tokens when unset). */ show(md: string, colors: BannerColors): void; /** Reset the scroll to the start without re-fading (to loop a long message). */ resetScroll(): void; /** Fade the current message out over durationMs. */ hide(durationMs: number): void; /** Overflow width of the currently-shown message in px (0 when it fits or motion is reduced). */ overflowPx(): number; /** Animate the horizontal scroll to toPx over durationMs. */ scrollTo(toPx: number, durationMs: number): void; /** Resume an in-flight scroll on a freshly-mounted view: jump to fromTx (px, signed) instantly, * then continue to toPx over durationMs. Used by the engine on attach to carry the scroll * position across a navigation; the rotator itself never calls it. */ resumeScroll(fromTx: number, toPx: number, durationMs: number): void; } export interface Rotator { start(): void; stop(): void; /** Re-present the current message (re-measure overflow + restart its scroll), e.g. after the * viewport size changed. A no-op before start(). */ restart(): void; } /** * createBannerRotator rotates the scheduled messages: each message fades in, holds * `holdMs` (an overflowing one scrolls to its right edge and back while under holdMs), * then fades out over `fadeOutMs`, waits `gapMs`, and fades the next one in. A lone * message pulses (the same message fades back in) so the cycle keeps its rhythm. * Reduce-motion is handled by the host: pass zeroed fade timings and an overflowPx that * returns 0, and messages swap instantly without scrolling. */ export function createBannerRotator( campaigns: BannerCampaign[], host: BannerHost, config: BannerTimings = defaultBannerTimings, ): Rotator { const sched = createScheduler(campaigns); let running = false; let cycleStart = 0; let lastShown: BannerItem | null = null; // the item currently presented, for restart() (re-measure) const timers: ReturnType[] = []; const at = (ms: number, fn: () => void) => { timers.push(setTimeout(fn, ms)); }; const clear = () => { for (const t of timers) clearTimeout(t); timers.length = 0; }; function present(item: BannerItem) { if (!running) return; clear(); lastShown = item; host.show(item.md, item.colors); at(config.fadeInMs, measure); } function measure() { if (!running) return; cycleStart = Date.now(); const over = host.overflowPx(); if (over > 0) { scrollCycle(over); return; } // Hold, then run the fade cycle. A lone message pulses (fades out, then back in) rather than // sitting frozen, so the banner keeps its rhythm even with a single campaign message. at(config.holdMs, advance); } function scrollCycle(over: number) { const dur = (over / config.scrollPxPerSec) * 1000; at(config.edgePauseMs, () => { host.scrollTo(over, dur); at(dur + config.edgePauseMs, () => { if (Date.now() - cycleStart >= config.holdMs) { advance(); } else { // Loop the same message with a fade: fade out at the right edge, rewind to the start // while hidden, fade the same message back in, then scroll again — so the rewind gets // the same fade as a message change, not a hard jump. host.hide(config.fadeOutMs); at(config.fadeOutMs + config.gapMs, () => { if (lastShown) host.show(lastShown.md, lastShown.colors); at(config.fadeInMs, () => scrollCycle(over)); }); } }); }); } function advance() { if (!running) return; host.hide(config.fadeOutMs); at(config.fadeOutMs + config.gapMs, () => present(sched.next())); } return { start() { if (running || sched.total === 0) return; running = true; present(sched.next()); }, stop() { running = false; clear(); }, restart() { if (running && lastShown) present(lastShown); }, }; } const URL_RE = /^(https?:\/\/|\/)/i; function escapeHtml(s: string): string { return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!); } /** * linkify renders minimal markdown to a safe HTML string: everything is escaped, then * `[label](url)` becomes a link (only http(s):// or root-relative URLs are allowed). */ export function linkify(md: string): string { const parts: string[] = []; const re = /\[([^\]]+)\]\(([^)]+)\)/g; let last = 0; let m: RegExpExecArray | null; while ((m = re.exec(md)) !== null) { parts.push(escapeHtml(md.slice(last, m.index))); const label = escapeHtml(m[1]); const url = m[2].trim(); if (URL_RE.test(url)) { parts.push(`${label}`); } else { parts.push(label); } last = re.lastIndex; } parts.push(escapeHtml(md.slice(last))); return parts.join(''); }