Files
scrabble-game/ui/src/lib/banner.ts
T
Ilia Denisov dd45af20ef
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
fix(ads): fade the scroll rewind + keep the banner strip height constant
Two polish fixes (owner feedback):

- Scroll loop: a long message that scrolled to its right edge rewound with a hard
  jump (no fade). It now runs the same fade as a message change at each rewind:
  fade out at the edge, reset the scroll while hidden, fade the same message back
  in, then scroll again.
- Strip height: during the fade gap the message layer is removed, which let the
  strip collapse by ~1-2px. An always-present invisible spacer now reserves one
  line of height and the message is overlaid absolutely, so the strip height is
  constant whether or not the message is showing.

Verified live: opacity sampling shows a full fade-out → gap → fade-in at each
scroll rewind (~every 6s), and the .ad height stays a single constant value
(30.31px) across the whole cycle including the gap. Loop-fade unit-tested.
2026-06-16 07:18:50 +02:00

205 lines
7.2 KiB
TypeScript

// 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, 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,
};
/** Scheduler yields the next message to show, fairly across weighted campaigns. */
export interface Scheduler {
next(): string;
/** 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 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);
return {
total,
next(): string {
if (cs.length === 0) return '';
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;
},
};
}
/** 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. */
show(md: string): 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 = ''; // the message currently presented, for restart() (re-measure)
const timers: ReturnType<typeof setTimeout>[] = [];
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(md: string) {
if (!running) return;
clear();
lastShown = md;
host.show(md);
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, () => {
host.show(lastShown);
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`);
} else {
parts.push(label);
}
last = re.lastIndex;
}
parts.push(escapeHtml(md.slice(last)));
return parts.join('');
}