mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-02-02 08:33:37 +00:00
Adds the thinkback plugin - a personalized Year in Review ASCII animation generator for Claude Code users. Features: - Multiple vibes: cozy, awards show, morning news, RPG quest - Quick generation with templates or deep dive with personalized narratives - Comprehensive animation helpers for backgrounds, transitions, particles - Stats extraction from Claude Code usage history 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
971 lines
30 KiB
JavaScript
971 lines
30 KiB
JavaScript
/* eslint-disable */
|
|
/**
|
|
* News Broadcast Effects
|
|
* Specialized effects for the morning news vibe
|
|
*/
|
|
(function() {
|
|
|
|
// Seeded random for consistent effects
|
|
function seededRandom(seed) {
|
|
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
|
|
return x - Math.floor(x);
|
|
}
|
|
|
|
// Easing functions
|
|
function easeOut(t) {
|
|
return 1 - Math.pow(1 - t, 3);
|
|
}
|
|
|
|
function easeInOut(t) {
|
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
}
|
|
|
|
// ============================================================================
|
|
// LOWER THIRD - News-style stat display bar
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Draw a "lower third" graphics bar (news-style info bar)
|
|
* @param fb - Framebuffer
|
|
* @param y - Y position (typically near bottom)
|
|
* @param label - Left side label (e.g., "COMMITS THIS YEAR")
|
|
* @param value - Right side value (e.g., "1,247")
|
|
* @param progress - Animation progress 0-1 (for reveal)
|
|
* @param options - { style: 'single'|'double'|'heavy', depth: number }
|
|
*/
|
|
function lowerThird(fb, y, label, value, progress, options = {}) {
|
|
const { style = 'heavy', depth = 5, accentChar = '▌' } = options;
|
|
|
|
const totalWidth = Math.min(fb.width - 4, 50);
|
|
const x = Math.floor((fb.width - totalWidth) / 2);
|
|
|
|
// Animate width based on progress
|
|
const visibleWidth = Math.floor(easeOut(progress) * totalWidth);
|
|
if (visibleWidth < 3) return;
|
|
|
|
const BORDERS = {
|
|
single: { h: '─', v: '│', tl: '┌', tr: '┐', bl: '└', br: '┘' },
|
|
double: { h: '═', v: '║', tl: '╔', tr: '╗', bl: '╚', br: '╝' },
|
|
heavy: { h: '━', v: '┃', tl: '┏', tr: '┓', bl: '┗', br: '┛' },
|
|
};
|
|
const chars = BORDERS[style] || BORDERS.heavy;
|
|
|
|
const startX = Math.floor((fb.width - visibleWidth) / 2);
|
|
|
|
// Top border
|
|
fb.setPixel(startX, y, chars.tl, depth);
|
|
for (let i = 1; i < visibleWidth - 1; i++) {
|
|
fb.setPixel(startX + i, y, chars.h, depth);
|
|
}
|
|
fb.setPixel(startX + visibleWidth - 1, y, chars.tr, depth);
|
|
|
|
// Content line with accent bar
|
|
fb.setPixel(startX, y + 1, chars.v, depth);
|
|
fb.setPixel(startX + 1, y + 1, accentChar, depth);
|
|
fb.setPixel(startX + 2, y + 1, ' ', depth);
|
|
|
|
// Draw label (left-aligned after accent)
|
|
const maxLabelLen = visibleWidth - value.length - 6;
|
|
const displayLabel = label.slice(0, Math.max(0, maxLabelLen));
|
|
for (let i = 0; i < displayLabel.length; i++) {
|
|
fb.setPixel(startX + 3 + i, y + 1, displayLabel[i], depth);
|
|
}
|
|
|
|
// Draw value (right-aligned)
|
|
const valueStart = startX + visibleWidth - value.length - 2;
|
|
for (let i = 0; i < value.length; i++) {
|
|
if (valueStart + i > startX + 3 + displayLabel.length) {
|
|
fb.setPixel(valueStart + i, y + 1, value[i], depth);
|
|
}
|
|
}
|
|
|
|
fb.setPixel(startX + visibleWidth - 1, y + 1, chars.v, depth);
|
|
|
|
// Bottom border
|
|
fb.setPixel(startX, y + 2, chars.bl, depth);
|
|
for (let i = 1; i < visibleWidth - 1; i++) {
|
|
fb.setPixel(startX + i, y + 2, chars.h, depth);
|
|
}
|
|
fb.setPixel(startX + visibleWidth - 1, y + 2, chars.br, depth);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TICKER TAPE - Scrolling news ticker
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Draw a scrolling ticker tape at bottom of screen
|
|
* @param fb - Framebuffer
|
|
* @param y - Y position
|
|
* @param items - Array of strings to scroll
|
|
* @param frame - Current frame for animation
|
|
* @param options - { separator: string, speed: number, depth: number }
|
|
*/
|
|
function tickerTape(fb, y, items, frame, options = {}) {
|
|
const { separator = ' ▸ ', speed = 0.5, depth = 5 } = options;
|
|
|
|
// Build full ticker string
|
|
const tickerText = items.join(separator) + separator;
|
|
const doubledText = tickerText + tickerText; // Double for seamless loop
|
|
|
|
// Calculate scroll offset
|
|
const offset = Math.floor(frame * speed) % tickerText.length;
|
|
|
|
// Draw ticker background line
|
|
for (let x = 0; x < fb.width; x++) {
|
|
const charIdx = (x + offset) % doubledText.length;
|
|
fb.setPixel(x, y, doubledText[charIdx], depth);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// BREAKING NEWS BANNER
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Draw a breaking news banner with optional flash effect
|
|
* @param fb - Framebuffer
|
|
* @param y - Y position
|
|
* @param text - Breaking news text
|
|
* @param frame - Current frame for flash effect
|
|
* @param options - { flash: boolean, depth: number }
|
|
*/
|
|
function breakingBanner(fb, y, text, frame, options = {}) {
|
|
const { flash = true, depth = 10 } = options;
|
|
|
|
const totalWidth = text.length + 8;
|
|
const x = Math.floor((fb.width - totalWidth) / 2);
|
|
|
|
// Flash effect - alternate between filled and empty style
|
|
const isFlash = flash && Math.floor(frame / 8) % 2 === 0;
|
|
|
|
// Top border
|
|
fb.setPixel(x, y, '╔', depth);
|
|
for (let i = 1; i < totalWidth - 1; i++) {
|
|
fb.setPixel(x + i, y, '═', depth);
|
|
}
|
|
fb.setPixel(x + totalWidth - 1, y, '╗', depth);
|
|
|
|
// Content line
|
|
fb.setPixel(x, y + 1, '║', depth);
|
|
|
|
// Flashing indicator
|
|
const indicator = isFlash ? ' ⚡ ' : ' ';
|
|
for (let i = 0; i < 3; i++) {
|
|
fb.setPixel(x + 1 + i, y + 1, indicator[i], depth);
|
|
}
|
|
|
|
// Text
|
|
for (let i = 0; i < text.length; i++) {
|
|
fb.setPixel(x + 4 + i, y + 1, text[i], depth);
|
|
}
|
|
|
|
// Flashing indicator (end)
|
|
for (let i = 0; i < 3; i++) {
|
|
fb.setPixel(x + 4 + text.length + i, y + 1, indicator[2 - i], depth);
|
|
}
|
|
|
|
fb.setPixel(x + totalWidth - 1, y + 1, '║', depth);
|
|
|
|
// Bottom border
|
|
fb.setPixel(x, y + 2, '╚', depth);
|
|
for (let i = 1; i < totalWidth - 1; i++) {
|
|
fb.setPixel(x + i, y + 2, '═', depth);
|
|
}
|
|
fb.setPixel(x + totalWidth - 1, y + 2, '╝', depth);
|
|
}
|
|
|
|
// ============================================================================
|
|
// LIVE INDICATOR - Blinking "LIVE" badge
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Draw a blinking LIVE indicator
|
|
* @param fb - Framebuffer
|
|
* @param x - X position
|
|
* @param y - Y position
|
|
* @param frame - Current frame for blink effect
|
|
* @param options - { speed: number, depth: number }
|
|
*/
|
|
function liveIndicator(fb, x, y, frame, options = {}) {
|
|
const { speed = 0.3, depth = 10 } = options;
|
|
|
|
// Blink the dot
|
|
const showDot = Math.sin(frame * speed) > 0;
|
|
const dot = showDot ? '●' : '○';
|
|
|
|
const text = `${dot} LIVE`;
|
|
for (let i = 0; i < text.length; i++) {
|
|
fb.setPixel(x + i, y, text[i], depth);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// SEGMENT TITLE - News segment header
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Draw a segment title with decorative elements
|
|
* @param fb - Framebuffer
|
|
* @param y - Y position
|
|
* @param title - Segment title (e.g., "TOP STORIES")
|
|
* @param progress - Animation progress 0-1
|
|
* @param options - { style: 'bracket'|'arrow'|'box', depth: number }
|
|
*/
|
|
function segmentTitle(fb, y, title, progress, options = {}) {
|
|
const { style = 'arrow', depth = 5 } = options;
|
|
|
|
const easedProgress = easeOut(progress);
|
|
const visibleChars = Math.floor(easedProgress * title.length);
|
|
const visibleTitle = title.slice(0, visibleChars);
|
|
|
|
let fullText;
|
|
switch (style) {
|
|
case 'bracket':
|
|
fullText = `【 ${visibleTitle} 】`;
|
|
break;
|
|
case 'box':
|
|
fullText = `┃ ${visibleTitle} ┃`;
|
|
break;
|
|
case 'arrow':
|
|
default:
|
|
fullText = `▸▸▸ ${visibleTitle}`;
|
|
}
|
|
|
|
const x = Math.floor((fb.width - fullText.length) / 2);
|
|
|
|
for (let i = 0; i < fullText.length; i++) {
|
|
fb.setPixel(x + i, y, fullText[i], depth);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// STAT COUNTER - Animated number reveal
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Animate a number counting up
|
|
* @param fb - Framebuffer
|
|
* @param x - X position
|
|
* @param y - Y position
|
|
* @param targetValue - Final number to display
|
|
* @param progress - Animation progress 0-1
|
|
* @param options - { prefix: string, suffix: string, depth: number }
|
|
*/
|
|
function statCounter(fb, x, y, targetValue, progress, options = {}) {
|
|
const { prefix = '', suffix = '', depth = 5, commas = true } = options;
|
|
|
|
const easedProgress = easeOut(progress);
|
|
const currentValue = Math.floor(easedProgress * targetValue);
|
|
|
|
// Format with commas if requested
|
|
let valueStr = currentValue.toString();
|
|
if (commas && currentValue >= 1000) {
|
|
valueStr = currentValue.toLocaleString();
|
|
}
|
|
|
|
const fullText = `${prefix}${valueStr}${suffix}`;
|
|
|
|
for (let i = 0; i < fullText.length; i++) {
|
|
fb.setPixel(x + i, y, fullText[i], depth);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// WEATHER FORECAST STYLE - Bar chart display
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Draw a horizontal bar chart (weather forecast style)
|
|
* @param fb - Framebuffer
|
|
* @param x - X position
|
|
* @param y - Y position
|
|
* @param label - Label for this bar
|
|
* @param value - Value (0-1 for percentage)
|
|
* @param maxWidth - Maximum width of bar
|
|
* @param progress - Animation progress 0-1
|
|
* @param options - { char: string, depth: number }
|
|
*/
|
|
function forecastBar(fb, x, y, label, value, maxWidth, progress, options = {}) {
|
|
const { char = '█', emptyChar = '░', depth = 5 } = options;
|
|
|
|
const labelWidth = 12;
|
|
const barWidth = maxWidth - labelWidth - 2;
|
|
|
|
// Draw label (right-aligned in label area)
|
|
const paddedLabel = label.slice(0, labelWidth).padStart(labelWidth);
|
|
for (let i = 0; i < paddedLabel.length; i++) {
|
|
fb.setPixel(x + i, y, paddedLabel[i], depth);
|
|
}
|
|
|
|
// Separator
|
|
fb.setPixel(x + labelWidth, y, ' ', depth);
|
|
|
|
// Draw bar
|
|
const animatedValue = value * easeOut(progress);
|
|
const filledWidth = Math.floor(animatedValue * barWidth);
|
|
|
|
for (let i = 0; i < barWidth; i++) {
|
|
const barChar = i < filledWidth ? char : emptyChar;
|
|
fb.setPixel(x + labelWidth + 1 + i, y, barChar, depth);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// SPLIT WIPE TRANSITION - News-style double wipe
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Split wipe from center outward (news transition style)
|
|
* @param fb - Framebuffer
|
|
* @param progress - Transition progress 0-1
|
|
*/
|
|
function splitWipe(fb, progress) {
|
|
const centerY = Math.floor(fb.height / 2);
|
|
const halfHeight = fb.height / 2;
|
|
const wipeDistance = Math.floor(easeOut(progress) * halfHeight);
|
|
|
|
// Wipe from center upward
|
|
for (let y = centerY - wipeDistance; y >= 0; y--) {
|
|
for (let x = 0; x < fb.width; x++) {
|
|
if (centerY - y > wipeDistance) {
|
|
fb.setPixel(x, y, ' ', -100);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wipe from center downward
|
|
for (let y = centerY + wipeDistance; y < fb.height; y++) {
|
|
for (let x = 0; x < fb.width; x++) {
|
|
if (y - centerY > wipeDistance) {
|
|
fb.setPixel(x, y, ' ', -100);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// PUSH TRANSITION - Content pushes off screen
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Push transition - old content slides out as new slides in
|
|
* @param fb - Framebuffer
|
|
* @param progress - Transition progress 0-1
|
|
* @param direction - 'left', 'right', 'up', 'down'
|
|
*/
|
|
function pushTransition(fb, progress, direction = 'left') {
|
|
const easedProgress = easeOut(progress);
|
|
|
|
if (direction === 'left' || direction === 'right') {
|
|
const offset = Math.floor(easedProgress * fb.width);
|
|
const dir = direction === 'left' ? 1 : -1;
|
|
|
|
// Clear the area being pushed into
|
|
for (let y = 0; y < fb.height; y++) {
|
|
for (let i = 0; i < offset; i++) {
|
|
const x = direction === 'left' ? fb.width - 1 - i : i;
|
|
fb.setPixel(x, y, ' ', -100);
|
|
}
|
|
}
|
|
} else {
|
|
const offset = Math.floor(easedProgress * fb.height);
|
|
|
|
for (let x = 0; x < fb.width; x++) {
|
|
for (let i = 0; i < offset; i++) {
|
|
const y = direction === 'up' ? fb.height - 1 - i : i;
|
|
fb.setPixel(x, y, ' ', -100);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// HEADLINE CRAWL - Typewriter with cursor flash
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Draw headline with news-style typewriter and blinking cursor
|
|
* @param fb - Framebuffer
|
|
* @param y - Y position
|
|
* @param text - Headline text
|
|
* @param progress - Animation progress 0-1
|
|
* @param frame - Current frame for cursor blink
|
|
* @param options - { depth: number }
|
|
*/
|
|
function headlineCrawl(fb, y, text, progress, frame, options = {}) {
|
|
const { depth = 5, centered = true } = options;
|
|
|
|
const visibleChars = Math.floor(progress * text.length);
|
|
const visibleText = text.slice(0, visibleChars);
|
|
|
|
const x = centered
|
|
? Math.floor((fb.width - text.length) / 2)
|
|
: 2;
|
|
|
|
// Draw visible text
|
|
for (let i = 0; i < visibleText.length; i++) {
|
|
fb.setPixel(x + i, y, visibleText[i], depth);
|
|
}
|
|
|
|
// Blinking block cursor at end
|
|
if (progress < 1) {
|
|
const cursorChar = Math.floor(frame / 6) % 2 === 0 ? '█' : ' ';
|
|
fb.setPixel(x + visibleChars, y, cursorChar, depth);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// COUNTDOWN REVEAL - "3... 2... 1..." style countdown
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Draw a dramatic countdown
|
|
* @param fb - Framebuffer
|
|
* @param progress - Animation progress 0-1
|
|
* @param options - { numbers: array, depth: number }
|
|
*/
|
|
function countdownReveal(fb, progress, options = {}) {
|
|
const { numbers = ['3', '2', '1', 'GO!'], depth = 10 } = options;
|
|
|
|
const numIdx = Math.floor(progress * numbers.length);
|
|
if (numIdx >= numbers.length) return;
|
|
|
|
const current = numbers[numIdx];
|
|
const phaseProgress = (progress * numbers.length) % 1;
|
|
|
|
// Scale effect
|
|
const scale = 1 + (1 - phaseProgress) * 0.5;
|
|
|
|
const x = Math.floor((fb.width - current.length) / 2);
|
|
const y = Math.floor(fb.height / 2);
|
|
|
|
for (let i = 0; i < current.length; i++) {
|
|
fb.setPixel(x + i, y, current[i], depth);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// NEWS ARTICLE - Full article card display
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Draw a news article card (like a newspaper article)
|
|
* @param fb - Framebuffer
|
|
* @param x - X position (left edge)
|
|
* @param y - Y position (top edge)
|
|
* @param article - { headline, subhead?, body?, stat?, statLabel?, category? }
|
|
* @param progress - Animation progress 0-1
|
|
* @param options - { width, style, depth }
|
|
*/
|
|
function newsArticle(fb, x, y, article, progress, options = {}) {
|
|
const {
|
|
width = 40,
|
|
style = 'boxed', // 'boxed', 'minimal', 'breaking'
|
|
depth = 5,
|
|
} = options;
|
|
|
|
const {
|
|
headline = '',
|
|
subhead = '',
|
|
body = '',
|
|
stat = '',
|
|
statLabel = '',
|
|
category = '',
|
|
} = article;
|
|
|
|
const easedProgress = easeOut(progress);
|
|
if (easedProgress < 0.05) return;
|
|
|
|
let currentY = y;
|
|
|
|
// Calculate visible width for animation
|
|
const visibleWidth = Math.floor(easedProgress * width);
|
|
if (visibleWidth < 5) return;
|
|
|
|
// Draw box border if boxed style
|
|
if (style === 'boxed' || style === 'breaking') {
|
|
const borderChar = style === 'breaking' ? '═' : '─';
|
|
const cornerTL = style === 'breaking' ? '╔' : '┌';
|
|
const cornerTR = style === 'breaking' ? '╗' : '┐';
|
|
|
|
// Top border
|
|
fb.setPixel(x, currentY, cornerTL, depth);
|
|
for (let i = 1; i < visibleWidth - 1; i++) {
|
|
fb.setPixel(x + i, currentY, borderChar, depth);
|
|
}
|
|
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, cornerTR, depth);
|
|
currentY++;
|
|
}
|
|
|
|
// Category tag
|
|
if (category && easedProgress > 0.2) {
|
|
const catText = ` ${category} `;
|
|
const catStart = x + 2;
|
|
for (let i = 0; i < catText.length && catStart + i < x + visibleWidth - 1; i++) {
|
|
fb.setPixel(catStart + i, currentY, catText[i], depth + 1);
|
|
}
|
|
currentY++;
|
|
}
|
|
|
|
// Headline
|
|
if (headline && easedProgress > 0.3) {
|
|
const headlineProgress = Math.min(1, (easedProgress - 0.3) / 0.3);
|
|
const visibleHeadline = headline.slice(0, Math.floor(headlineProgress * headline.length));
|
|
const headStart = x + 2;
|
|
for (let i = 0; i < visibleHeadline.length && headStart + i < x + visibleWidth - 2; i++) {
|
|
fb.setPixel(headStart + i, currentY, visibleHeadline[i], depth + 2);
|
|
}
|
|
currentY++;
|
|
}
|
|
|
|
// Subhead
|
|
if (subhead && easedProgress > 0.5) {
|
|
const subStart = x + 2;
|
|
const displaySub = subhead.slice(0, visibleWidth - 4);
|
|
for (let i = 0; i < displaySub.length; i++) {
|
|
fb.setPixel(subStart + i, currentY, displaySub[i], depth);
|
|
}
|
|
currentY++;
|
|
}
|
|
|
|
// Divider line
|
|
if ((body || stat) && easedProgress > 0.55) {
|
|
for (let i = 2; i < Math.min(visibleWidth - 2, 15); i++) {
|
|
fb.setPixel(x + i, currentY, '·', depth);
|
|
}
|
|
currentY++;
|
|
}
|
|
|
|
// Body text (wrap if needed)
|
|
if (body && easedProgress > 0.6) {
|
|
const bodyProgress = Math.min(1, (easedProgress - 0.6) / 0.3);
|
|
const maxBodyWidth = visibleWidth - 4;
|
|
const words = body.split(' ');
|
|
let line = '';
|
|
let lineCount = 0;
|
|
const maxLines = 3;
|
|
|
|
for (const word of words) {
|
|
if ((line + ' ' + word).length > maxBodyWidth) {
|
|
// Draw current line
|
|
const visibleLine = line.slice(0, Math.floor(bodyProgress * line.length));
|
|
for (let i = 0; i < visibleLine.length; i++) {
|
|
fb.setPixel(x + 2 + i, currentY, visibleLine[i], depth);
|
|
}
|
|
currentY++;
|
|
lineCount++;
|
|
if (lineCount >= maxLines) break;
|
|
line = word;
|
|
} else {
|
|
line = line ? line + ' ' + word : word;
|
|
}
|
|
}
|
|
// Draw remaining line
|
|
if (line && lineCount < maxLines) {
|
|
const visibleLine = line.slice(0, Math.floor(bodyProgress * line.length));
|
|
for (let i = 0; i < visibleLine.length; i++) {
|
|
fb.setPixel(x + 2 + i, currentY, visibleLine[i], depth);
|
|
}
|
|
currentY++;
|
|
}
|
|
}
|
|
|
|
// Big stat display
|
|
if (stat && easedProgress > 0.7) {
|
|
const statProgress = Math.min(1, (easedProgress - 0.7) / 0.2);
|
|
const statStr = typeof stat === 'number'
|
|
? Math.floor(statProgress * stat).toLocaleString()
|
|
: stat;
|
|
const statStart = x + 2;
|
|
for (let i = 0; i < statStr.length && statStart + i < x + visibleWidth - 2; i++) {
|
|
fb.setPixel(statStart + i, currentY, statStr[i], depth + 3);
|
|
}
|
|
currentY++;
|
|
|
|
if (statLabel) {
|
|
for (let i = 0; i < statLabel.length && statStart + i < x + visibleWidth - 2; i++) {
|
|
fb.setPixel(statStart + i, currentY, statLabel[i], depth);
|
|
}
|
|
currentY++;
|
|
}
|
|
}
|
|
|
|
// Bottom border
|
|
if (style === 'boxed' || style === 'breaking') {
|
|
const borderChar = style === 'breaking' ? '═' : '─';
|
|
const cornerBL = style === 'breaking' ? '╚' : '└';
|
|
const cornerBR = style === 'breaking' ? '╝' : '┘';
|
|
|
|
fb.setPixel(x, currentY, cornerBL, depth);
|
|
for (let i = 1; i < visibleWidth - 1; i++) {
|
|
fb.setPixel(x + i, currentY, borderChar, depth);
|
|
}
|
|
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, cornerBR, depth);
|
|
}
|
|
|
|
return currentY - y + 1; // Return height
|
|
}
|
|
|
|
// ============================================================================
|
|
// NEWS GRID - Multiple articles in a grid layout
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Draw multiple news articles in a grid
|
|
* @param fb - Framebuffer
|
|
* @param articles - Array of article objects
|
|
* @param progress - Overall animation progress 0-1
|
|
* @param options - { columns, startY, spacing, articleWidth, staggerDelay }
|
|
*/
|
|
function newsGrid(fb, articles, progress, options = {}) {
|
|
const {
|
|
columns = 2,
|
|
startY = 4,
|
|
startX = 2,
|
|
spacing = 2,
|
|
articleWidth = 35,
|
|
staggerDelay = 0.15,
|
|
style = 'boxed',
|
|
} = options;
|
|
|
|
let currentY = startY;
|
|
let maxHeightInRow = 0;
|
|
|
|
articles.forEach((article, idx) => {
|
|
const col = idx % columns;
|
|
const row = Math.floor(idx / columns);
|
|
|
|
// Calculate staggered progress for this article
|
|
const articleProgress = Math.max(0, Math.min(1,
|
|
(progress - idx * staggerDelay) / (1 - (articles.length - 1) * staggerDelay)
|
|
));
|
|
|
|
if (articleProgress <= 0) return;
|
|
|
|
const x = startX + col * (articleWidth + spacing);
|
|
|
|
// Reset Y for new row
|
|
if (col === 0 && row > 0) {
|
|
currentY += maxHeightInRow + spacing;
|
|
maxHeightInRow = 0;
|
|
}
|
|
|
|
const height = newsArticle(fb, x, currentY, article, articleProgress, {
|
|
width: articleWidth,
|
|
style,
|
|
});
|
|
|
|
maxHeightInRow = Math.max(maxHeightInRow, height || 0);
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// HEADLINE CAROUSEL - Rotating headlines
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Show headlines one at a time with transitions
|
|
* @param fb - Framebuffer
|
|
* @param headlines - Array of headline strings or article objects
|
|
* @param progress - Animation progress 0-1 (cycles through all headlines)
|
|
* @param frame - Current frame for effects
|
|
* @param options - { y, style }
|
|
*/
|
|
function headlineCarousel(fb, headlines, progress, frame, options = {}) {
|
|
const { y = 10, style = 'crawl', depth = 5 } = options;
|
|
|
|
const numHeadlines = headlines.length;
|
|
const headlineIdx = Math.floor(progress * numHeadlines) % numHeadlines;
|
|
const headlineProgress = (progress * numHeadlines) % 1;
|
|
|
|
const headline = headlines[headlineIdx];
|
|
const text = typeof headline === 'string' ? headline : headline.headline;
|
|
|
|
if (style === 'crawl') {
|
|
headlineCrawl(fb, y, text, headlineProgress, frame, { depth });
|
|
} else if (style === 'slide') {
|
|
if (headlineProgress < 0.2) {
|
|
// Slide in
|
|
const slideP = headlineProgress / 0.2;
|
|
slideIn(fb, y, text, slideP, { from: 'right' });
|
|
} else if (headlineProgress > 0.8) {
|
|
// Slide out
|
|
const slideP = (headlineProgress - 0.8) / 0.2;
|
|
slideOut(fb, y, text, slideP, { to: 'left' });
|
|
} else {
|
|
// Static
|
|
fb.drawCenteredText(y, text);
|
|
}
|
|
} else {
|
|
// Fade style
|
|
const opacity = headlineProgress < 0.2 ? headlineProgress / 0.2
|
|
: headlineProgress > 0.8 ? 1 - (headlineProgress - 0.8) / 0.2
|
|
: 1;
|
|
if (opacity > 0.5) {
|
|
fb.drawCenteredText(y, text);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper for slideIn/slideOut (if not already available)
|
|
function slideIn(fb, y, text, progress, options = {}) {
|
|
const { from = 'left', depth = 5 } = options;
|
|
const x = from === 'left'
|
|
? Math.floor(-text.length + easeOut(progress) * (fb.width / 2 + text.length / 2))
|
|
: Math.floor(fb.width - easeOut(progress) * (fb.width / 2 + text.length / 2));
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
if (x + i >= 0 && x + i < fb.width) {
|
|
fb.setPixel(x + i, y, text[i], depth);
|
|
}
|
|
}
|
|
}
|
|
|
|
function slideOut(fb, y, text, progress, options = {}) {
|
|
const { to = 'left', depth = 5 } = options;
|
|
const centerX = Math.floor((fb.width - text.length) / 2);
|
|
const offset = Math.floor(easeOut(progress) * (fb.width / 2 + text.length));
|
|
const x = to === 'left' ? centerX - offset : centerX + offset;
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
if (x + i >= 0 && x + i < fb.width) {
|
|
fb.setPixel(x + i, y, text[i], depth);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// PROJECT SPOTLIGHT - Dedicated project article display
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Draw a featured project as a full news article
|
|
* @param fb - Framebuffer
|
|
* @param project - { name, commits, description?, body?, rank? }
|
|
* @param progress - Animation progress 0-1
|
|
* @param frame - Current frame for effects
|
|
* @param options - { centered, width }
|
|
*/
|
|
function accomplishmentSpotlight(fb, project, progress, frame, options = {}) {
|
|
const {
|
|
centered = true,
|
|
width = 50,
|
|
y = 5,
|
|
depth = 5,
|
|
showRank = true,
|
|
} = options;
|
|
|
|
const { name, commits, description, body, rank } = project;
|
|
|
|
const x = centered ? Math.floor((fb.width - width) / 2) : 2;
|
|
const easedProgress = easeOut(progress);
|
|
|
|
if (easedProgress < 0.1) return;
|
|
|
|
// Box border with double lines for emphasis
|
|
const visibleWidth = Math.floor(easedProgress * width);
|
|
let currentY = y;
|
|
|
|
// Helper to draw a row with side borders
|
|
function drawRow(text, textDepth = depth) {
|
|
fb.setPixel(x, currentY, '║', depth);
|
|
for (let i = 0; i < text.length && i < visibleWidth - 2; i++) {
|
|
fb.setPixel(x + 1 + i, currentY, text[i], textDepth);
|
|
}
|
|
// Fill remaining space
|
|
for (let i = text.length; i < visibleWidth - 2; i++) {
|
|
fb.setPixel(x + 1 + i, currentY, ' ', depth);
|
|
}
|
|
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, '║', depth);
|
|
currentY++;
|
|
}
|
|
|
|
// Top border
|
|
fb.setPixel(x, currentY, '╔', depth);
|
|
for (let i = 1; i < visibleWidth - 1; i++) {
|
|
fb.setPixel(x + i, currentY, '═', depth);
|
|
}
|
|
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, '╗', depth);
|
|
currentY++;
|
|
|
|
// Rank badge
|
|
if (showRank && rank && easedProgress > 0.15) {
|
|
drawRow(` #${rank} PROJECT`, depth + 1);
|
|
}
|
|
|
|
// Project name
|
|
if (easedProgress > 0.25) {
|
|
const nameProgress = Math.min(1, (easedProgress - 0.25) / 0.15);
|
|
const visibleName = name.slice(0, Math.floor(nameProgress * name.length));
|
|
drawRow(` ${visibleName}`, depth + 2);
|
|
}
|
|
|
|
// Divider after name
|
|
if (easedProgress > 0.35) {
|
|
fb.setPixel(x, currentY, '╟', depth);
|
|
for (let i = 1; i < visibleWidth - 1; i++) {
|
|
fb.setPixel(x + i, currentY, '─', depth);
|
|
}
|
|
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, '╢', depth);
|
|
currentY++;
|
|
}
|
|
|
|
// Commits stat with animated counter
|
|
if (easedProgress > 0.4) {
|
|
const counterProgress = Math.min(1, (easedProgress - 0.4) / 0.15);
|
|
const displayCommits = Math.floor(counterProgress * commits);
|
|
drawRow(` ${displayCommits.toLocaleString()} COMMITS`, depth + 3);
|
|
}
|
|
|
|
// Description (short tagline)
|
|
if (description && easedProgress > 0.5) {
|
|
const descProgress = Math.min(1, (easedProgress - 0.5) / 0.1);
|
|
const maxDesc = visibleWidth - 4;
|
|
const displayDesc = description.slice(0, Math.min(maxDesc, Math.floor(descProgress * description.length)));
|
|
drawRow(` ${displayDesc}`, depth);
|
|
}
|
|
|
|
// Body text (longer description with word wrap)
|
|
if (body && easedProgress > 0.55) {
|
|
// Add a small divider
|
|
fb.setPixel(x, currentY, '║', depth);
|
|
for (let i = 1; i < Math.min(visibleWidth - 1, 20); i++) {
|
|
fb.setPixel(x + i, currentY, '·', depth);
|
|
}
|
|
for (let i = 20; i < visibleWidth - 1; i++) {
|
|
fb.setPixel(x + i, currentY, ' ', depth);
|
|
}
|
|
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, '║', depth);
|
|
currentY++;
|
|
|
|
// Word-wrap the body text
|
|
const bodyProgress = Math.min(1, (easedProgress - 0.55) / 0.25);
|
|
const maxLineWidth = visibleWidth - 6;
|
|
const words = body.split(' ');
|
|
const lines = [];
|
|
let currentLine = '';
|
|
|
|
for (const word of words) {
|
|
if ((currentLine + ' ' + word).trim().length > maxLineWidth) {
|
|
if (currentLine) lines.push(currentLine);
|
|
currentLine = word;
|
|
} else {
|
|
currentLine = currentLine ? currentLine + ' ' + word : word;
|
|
}
|
|
}
|
|
if (currentLine) lines.push(currentLine);
|
|
|
|
// Draw visible portion of body
|
|
const totalChars = lines.join('').length;
|
|
let charsDrawn = 0;
|
|
const charsToShow = Math.floor(bodyProgress * totalChars);
|
|
|
|
for (const line of lines) {
|
|
if (charsDrawn >= charsToShow) break;
|
|
const lineCharsToShow = Math.min(line.length, charsToShow - charsDrawn);
|
|
const visibleLine = line.slice(0, lineCharsToShow);
|
|
drawRow(` ${visibleLine}`, depth);
|
|
charsDrawn += line.length;
|
|
}
|
|
}
|
|
|
|
// Bottom border
|
|
if (easedProgress > 0.85) {
|
|
fb.setPixel(x, currentY, '╚', depth);
|
|
for (let i = 1; i < visibleWidth - 1; i++) {
|
|
fb.setPixel(x + i, currentY, '═', depth);
|
|
}
|
|
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, '╝', depth);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// SCROLLING NEWS FEED - Vertical scrolling headlines
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Draw a vertically scrolling news feed
|
|
* @param fb - Framebuffer
|
|
* @param items - Array of { headline, category?, time? }
|
|
* @param frame - Current frame for scroll animation
|
|
* @param options - { x, y, width, height, speed }
|
|
*/
|
|
function newsFeed(fb, items, frame, options = {}) {
|
|
const {
|
|
x = 2,
|
|
y = 4,
|
|
width = 40,
|
|
height = 15,
|
|
speed = 0.1,
|
|
depth = 5,
|
|
} = options;
|
|
|
|
const lineHeight = 3;
|
|
const totalHeight = items.length * lineHeight;
|
|
const scrollOffset = (frame * speed) % totalHeight;
|
|
|
|
// Draw border
|
|
fb.setPixel(x, y - 1, '┌', depth);
|
|
for (let i = 1; i < width - 1; i++) {
|
|
fb.setPixel(x + i, y - 1, '─', depth);
|
|
}
|
|
fb.setPixel(x + width - 1, y - 1, '┐', depth);
|
|
|
|
items.forEach((item, idx) => {
|
|
const itemY = y + idx * lineHeight - Math.floor(scrollOffset);
|
|
|
|
// Only draw if within visible area
|
|
if (itemY >= y && itemY < y + height) {
|
|
// Category/time
|
|
if (item.category) {
|
|
const catText = `[${item.category}]`;
|
|
for (let i = 0; i < catText.length && x + 1 + i < x + width - 1; i++) {
|
|
fb.setPixel(x + 1 + i, itemY, catText[i], depth);
|
|
}
|
|
}
|
|
|
|
// Headline
|
|
const headY = itemY + 1;
|
|
if (headY < y + height) {
|
|
const maxLen = width - 3;
|
|
const headline = item.headline.slice(0, maxLen);
|
|
for (let i = 0; i < headline.length; i++) {
|
|
fb.setPixel(x + 2 + i, headY, headline[i], depth + 1);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Bottom border
|
|
fb.setPixel(x, y + height, '└', depth);
|
|
for (let i = 1; i < width - 1; i++) {
|
|
fb.setPixel(x + i, y + height, '─', depth);
|
|
}
|
|
fb.setPixel(x + width - 1, y + height, '┘', depth);
|
|
}
|
|
|
|
// Make available globally for browser script tag usage
|
|
if (typeof globalThis !== 'undefined') {
|
|
Object.assign(globalThis, {
|
|
lowerThird,
|
|
tickerTape,
|
|
breakingBanner,
|
|
liveIndicator,
|
|
segmentTitle,
|
|
statCounter,
|
|
forecastBar,
|
|
splitWipe,
|
|
pushTransition,
|
|
headlineCrawl,
|
|
countdownReveal,
|
|
// New article helpers
|
|
newsArticle,
|
|
newsGrid,
|
|
headlineCarousel,
|
|
accomplishmentSpotlight,
|
|
newsFeed,
|
|
});
|
|
}
|
|
})();
|