/** * 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 */}