Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux legacy, usage multi-features, hints de coût, anti-flash thème et hydratation admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
496 lines
40 KiB
TypeScript
496 lines
40 KiB
TypeScript
/**
|
||
* Server-side HTML renderer for presentations.
|
||
* Takes a structured JSON spec and produces a complete standalone HTML file.
|
||
* The AI only needs to output data — this module handles all the visual rendering.
|
||
*/
|
||
|
||
// ── Recipe definitions ──────────────────────────────────────────────────────
|
||
interface Recipe {
|
||
bg: string; text: string; textSecondary: string; textMuted: string
|
||
accent1: string; accent2: string
|
||
glassBg: string; glassBorder: string; svgGrid: string
|
||
fontDisplay: string; fontBody: string; fontUrl: string
|
||
isDark: boolean
|
||
}
|
||
|
||
const RECIPES: Record<string, Recipe> = {
|
||
'midnight-cathedral': { bg: '#0a0f1e', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#C9A84C', accent2: '#E8D5B5', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Playfair Display'", fontBody: "'Source Sans 3'", fontUrl: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Source+Sans+3:wght@300;400;600;700&display=swap', isDark: true },
|
||
'aurora-borealis': { bg: '#0f0a2a', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#7C3AED', accent2: '#06B6D4', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Instrument Serif'", fontBody: "'Inter'", fontUrl: 'https://fonts.googleapis.com/css2?family=Instrument+Serif&family=Inter:wght@300;400;600;700;900&display=swap', isDark: true },
|
||
'tokyo-neon': { bg: '#0a0a0f', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#FF006E', accent2: '#3A86FF', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Bebas Neue'", fontBody: "'Inter'", fontUrl: 'https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Inter:wght@300;400;600;700;900&display=swap', isDark: true },
|
||
'sunlit-gallery': { bg: '#FAF7F0', text: '#1C1C1C', textSecondary: '#475569', textMuted: '#64748B', accent1: '#D4A574', accent2: '#5B9BD5', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Abril Fatface'", fontBody: "'Epilogue'", fontUrl: 'https://fonts.googleapis.com/css2?family=Abril+Fatface&family=Epilogue:wght@300;400;600;700;900&display=swap', isDark: false },
|
||
'clinical-precision': { bg: '#F8FAFC', text: '#0F172A', textSecondary: '#475569', textMuted: '#64748B', accent1: '#0891B2', accent2: '#34D399', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Manrope'", fontBody: "'Manrope'", fontUrl: 'https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;600;700;800;900&display=swap', isDark: false },
|
||
'venture-pitch': { bg: '#18181B', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#F97316', accent2: '#14B8A6', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Archivo Black'", fontBody: "'DM Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=Archivo+Black&family=DM+Sans:wght@300;400;500;700&display=swap', isDark: true },
|
||
'forest-floor': { bg: '#0D1B0E', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#22C55E', accent2: '#A3B18A', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Outfit'", fontBody: "'Outfit'", fontUrl: 'https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800;900&display=swap', isDark: true },
|
||
'steel-glass': { bg: '#292524', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#D4C5A9', accent2: '#94A3B8', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Prata'", fontBody: "'Work Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=Prata&family=Work+Sans:wght@300;400;600;700&display=swap', isDark: true },
|
||
'cyberpunk-terminal': { bg: '#0A0A0A', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#00FF41', accent2: '#FFA500', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'JetBrains Mono'", fontBody: "'JetBrains Mono'", fontUrl: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;600;700;800&display=swap', isDark: true },
|
||
'editorial-ink': { bg: '#FFFCF5', text: '#1A1A2E', textSecondary: '#475569', textMuted: '#64748B', accent1: '#1A1A2E', accent2: '#800020', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'DM Serif Display'", fontBody: "'DM Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@300;400;500;700&display=swap', isDark: false },
|
||
'coastal-morning': { bg: '#F0F7FF', text: '#0F172A', textSecondary: '#475569', textMuted: '#64748B', accent1: '#2563EB', accent2: '#F97066', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Plus Jakarta Sans'", fontBody: "'Plus Jakarta Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;600;700;800&display=swap', isDark: false },
|
||
'paper-studio': { bg: '#FEFCF8', text: '#1E293B', textSecondary: '#475569', textMuted: '#64748B', accent1: '#1E293B', accent2: '#C2410C', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Literata'", fontBody: "'Source Sans 3'", fontUrl: 'https://fonts.googleapis.com/css2?family=Literata:wght@400;700;900&family=Source+Sans+3:wght@300;400;600;700&display=swap', isDark: false },
|
||
'architectural-saas': { bg: '#F2F0E9', text: '#1C1C1C', textSecondary: '#475569', textMuted: '#64748B', accent1: '#A47148', accent2: '#4A4E69', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Playfair Display'", fontBody: "'Inter'", fontUrl: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Inter:wght@300;400;600;700;900&display=swap', isDark: false },
|
||
}
|
||
|
||
function resolveRecipe(name?: string): Recipe {
|
||
if (!name || name === 'auto') return RECIPES['architectural-saas']
|
||
// Normalize: underscores to dashes for consistency
|
||
const key = name.toLowerCase().replace(/[\s_]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
||
return RECIPES[key] ?? RECIPES['architectural-saas']
|
||
}
|
||
|
||
// ── Slide types ─────────────────────────────────────────────────────────────
|
||
export interface SlideTitle { type: 'title'; title: string; subtitle?: string }
|
||
export interface SlideBullets { type: 'bullets'; title: string; items: string[] }
|
||
export interface SlideChart { type: 'chart'; title: string; chartType: 'bar' | 'horizontal-bar' | 'line' | 'donut' | 'radar'; data: { label: string; value: number; color?: string }[]; subtitle?: string }
|
||
export interface SlideStats { type: 'stats'; title: string; stats: { value: string; label: string }[] }
|
||
export interface SlideTable { type: 'table'; title: string; headers: string[]; rows: string[][] }
|
||
export interface SlideCards { type: 'cards'; title: string; cards: { title: string; description: string }[] }
|
||
export interface SlideTimeline { type: 'timeline'; title: string; events: { date: string; title: string; description?: string }[] }
|
||
export interface SlideQuote { type: 'quote'; quote: string; author?: string; context?: string }
|
||
export interface SlideComparison { type: 'comparison'; title: string; left: { title: string; points: string[]; score?: string }; right: { title: string; points: string[]; score?: string } }
|
||
export interface SlideEquation { type: 'equation'; title: string; equations: { latex: string; label?: string }[]; explanation?: string }
|
||
export interface SlideImage { type: 'image'; title: string; url?: string; caption?: string }
|
||
export interface SlideSummary { type: 'summary'; title: string; items: string[] }
|
||
|
||
export type SlideSpec = SlideTitle | SlideBullets | SlideChart | SlideStats | SlideTable | SlideCards | SlideTimeline | SlideQuote | SlideComparison | SlideEquation | SlideImage | SlideSummary
|
||
|
||
export interface PresentationInput {
|
||
title: string
|
||
theme?: string
|
||
slides: SlideSpec[]
|
||
}
|
||
|
||
// ── Individual slide renderers ──────────────────────────────────────────────
|
||
function renderTitle(slide: SlideTitle, r: Recipe, idx: number): string {
|
||
return `<div class="slide" data-slide="${idx}">
|
||
${mesh(r)}
|
||
<canvas id="particles-${idx}" style="position:absolute;inset:0;z-index:1;pointer-events:none;width:100%;height:100%;"></canvas>
|
||
<div class="content" style="text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;">
|
||
<div class="reveal" style="width:52px;height:5px;background:${r.accent1};border-radius:3px;margin-bottom:24px;"></div>
|
||
<h1 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(2.8rem,5vw,4.5rem);font-weight:900;line-height:1.05;letter-spacing:-0.04em;background:linear-gradient(135deg,${r.accent1},${r.accent2});-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin:0;max-width:900px;">${esc(slide.title)}</h1>
|
||
${slide.subtitle ? `<p class="reveal" style="font-size:clamp(1rem,2vw,1.4rem);color:${r.textSecondary};margin-top:20px;max-width:640px;line-height:1.5;">${esc(slide.subtitle)}</p>` : ''}
|
||
</div>
|
||
</div>`
|
||
}
|
||
|
||
function renderBullets(slide: SlideBullets, r: Recipe, idx: number): string {
|
||
const list = (slide.items || []).filter(Boolean)
|
||
// Dense layout: 2-col grid when 4+ bullets (Hermes/OpenClaw: avoid sparse empty slides)
|
||
const twoCol = list.length >= 4
|
||
const items = list.map((item) => `
|
||
<div class="reveal" style="display:flex;align-items:flex-start;gap:14px;padding:12px 14px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;">
|
||
<span style="width:8px;height:8px;border-radius:50%;background:${r.accent1};margin-top:8px;flex-shrink:0;"></span>
|
||
<span style="font-size:clamp(0.95rem,1.6vw,1.15rem);line-height:1.55;color:${r.text};">${esc(item)}</span>
|
||
</div>`).join('')
|
||
return `<div class="slide" data-slide="${idx}">
|
||
${mesh(r)}
|
||
<div class="content">
|
||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
|
||
<div style="display:grid;grid-template-columns:${twoCol ? '1fr 1fr' : '1fr'};gap:12px;">${items}</div>
|
||
</div>
|
||
</div>`
|
||
}
|
||
|
||
function renderChart(slide: SlideChart, r: Recipe, idx: number): string {
|
||
const chartHtml = (() => {
|
||
switch (slide.chartType) {
|
||
case 'bar': return renderBarChart(slide.data, r)
|
||
case 'horizontal-bar': return renderHBarChart(slide.data, r)
|
||
case 'line': return renderLineChart(slide.data, r)
|
||
case 'donut': return renderDonutChart(slide.data, r)
|
||
case 'radar': return renderRadarChart(slide.data, r)
|
||
default: return renderBarChart(slide.data, r)
|
||
}
|
||
})()
|
||
return `<div class="slide" data-slide="${idx}">
|
||
${mesh(r)}
|
||
<div class="content">
|
||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3vw,2.2rem);font-weight:800;letter-spacing:-0.03em;margin:0;">${esc(slide.title)}</h2>
|
||
${slide.subtitle ? `<p class="reveal" style="font-size:0.95rem;color:${r.textMuted};margin:6px 0 0;">${esc(slide.subtitle)}</p>` : ''}
|
||
<div class="reveal" style="margin-top:32px;">${chartHtml}</div>
|
||
</div>
|
||
</div>`
|
||
}
|
||
|
||
function renderStats(slide: SlideStats, r: Recipe, idx: number): string {
|
||
const stats = (slide.stats || []).filter((s) => s && (s.value || s.label))
|
||
// Never force empty columns: 1 stat = full width, 2 = half, 3–4 = equal
|
||
const n = Math.max(1, stats.length)
|
||
const cols = n === 1 ? 1 : n === 2 ? 2 : n === 3 ? 3 : 4
|
||
const items = stats.map(s => `
|
||
<div class="reveal" style="text-align:center;padding:28px 16px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:16px;">
|
||
<div style="font-size:clamp(2.2rem,4vw,3.5rem);font-weight:900;line-height:1;background:linear-gradient(135deg,${r.accent1},${r.accent2});-webkit-background-clip:text;-webkit-text-fill-color:transparent;" data-count="${extractNum(s.value)}" data-suffix="${extractSuffix(s.value)}">${esc(s.value)}</div>
|
||
<div style="font-size:0.8rem;color:${r.textMuted};margin-top:10px;letter-spacing:0.12em;text-transform:uppercase;font-weight:600;">${esc(s.label)}</div>
|
||
</div>`).join('')
|
||
return `<div class="slide" data-slide="${idx}">
|
||
${mesh(r)}
|
||
<div class="content">
|
||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 24px;">${esc(slide.title)}</h2>
|
||
<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:16px;">${items}</div>
|
||
</div>
|
||
</div>`
|
||
}
|
||
|
||
function renderTable(slide: SlideTable, r: Recipe, idx: number): string {
|
||
const ths = slide.headers.map(h => `<th style="padding:12px 16px;text-align:left;font-size:0.75rem;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${r.accent1};border-bottom:2px solid ${r.accent1};">${esc(h)}</th>`).join('')
|
||
const rows = slide.rows.map((row, ri) => {
|
||
const tds = row.map(cell => `<td style="padding:11px 16px;font-size:0.9rem;color:${r.text};border-bottom:1px solid ${r.glassBorder};">${esc(cell)}</td>`).join('')
|
||
return `<tr style="background:${ri % 2 === 0 ? 'transparent' : r.glassBg};">${tds}</tr>`
|
||
}).join('')
|
||
return `<div class="slide" data-slide="${idx}">
|
||
${mesh(r)}
|
||
<div class="content">
|
||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:24px;"></div>
|
||
<div class="reveal" style="overflow:auto;border-radius:12px;border:1px solid ${r.glassBorder};">
|
||
<table style="width:100%;border-collapse:collapse;"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
}
|
||
|
||
function renderCards(slide: SlideCards, r: Recipe, idx: number): string {
|
||
const cards = (slide.cards || []).filter((c) => c && (c.title || c.description))
|
||
// 1 card → full width (no empty second column). 2 → 2 cols. 3 → 3. 4+ → 2x2
|
||
const n = Math.max(1, cards.length)
|
||
const cols = n === 1 ? 1 : n === 2 ? 2 : n === 3 ? 3 : 2
|
||
const items = cards.map((c, i) => `
|
||
<div class="reveal" style="padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;position:relative;overflow:hidden;min-height:120px;">
|
||
<span style="position:absolute;top:10px;right:14px;font-size:1.8rem;font-weight:900;color:${r.accent1};opacity:0.12;">${String(i + 1).padStart(2, '0')}</span>
|
||
<div style="width:24px;height:3px;background:${r.accent1};border-radius:2px;margin-bottom:12px;"></div>
|
||
<div style="font-size:1.05rem;font-weight:700;color:${r.text};margin-bottom:8px;">${esc(c.title)}</div>
|
||
<div style="font-size:0.9rem;color:${r.textSecondary};line-height:1.6;">${esc(c.description)}</div>
|
||
</div>`).join('')
|
||
return `<div class="slide" data-slide="${idx}">
|
||
${mesh(r)}
|
||
<div class="content">
|
||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 24px;">${esc(slide.title)}</h2>
|
||
<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:16px;">${items}</div>
|
||
</div>
|
||
</div>`
|
||
}
|
||
|
||
function renderTimeline(slide: SlideTimeline, r: Recipe, idx: number): string {
|
||
const items = slide.events.map((ev, i) => `
|
||
<div class="reveal" style="display:flex;align-items:flex-start;gap:20px;position:relative;padding-left:20px;">
|
||
<div style="position:absolute;left:0;top:8px;width:12px;height:12px;border-radius:50%;background:${r.accent1};border:3px solid ${r.bg};z-index:1;"></div>
|
||
<div>
|
||
<span style="font-size:0.7rem;font-weight:700;letter-spacing:0.15em;text-transform:uppercase;color:${r.accent1};">${esc(ev.date)}</span>
|
||
<div style="font-size:1rem;font-weight:700;color:${r.text};margin-top:2px;">${esc(ev.title)}</div>
|
||
${ev.description ? `<div style="font-size:0.85rem;color:${r.textSecondary};margin-top:4px;line-height:1.5;">${esc(ev.description)}</div>` : ''}
|
||
</div>
|
||
</div>`).join('')
|
||
return `<div class="slide" data-slide="${idx}">
|
||
${mesh(r)}
|
||
<div class="content">
|
||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
|
||
<div style="display:flex;flex-direction:column;gap:20px;border-left:2px solid ${r.accent1};padding-left:8px;margin-left:6px;">${items}</div>
|
||
</div>
|
||
</div>`
|
||
}
|
||
|
||
function renderQuote(slide: SlideQuote, r: Recipe, idx: number): string {
|
||
return `<div class="slide" data-slide="${idx}">
|
||
${mesh(r)}
|
||
<div class="content" style="display:flex;flex-direction:column;justify-content:center;height:100%;">
|
||
<div class="reveal" style="font-size:5rem;color:${r.accent1};opacity:0.4;line-height:0.6;font-family:Georgia,serif;">"</div>
|
||
<blockquote class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.5rem,3vw,2.2rem);font-weight:600;font-style:italic;line-height:1.4;letter-spacing:-0.02em;color:${r.text};margin:16px 0 0;max-width:860px;">${esc(slide.quote)}</blockquote>
|
||
${slide.author ? `<div class="reveal" style="display:flex;align-items:center;gap:12px;margin-top:28px;"><div style="width:36px;height:2px;background:${r.accent1};"></div><span style="font-size:0.85rem;font-weight:700;text-transform:uppercase;letter-spacing:0.12em;color:${r.accent1};">— ${esc(slide.author)}</span></div>` : ''}
|
||
${slide.context ? `<p class="reveal" style="font-size:0.95rem;color:${r.textSecondary};margin-top:20px;line-height:1.7;max-width:700px;">${esc(slide.context)}</p>` : ''}
|
||
</div>
|
||
</div>`
|
||
}
|
||
|
||
function renderComparison(slide: SlideComparison, r: Recipe, idx: number): string {
|
||
const col = (side: { title: string; points: string[]; score?: string }, accent: string) => {
|
||
const pts = side.points.map(p => `<li style="display:flex;gap:8px;font-size:0.88rem;color:${r.textSecondary};line-height:1.5;"><span style="color:${accent};font-weight:700;flex-shrink:0;">✓</span>${esc(p)}</li>`).join('')
|
||
return `<div class="reveal" style="padding:28px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:16px;border-top:3px solid ${accent};">
|
||
<div style="font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.2em;color:${accent};margin-bottom:14px;">${esc(side.title)}</div>
|
||
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:10px;">${pts}</ul>
|
||
${side.score ? `<div style="margin-top:20px;padding-top:16px;border-top:1px solid ${r.glassBorder};"><div style="font-size:1.5rem;font-weight:900;color:${accent};">${esc(side.score)}</div></div>` : ''}
|
||
</div>`
|
||
}
|
||
return `<div class="slide" data-slide="${idx}">
|
||
${mesh(r)}
|
||
<div class="content">
|
||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">${col(slide.left, r.accent1)}${col(slide.right, r.accent2)}</div>
|
||
</div>
|
||
</div>`
|
||
}
|
||
|
||
function renderEquation(slide: SlideEquation, r: Recipe, idx: number): string {
|
||
const eqs = (slide.equations || []).map((eq) => {
|
||
// data-latex for KaTeX client render; also show fallback monospaced text
|
||
const latex = eq.latex || ''
|
||
return `
|
||
<div class="reveal" style="text-align:center;padding:22px 24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;margin-bottom:14px;">
|
||
<div class="eq-latex" data-latex="${esc(latex)}" style="font-size:clamp(1.25rem,2.6vw,1.85rem);color:${r.text};overflow-x:auto;padding:8px 0;"></div>
|
||
<noscript><code style="font-size:1.1rem;">${esc(latex)}</code></noscript>
|
||
${eq.label ? `<div style="font-size:0.85rem;color:${r.textMuted};margin-top:10px;font-weight:600;">${esc(eq.label)}</div>` : ''}
|
||
</div>`
|
||
}).join('')
|
||
return `<div class="slide" data-slide="${idx}">
|
||
${mesh(r)}
|
||
<div class="content">
|
||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
|
||
${eqs || `<div class="reveal" style="color:${r.textMuted};">Aucune équation</div>`}
|
||
${slide.explanation ? `<p class="reveal" style="font-size:1rem;color:${r.textSecondary};line-height:1.7;margin-top:18px;max-width:780px;">${esc(slide.explanation)}</p>` : ''}
|
||
</div>
|
||
</div>`
|
||
}
|
||
|
||
function renderImage(slide: SlideImage, r: Recipe, idx: number): string {
|
||
const img = slide.url
|
||
? `<img src="${esc(slide.url)}" alt="${esc(slide.title)}" style="max-width:80%;max-height:60vh;border-radius:12px;object-fit:contain;" />`
|
||
: `<div style="width:400px;height:250px;background:${r.glassBg};border:1px dashed ${r.glassBorder};border-radius:12px;display:flex;align-items:center;justify-content:center;color:${r.textMuted};font-size:0.9rem;">Image placeholder</div>`
|
||
return `<div class="slide" data-slide="${idx}">
|
||
${mesh(r)}
|
||
<div class="content" style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;">
|
||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3vw,2.2rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
|
||
<div class="reveal">${img}</div>
|
||
${slide.caption ? `<p class="reveal" style="font-size:0.85rem;color:${r.textMuted};margin-top:16px;text-align:center;">${esc(slide.caption)}</p>` : ''}
|
||
</div>
|
||
</div>`
|
||
}
|
||
|
||
function renderSummary(slide: SlideSummary, r: Recipe, idx: number, isLast: boolean): string {
|
||
const items = slide.items.map(item => `
|
||
<div class="reveal" style="display:flex;align-items:center;gap:16px;padding:14px 20px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;">
|
||
<div style="width:26px;height:26px;min-width:26px;background:${r.accent1};border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:13px;color:#fff;font-weight:900;">✓</div>
|
||
<span style="font-size:1rem;line-height:1.45;color:${r.text};">${esc(item)}</span>
|
||
</div>`).join('')
|
||
const canvas = isLast ? `<canvas id="particles-${idx}" style="position:absolute;inset:0;z-index:1;pointer-events:none;width:100%;height:100%;"></canvas>` : ''
|
||
return `<div class="slide" data-slide="${idx}">
|
||
${mesh(r)}
|
||
${canvas}
|
||
<div class="content">
|
||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
|
||
<div style="display:flex;flex-direction:column;gap:12px;">${items}</div>
|
||
</div>
|
||
</div>`
|
||
}
|
||
|
||
// ── Chart renderers ─────────────────────────────────────────────────────────
|
||
function renderBarChart(data: { label: string; value: number }[], r: Recipe): string {
|
||
const max = Math.max(...data.map(d => d.value), 1)
|
||
const bars = data.map(d => {
|
||
const pct = Math.round((d.value / max) * 100)
|
||
return `<div style="display:flex;flex-direction:column;align-items:center;gap:6px;flex:1;min-width:0;">
|
||
<span style="font-size:0.75rem;font-weight:700;color:${r.textSecondary};">${d.value}</span>
|
||
<div class="bar" data-height="${pct}" style="background:linear-gradient(to top,${r.accent1},${r.accent2});height:0%;width:100%;border-radius:6px 6px 0 0;"></div>
|
||
<span style="font-size:0.7rem;color:${r.textMuted};text-align:center;max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(d.label)}</span>
|
||
</div>`
|
||
}).join('')
|
||
return `<div style="display:flex;align-items:flex-end;gap:12px;height:200px;">${bars}</div>`
|
||
}
|
||
|
||
function renderHBarChart(data: { label: string; value: number }[], r: Recipe): string {
|
||
const max = Math.max(...data.map(d => d.value), 1)
|
||
const bars = data.map(d => {
|
||
const pct = Math.round((d.value / max) * 100)
|
||
return `<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px;">
|
||
<span style="width:100px;text-align:right;font-size:0.8rem;color:${r.textSecondary};flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(d.label)}</span>
|
||
<div style="flex:1;background:${r.glassBg};border-radius:6px;height:28px;overflow:hidden;">
|
||
<div class="bar-fill" data-width="${pct}" style="height:100%;border-radius:6px;width:0%;background:linear-gradient(to right,${r.accent1},${r.accent2});"></div>
|
||
</div>
|
||
<span style="width:40px;font-size:0.8rem;font-weight:700;color:${r.text};">${d.value}</span>
|
||
</div>`
|
||
}).join('')
|
||
return `<div>${bars}</div>`
|
||
}
|
||
|
||
function renderLineChart(data: { label: string; value: number }[], r: Recipe): string {
|
||
const max = Math.max(...data.map(d => d.value), 1)
|
||
const w = 600, h = 200, pad = 50
|
||
const points = data.map((d, i) => {
|
||
const x = pad + (i / Math.max(data.length - 1, 1)) * (w - 2 * pad)
|
||
const y = h - pad - ((d.value / max) * (h - 2 * pad))
|
||
return `${x},${y}`
|
||
})
|
||
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p}`).join(' ')
|
||
const areaD = `${pathD} L${pad + ((data.length - 1) / Math.max(data.length - 1, 1)) * (w - 2 * pad)},${h - pad} L${pad},${h - pad} Z`
|
||
const gridLines = [0.25, 0.5, 0.75].map(f => {
|
||
const y = h - pad - f * (h - 2 * pad)
|
||
return `<line x1="${pad}" y1="${y}" x2="${w - pad}" y2="${y}" stroke="${r.svgGrid}" stroke-width="1"/>`
|
||
}).join('')
|
||
const dots = data.map((d, i) => {
|
||
const x = pad + (i / Math.max(data.length - 1, 1)) * (w - 2 * pad)
|
||
const y = h - pad - ((d.value / max) * (h - 2 * pad))
|
||
return `<circle cx="${x}" cy="${y}" r="4" fill="${r.accent1}" stroke="${r.bg}" stroke-width="2"/>`
|
||
}).join('')
|
||
const labels = data.map((d, i) => {
|
||
const x = pad + (i / Math.max(data.length - 1, 1)) * (w - 2 * pad)
|
||
return `<text x="${x}" y="${h - 15}" text-anchor="middle" font-size="10" fill="${r.textMuted}">${esc(d.label)}</text>`
|
||
}).join('')
|
||
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:auto;">
|
||
<defs><linearGradient id="lg-${Math.random().toString(36).slice(2, 6)}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${r.accent1}" stop-opacity="0.25"/><stop offset="100%" stop-color="${r.accent1}" stop-opacity="0"/></linearGradient></defs>
|
||
${gridLines}
|
||
<path fill="url(#lg-area)" d="${areaD}" opacity="0.4"/>
|
||
<path class="line-path" d="${pathD}" stroke="${r.accent1}" fill="none" stroke-width="2.5" stroke-linecap="round"/>
|
||
${dots}${labels}
|
||
</svg>`
|
||
}
|
||
|
||
function renderDonutChart(data: { label: string; value: number }[], r: Recipe): string {
|
||
const total = data.reduce((s, d) => s + d.value, 0) || 1
|
||
const colors = [r.accent1, r.accent2, '#10b981', '#f59e0b', '#ef4444', '#6366f1']
|
||
let offset = 0
|
||
const rings = data.map((d, i) => {
|
||
const pct = (d.value / total) * 100
|
||
const dashLen = (pct / 100) * 502
|
||
const ring = `<circle cx="100" cy="100" r="80" fill="none" stroke="${colors[i % colors.length]}" stroke-width="20" stroke-dasharray="${dashLen} 502" stroke-dashoffset="${-offset}" transform="rotate(-90 100 100)"/>`
|
||
offset += dashLen
|
||
return ring
|
||
}).join('')
|
||
const legend = data.map((d, i) => `<div style="display:flex;align-items:center;gap:8px;"><div style="width:10px;height:10px;border-radius:50%;background:${colors[i % colors.length]};"></div><span style="font-size:0.8rem;color:${r.textSecondary};">${esc(d.label)} (${Math.round((d.value / total) * 100)}%)</span></div>`).join('')
|
||
return `<div style="display:flex;align-items:center;gap:40px;justify-content:center;">
|
||
<svg viewBox="0 0 200 200" style="width:180px;height:180px;">${rings}<text x="100" y="105" text-anchor="middle" font-size="22" font-weight="900" fill="${r.text}">${total}</text></svg>
|
||
<div style="display:flex;flex-direction:column;gap:8px;">${legend}</div>
|
||
</div>`
|
||
}
|
||
|
||
function renderRadarChart(data: { label: string; value: number }[], r: Recipe): string {
|
||
const n = data.length
|
||
const cx = 150, cy = 150, radius = 110
|
||
const max = Math.max(...data.map(d => d.value), 1)
|
||
const angleStep = (2 * Math.PI) / n
|
||
|
||
// Choose grid color based on theme darkness
|
||
const gridColor = r.isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)'
|
||
const labelColor = r.isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'
|
||
|
||
// Grid
|
||
const gridLevels = [0.25, 0.5, 0.75, 1].map(f => {
|
||
const pts = Array.from({ length: n }, (_, i) => {
|
||
const a = i * angleStep - Math.PI / 2
|
||
return `${cx + Math.cos(a) * radius * f},${cy + Math.sin(a) * radius * f}`
|
||
}).join(' ')
|
||
return `<polygon points="${pts}" fill="none" stroke="${gridColor}" stroke-width="1"/>`
|
||
}).join('')
|
||
|
||
// Axis lines from center
|
||
const axisLines = Array.from({ length: n }, (_, i) => {
|
||
const a = i * angleStep - Math.PI / 2
|
||
return `<line x1="${cx}" y1="${cy}" x2="${cx + Math.cos(a) * radius}" y2="${cy + Math.sin(a) * radius}" stroke="${gridColor}" stroke-width="1"/>`
|
||
}).join('')
|
||
|
||
// Data polygon
|
||
const dataPts = data.map((d, i) => {
|
||
const a = i * angleStep - Math.PI / 2
|
||
const r2 = (d.value / max) * radius
|
||
return `${cx + Math.cos(a) * r2},${cy + Math.sin(a) * r2}`
|
||
}).join(' ')
|
||
|
||
// Labels with better contrast
|
||
const labels = data.map((d, i) => {
|
||
const a = i * angleStep - Math.PI / 2
|
||
const lx = cx + Math.cos(a) * (radius + 20)
|
||
const ly = cy + Math.sin(a) * (radius + 20)
|
||
return `<text x="${lx}" y="${ly}" text-anchor="middle" font-size="11" font-weight="600" fill="${labelColor}">${esc(d.label)}</text>`
|
||
}).join('')
|
||
|
||
return `<svg viewBox="0 0 300 300" style="width:100%;max-width:320px;height:auto;margin:0 auto;display:block;">
|
||
${axisLines}
|
||
${gridLevels}
|
||
<polygon points="${dataPts}" fill="${r.accent1}" fill-opacity="0.2" stroke="${r.accent1}" stroke-width="2.5"/>
|
||
${labels}
|
||
</svg>`
|
||
}
|
||
|
||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||
function esc(s: string): string { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') }
|
||
function mesh(r: Recipe): string {
|
||
return `<div class="gradient-mesh"><div class="blob" style="width:500px;height:500px;top:-100px;right:-100px;background:${r.accent1};opacity:0.12;--dur:18s;"></div><div class="blob" style="width:300px;height:300px;bottom:-80px;left:-60px;background:${r.accent2};opacity:0.09;--dur:14s;"></div></div>`
|
||
}
|
||
function extractNum(s: string): string { const m = s.match(/[\d.]+/); return m ? m[0] : '0' }
|
||
function extractSuffix(s: string): string { const m = s.match(/[\d.]+(.*)/); return m ? m[1].trim() : '' }
|
||
|
||
// ── Render a single slide by type ───────────────────────────────────────────
|
||
function renderSlide(slide: SlideSpec, r: Recipe, idx: number, total: number): string {
|
||
switch (slide.type) {
|
||
case 'title': return renderTitle(slide, r, idx)
|
||
case 'bullets': return renderBullets(slide, r, idx)
|
||
case 'chart': return renderChart(slide, r, idx)
|
||
case 'stats': return renderStats(slide, r, idx)
|
||
case 'table': return renderTable(slide, r, idx)
|
||
case 'cards': return renderCards(slide, r, idx)
|
||
case 'timeline': return renderTimeline(slide, r, idx)
|
||
case 'quote': return renderQuote(slide, r, idx)
|
||
case 'comparison': return renderComparison(slide, r, idx)
|
||
case 'equation': return renderEquation(slide, r, idx)
|
||
case 'image': return renderImage(slide, r, idx)
|
||
case 'summary': return renderSummary(slide, r, idx, idx === total)
|
||
default: return renderBullets({ type: 'bullets', title: (slide as any).title || 'Slide', items: (slide as any).items || (slide as any).content || ['Content'] }, r, idx)
|
||
}
|
||
}
|
||
|
||
// ── Main export: build full HTML from spec ──────────────────────────────────
|
||
export function buildPresentationHTML(input: PresentationInput): string {
|
||
const r = resolveRecipe(input.theme)
|
||
const total = input.slides.length
|
||
const slidesHtml = input.slides.map((s, i) => renderSlide(s, r, i + 1, total)).join('\n')
|
||
|
||
return `<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||
<title>${esc(input.title)}</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="${r.fontUrl}" rel="stylesheet">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||
<style>
|
||
:root{--color-bg:${r.bg};--color-text:${r.text};--color-text-secondary:${r.textSecondary};--color-text-muted:${r.textMuted};--color-accent-1:${r.accent1};--color-accent-2:${r.accent2};--color-glass-bg:${r.glassBg};--color-glass-border:${r.glassBorder};--svg-grid-line:${r.svgGrid};--font-display:${r.fontDisplay},serif;--font-body:${r.fontBody},system-ui,sans-serif;}
|
||
*,*::before,*::after{box-sizing:border-box;}
|
||
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:var(--color-bg);color:var(--color-text);font-family:var(--font-body);-webkit-font-smoothing:antialiased;}
|
||
/* Fill iframe/container — use % not 100vw (vw breaks lab preview letterboxing) */
|
||
.deck{width:100%;height:100%;position:relative;overflow:hidden;}
|
||
.slide{position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-bg);opacity:0;transform:scale(0.98);transition:opacity 0.45s ease,transform 0.45s ease;pointer-events:none;overflow:hidden;display:flex;align-items:center;justify-content:center;}
|
||
.slide.active{opacity:1;transform:scale(1);pointer-events:all;}
|
||
.slide>.content{position:relative;z-index:2;width:100%;max-width:min(1100px,94%);height:100%;max-height:100%;padding:clamp(1.25rem,3.5vmin,3.5rem);display:flex;flex-direction:column;justify-content:center;box-sizing:border-box;}
|
||
.eq-latex{max-width:100%;}
|
||
.eq-latex .katex-display{margin:0.4em 0;overflow-x:auto;overflow-y:hidden;}
|
||
.gradient-mesh{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0;}
|
||
.blob{position:absolute;border-radius:50%;filter:blur(80px);animation:float-slow var(--dur,18s) ease-in-out infinite;}
|
||
@keyframes float-slow{0%{transform:translate(0,0) scale(1);}50%{transform:translate(-40px,40px) scale(0.92);}100%{transform:translate(0,0) scale(1);}}
|
||
.nav-controls{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:100;display:flex;align-items:center;gap:12px;background:rgba(0,0,0,0.45);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,0.08);border-radius:9999px;padding:8px 16px;}
|
||
.nav-dot{width:10px;height:10px;border-radius:50%;background:rgba(255,255,255,0.2);border:none;cursor:pointer;transition:all 0.3s;padding:0;flex-shrink:0;}
|
||
.nav-dot.active{background:var(--color-accent-1);transform:scale(1.3);box-shadow:0 0 8px ${r.accent1}66;}
|
||
.nav-btn{background:none;border:none;color:rgba(255,255,255,0.5);font-size:20px;cursor:pointer;padding:0 4px;}
|
||
.nav-btn:hover{color:var(--color-accent-1);}
|
||
.slide-counter{font-size:0.75rem;color:rgba(255,255,255,0.4);font-variant-numeric:tabular-nums;min-width:44px;text-align:center;}
|
||
.bar{border-radius:6px 6px 0 0;transition:height 0.8s cubic-bezier(0.34,1.56,0.64,1);}
|
||
.bar-fill{transition:width 0.8s cubic-bezier(0.34,1.56,0.64,1);}
|
||
.line-path{stroke-dasharray:9999;stroke-dashoffset:9999;transition:stroke-dashoffset 1.2s ease;}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="deck">
|
||
${slidesHtml}
|
||
</div>
|
||
<div class="nav-controls" id="nav-controls">
|
||
<button class="nav-btn" onclick="changeSlide(-1)">‹</button>
|
||
<div id="nav-dots" style="display:flex;gap:8px;align-items:center;"></div>
|
||
<button class="nav-btn" onclick="changeSlide(1)">›</button>
|
||
<span class="slide-counter" id="slide-counter">1 / ${total}</span>
|
||
</div>
|
||
<script>
|
||
var current=1,slides=document.querySelectorAll('.slide'),total=slides.length;
|
||
(function(){var d=document.getElementById('nav-dots');for(var i=1;i<=total;i++){var b=document.createElement('button');b.className='nav-dot'+(i===1?' active':'');(function(n){b.addEventListener('click',function(){goToSlide(n)});})(i);d.appendChild(b);}updateNav();})();
|
||
function renderKatex(){if(typeof katex==='undefined')return;document.querySelectorAll('.eq-latex[data-latex]').forEach(function(el){var tex=el.getAttribute('data-latex')||'';try{katex.render(tex,el,{throwOnError:false,displayMode:true});}catch(e){el.textContent=tex;}});}
|
||
function goToSlide(n){if(n<1||n>total||n===current)return;var p=document.querySelector('.slide.active'),nx=document.querySelector('.slide[data-slide="'+n+'"]');if(!nx)return;if(p)p.classList.remove('active');nx.classList.add('active');current=n;updateNav();animateSlide(nx);setTimeout(renderKatex,40);}
|
||
function changeSlide(dir){var n=current+dir;if(n<1)n=total;if(n>total)n=1;goToSlide(n);}
|
||
function updateNav(){document.querySelectorAll('.nav-dot').forEach(function(d,i){d.classList.toggle('active',(i+1)===current);});var c=document.getElementById('slide-counter');if(c)c.textContent=current+' / '+total;try{parent.postMessage({type:'slideChange',current:current,total:total},'*');}catch(e){}}
|
||
document.addEventListener('keydown',function(e){if(e.key==='ArrowRight'||e.key===' ')changeSlide(1);if(e.key==='ArrowLeft')changeSlide(-1);});
|
||
var tx=0;document.addEventListener('touchstart',function(e){tx=e.touches[0].clientX;},{passive:true});document.addEventListener('touchend',function(e){var dx=tx-e.changedTouches[0].clientX;if(Math.abs(dx)>50)changeSlide(dx>0?1:-1);},{passive:true});
|
||
function animateSlide(s){s.querySelectorAll('.reveal').forEach(function(el,i){el.style.transition='none';el.style.opacity='0';el.style.transform='translateY(18px)';el.offsetHeight;el.style.transition='opacity 0.35s ease '+(i*0.07)+'s, transform 0.35s ease '+(i*0.07)+'s';el.style.opacity='1';el.style.transform='translateY(0)';});s.querySelectorAll('.bar[data-height]').forEach(function(b){b.style.height='0%';setTimeout(function(){b.style.height=b.dataset.height+'%';},100);});s.querySelectorAll('.bar-fill[data-width]').forEach(function(b){b.style.width='0%';setTimeout(function(){b.style.width=b.dataset.width+'%';},100);});s.querySelectorAll('.line-path').forEach(function(p){var l=p.getTotalLength?p.getTotalLength():2000;p.style.strokeDasharray=l;p.style.strokeDashoffset=l;setTimeout(function(){p.style.strokeDashoffset='0';},100);});s.querySelectorAll('[data-count]').forEach(function(el){var t=parseFloat(el.dataset.count),sf=el.dataset.suffix||'',st=30,inc=t/st,i=0,v=0;var iv=setInterval(function(){v+=inc;i++;el.textContent=(i>=st?t:Math.round(v))+sf;if(i>=st)clearInterval(iv);},30);});}
|
||
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);renderKatex();},300);}else{setTimeout(renderKatex,400);}
|
||
// Particles
|
||
document.querySelectorAll('canvas[id^="particles-"]').forEach(function(c){c.width=window.innerWidth;c.height=window.innerHeight;var ctx=c.getContext('2d'),pts=[];for(var i=0;i<50;i++)pts.push({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3,r:Math.random()*2+0.5});function draw(){ctx.clearRect(0,0,c.width,c.height);pts.forEach(function(p){p.x+=p.vx;p.y+=p.vy;if(p.x<0)p.x=c.width;if(p.x>c.width)p.x=0;if(p.y<0)p.y=c.height;if(p.y>c.height)p.y=0;ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fillStyle='${r.accent1}80';ctx.fill();});requestAnimationFrame(draw);}draw();});
|
||
</script>
|
||
</body>
|
||
</html>`
|
||
}
|