// Announcement / "ad" banner — a parameterised rotator plus a tiny markdown linkifier. // The rotator is DOM-agnostic (the host measures overflow and applies the visual // effects through callbacks), so its timing is unit-testable with fake timers. Today // the content is a mock long↔short rotation; later it becomes a server-driven // announcements channel (see ARCHITECTURE). export interface BannerConfig { /** How long one message is shown before advancing (short text), ms. */ holdMs: number; /** Pause at each end before/after scrolling a long message, ms. */ edgePauseMs: number; /** Scroll speed for a long (overflowing) message, px/sec. */ scrollPxPerSec: number; /** Cross-fade duration between messages, ms. */ fadeMs: number; } export const defaultBannerConfig: BannerConfig = { holdMs: 60_000, edgePauseMs: 5_000, scrollPxPerSec: 40, fadeMs: 400, }; export interface BannerItem { /** Minimal markdown: plain text + `[label](url)` links. */ md: string; } /** The host the rotator drives; the Svelte component supplies the DOM measurements. */ export interface BannerHost { /** Overflow width of item `index` in px (0 when it fits). */ overflowPx(index: number): number; /** Render item `index` (the host fades it in and resets scroll to the start). */ show(index: number): void; /** Animate the horizontal scroll to `toPx` over `durationMs`. */ scrollTo(toPx: number, durationMs: number): void; } export interface Rotator { start(): void; stop(): void; } /** * createBannerRotator drives a list of messages: a fitting message holds `holdMs` * then advances; an overflowing one pauses, scrolls to its right edge, pauses, then * repeats while the elapsed cycle is under `holdMs`, else advances. */ export function createBannerRotator( items: BannerItem[], host: BannerHost, config: BannerConfig = defaultBannerConfig, ): Rotator { let index = 0; let running = false; let cycleStart = 0; 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 advance() { if (!running) return; index = (index + 1) % items.length; present(); } function present() { if (!running) return; clear(); host.show(index); // Let the swapped-in message render before measuring its overflow. at(config.fadeMs, () => { const over = host.overflowPx(index); if (over <= 0) { at(config.holdMs, advance); return; } cycleStart = Date.now(); scrollCycle(over); }); } 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 { host.show(index); // resets scroll to the start scrollCycle(over); } }); }); } return { start() { if (running || items.length === 0) return; running = true; index = 0; present(); }, stop() { running = false; clear(); }, }; } 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(''); } /** mockBanners is the placeholder rotation (long ↔ short) to demo the mechanics. */ export function mockBanners(): BannerItem[] { return [ { md: 'New season starts soon — [learn more](https://example.com/season).' }, { md: 'Tip: a 7-tile play earns a +50 bonus. Try the daily tournament, climb the leaderboard, and challenge friends — more modes are coming, [stay tuned](https://example.com/news)!', }, ]; }