/** * TimelineSlide.tsx * Slide 2 (when --timeline is used): Career Trajectory. * Horizontal timeline showing roles (and optionally education) with * highlighted nodes for experience most relevant to the target role. * Designed for 1080x1080 square format. */ import React from 'react'; import type { SlideContent, TimelineEntry } from '../../schema/carouselSchema'; import type { Theme } from '../themes'; interface TimelineSlideProps { slide: SlideContent; theme: Theme; } // Education node accent — indigo, stands apart from the role accent const EDU_COLOR = '#6366F1'; const TIMELINE_Y = 680; // px from top — vertical center of the line const X_START = 160; // enough room for leftmost label const X_END = 960; const X_SPAN = X_END - X_START; const MIN_NODE_GAP = 180; // minimum px between node centres function getX(year: string, minYear: number, yearSpan: number): number { const y = parseInt(year, 10); if (isNaN(y) || yearSpan === 0) return X_START; return X_START + ((y - minYear) / yearSpan) * X_SPAN; } /** * Enforce a minimum gap between node positions. * If two consecutive nodes are too close, push right ones outward. */ function enforceMinGap(positions: number[]): number[] { if (positions.length <= 1) return positions; const result = [...positions]; for (let i = 1; i < result.length; i++) { if (result[i] - result[i - 1] < MIN_NODE_GAP) { result[i] = result[i - 1] + MIN_NODE_GAP; } } // If we pushed the last node beyond X_END, scale back proportionally const overflow = result[result.length - 1] - X_END; if (overflow > 0) { const shift = overflow / (result.length - 1); for (let i = 1; i < result.length; i++) { result[i] -= shift * i; } } return result; } function NodeCircle({ x, entry, accentColor, isPrimary, }: { x: number; entry: TimelineEntry; accentColor: string; isPrimary: boolean; // top-scoring role → full accent; others → muted }): React.ReactElement { const r = entry.isCurrent ? 22 : isPrimary ? 17 : entry.isHighlighted ? 13 : 9; const fill = entry.isEducation ? EDU_COLOR : isPrimary || entry.isCurrent ? accentColor : entry.isHighlighted ? `${accentColor}99` // 60% opacity of accent for secondary highlighted : '#CBD5E1'; return (
); } function ConnectorLine({ x, isAbove, isHighlighted, isEducation, accentColor, }: { x: number; isAbove: boolean; isHighlighted: boolean; isEducation: boolean; accentColor: string; }): React.ReactElement { const color = isEducation ? EDU_COLOR : isHighlighted ? accentColor : '#D1D5DB'; const h = 120; return (
); } function EntryLabel({ x, entry, isAbove, accentColor, textColor, subtextColor, isPrimary, }: { x: number; entry: TimelineEntry; isAbove: boolean; accentColor: string; textColor: string; subtextColor: string; isPrimary: boolean; }): React.ReactElement { const BOX_W = 170; const left = Math.min(Math.max(x - BOX_W / 2, 20), X_END - BOX_W); const labelTop = isAbove ? TIMELINE_Y - 250 : TIMELINE_Y + 130; const sublabelTop = isAbove ? TIMELINE_Y - 208 : TIMELINE_Y + 168; const yearTop = isAbove ? TIMELINE_Y + 34 : TIMELINE_Y - 54; const labelColor = entry.isEducation ? EDU_COLOR : entry.isCurrent ? accentColor : isPrimary ? textColor : entry.isHighlighted ? `${textColor}BB` : subtextColor; return ( <> {/* Year */}
{entry.year}
{/* Title label */}
{entry.label}
{/* Sublabel (employer / institution) */} {entry.sublabel && (
{entry.sublabel}
)} ); } export function TimelineSlide({ slide, theme }: TimelineSlideProps): React.ReactElement { const { bgColor, accentColor, textColor, subtextColor, fontFamily, borderColor, } = theme; const entries: TimelineEntry[] = slide.timelineEntries ?? []; const years = entries.map((e) => parseInt(e.year, 10)).filter((y) => !isNaN(y)); const minYear = years.length ? Math.min(...years) : new Date().getFullYear() - 5; const maxYear = years.length ? Math.max(...years) : new Date().getFullYear(); const yearSpan = Math.max(maxYear - minYear, 1); // Compute x positions with minimum gap enforcement const rawPositions = entries.map((e) => getX(e.year, minYear, yearSpan)); const nodePositions = enforceMinGap(rawPositions); // Top 2 highlighted (non-education) roles get full accent treatment const highlightedIndices = entries .map((e, i) => ({ e, i })) .filter(({ e }) => e.isHighlighted && !e.isEducation) .map(({ i }) => i); const primarySet = new Set(highlightedIndices.slice(0, 2)); const hasEducation = entries.some((e) => e.isEducation); const slideNumStr = String(slide.slideNumber).padStart(2, '0'); return (
{/* Top accent bar */}
{/* Slide number */}
{slideNumStr}
{/* Eyebrow */}
Career Trajectory
{/* Title */}
{slide.title}
{/* Subtitle */} {slide.subtitle && (
{slide.subtitle}
)} {/* Background band behind timeline */}
{/* ── Timeline line ── */}
{/* Start cap */}
{/* End arrow */}
{/* ── Entries ── */} {entries.map((entry, i) => { const x = nodePositions[i]; const isAbove = i % 2 === 0; const isPrimary = primarySet.has(i); return ( ); })} {/* ── Legend ── */}
Relevant to target role
Other experience
{hasEducation && (
Education
)}
{/* Bottom rule */}
); } export default TimelineSlide;