"use client"; import * as React from "react"; import { cn } from "./lib/utils"; export interface SplitFlapRowData { id?: string; train: string; time: string; destination: string; track: string; status: "ON TIME" | "BOARDING" | "DELAYED" | "CANCELLED" | "DEPARTED" | "FINAL CALL" | string; remarks?: string; } export interface SplitFlapCharProps extends React.HTMLAttributes { char?: string; size?: "sm" | "md" | "lg"; color?: "default" | "accent" | "amber" | "green" | "destructive" | "muted"; sound?: boolean; onFlip?: () => void; className?: string; } export interface SplitFlapTextProps extends React.HTMLAttributes { value: string; length?: number; size?: "sm" | "md" | "lg"; color?: "default" | "accent" | "amber" | "green" | "destructive" | "muted"; stagger?: number; sound?: boolean; onComplete?: () => void; className?: string; } export interface SplitFlapRowProps extends React.HTMLAttributes { train: string; time: string; destination: string; track: string; status: string; remarks?: string; sound?: boolean; className?: string; } export interface SplitFlapBoardProps extends React.HTMLAttributes { title?: string; subtitle?: string; terminalCode?: string; rows?: SplitFlapRowData[]; sound?: boolean; onSoundChange?: (enabled: boolean) => void; onFlip?: (char: string, index: number) => void; showControls?: boolean; className?: string; } const FLAP_CHARSET = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-:.#/'*!"; /** * Web Audio API synthesizer for mechanical Solari relay click. * Zero external audio files, pure browser synthesis. */ function playMechanicalClick(audioCtxRef: React.RefObject) { if (typeof window === "undefined") return; try { if (!audioCtxRef.current) { const AudioCtx = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (AudioCtx) { audioCtxRef.current = new AudioCtx(); } } const ctx = audioCtxRef.current; if (!ctx) return; if (ctx.state === "suspended") { void ctx.resume(); } const now = ctx.currentTime; const osc = ctx.createOscillator(); const gain = ctx.createGain(); const filter = ctx.createBiquadFilter(); filter.type = "bandpass"; filter.frequency.setValueAtTime(900 + Math.random() * 300, now); filter.Q.setValueAtTime(4, now); osc.type = "triangle"; osc.frequency.setValueAtTime(180 + Math.random() * 50, now); osc.frequency.exponentialRampToValueAtTime(30, now + 0.02); gain.gain.setValueAtTime(0.09, now); gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.02); osc.connect(filter); filter.connect(gain); gain.connect(ctx.destination); osc.start(now); osc.stop(now + 0.02); } catch { // Autoplay or audio context permission restricted } } /** * SplitFlapChar: Single mechanical flip flap card. */ export function SplitFlapChar({ char = " ", size = "md", color = "default", sound = false, onFlip, className, ...props }: SplitFlapCharProps) { const targetChar = (char[0] || " ").toUpperCase(); const [displayChar, setDisplayChar] = React.useState(targetChar); const [prevChar, setPrevChar] = React.useState(targetChar); const [isFlipping, setIsFlipping] = React.useState(false); const audioCtxRef = React.useRef(null); React.useEffect(() => { if (targetChar === displayChar) return; let mounted = true; let step = 0; const maxSteps = 14; const startIdx = FLAP_CHARSET.indexOf(displayChar); const endIdx = FLAP_CHARSET.indexOf(targetChar); let currentIdx = startIdx === -1 ? 0 : startIdx; const targetValidIdx = endIdx === -1 ? 0 : endIdx; const interval = setInterval(() => { if (!mounted) return; step += 1; if (currentIdx !== targetValidIdx && step < maxSteps) { currentIdx = (currentIdx + 1) % FLAP_CHARSET.length; const nextChar = FLAP_CHARSET[currentIdx]; setPrevChar(displayChar); setDisplayChar(nextChar); setIsFlipping(true); if (sound) { playMechanicalClick(audioCtxRef); } if (onFlip) { onFlip(); } setTimeout(() => { if (mounted) setIsFlipping(false); }, 35); } else { setPrevChar(displayChar); setDisplayChar(targetChar); setIsFlipping(false); clearInterval(interval); } }, 45); return () => { mounted = false; clearInterval(interval); }; }, [targetChar, sound, onFlip]); const sizeClasses = { sm: "w-5 h-7 text-xs", md: "w-6 h-9 text-sm sm:w-7 sm:h-10 sm:text-base", lg: "w-8 h-12 text-lg sm:w-9 sm:h-14 sm:text-xl", }[size]; const colorClasses = { default: "text-foreground", accent: "text-accent", amber: "text-amber-500 dark:text-amber-400", green: "text-emerald-700 dark:text-emerald-400", destructive: "text-destructive", muted: "text-muted-foreground", }[color]; return (
{/* Top Leaf Half */}
{displayChar} {/* Subtle top glare */}
{/* Center Hinge Crease & Pins */}
{/* Left mechanical hinge pin */}
{/* Right mechanical hinge pin */}
{/* Bottom Leaf Half */}
{displayChar} {/* Bottom edge shadow */}
{/* 3D Animated Flap Layer during transitions */} {isFlipping && ( )}
); } /** * SplitFlapText: Renders a line of flipping split-flap characters. */ export function SplitFlapText({ value, length, size = "md", color = "default", stagger = 30, sound = false, onComplete, className, ...props }: SplitFlapTextProps) { const paddedValue = React.useMemo(() => { const raw = (value || "").toUpperCase(); if (!length) return raw; return raw.padEnd(length, " ").slice(0, length); }, [value, length]); return (
{paddedValue.split("").map((ch, idx) => ( ))}
); } /** * SplitFlapRow: Departure schedule item with columns. */ export function SplitFlapRow({ train, time, destination, track, status, remarks, sound = false, className, ...props }: SplitFlapRowProps) { const statusColor = React.useMemo(() => { const s = status.toUpperCase(); if (s.includes("ON TIME")) return "green"; if (s.includes("BOARDING") || s.includes("FINAL")) return "amber"; if (s.includes("DELAYED") || s.includes("CANCEL")) return "destructive"; return "default"; }, [status]); return (
{/* Time */}
{/* Train / Flight */}
{/* Destination */}
{/* Track / Gate */}
{/* Status */}
); } const DEFAULT_ROWS: SplitFlapRowData[] = [ { id: "1", time: "08:45", train: "BB-102", destination: "NEW YORK PENN", track: "04", status: "ON TIME", }, { id: "2", time: "09:15", train: "BB-204", destination: "BOSTON SOUTH", track: "08", status: "BOARDING", }, { id: "3", time: "09:40", train: "BB-319", destination: "CHICAGO UNION", track: "02", status: "DELAYED", }, { id: "4", time: "10:05", train: "BB-440", destination: "WASHINGTON DC", track: "11", status: "ON TIME", }, { id: "5", time: "10:30", train: "BB-508", destination: "MONTREAL CENTR", track: "06", status: "FINAL CALL", }, ]; /** * SplitFlapBoard: Full retro mechanical airport / station departure concourse board. */ export function SplitFlapBoard({ title = "GRAND CENTRAL TERMINAL", subtitle = "DEPARTURES CONCOURSE · TRACK ASSIGNMENTS", terminalCode = "GCT-SOLARI-4", rows = DEFAULT_ROWS, sound = false, onSoundChange, onFlip, showControls = true, className, ...props }: SplitFlapBoardProps) { const [boardRows, setBoardRows] = React.useState(rows); const [soundActive, setSoundActive] = React.useState(sound); const [filter, setFilter] = React.useState("ALL"); const [clockTime, setClockTime] = React.useState("08:42:15"); // Keep internal sound synced if controlled React.useEffect(() => { setSoundActive(sound); }, [sound]); // Update clock every second React.useEffect(() => { const timer = setInterval(() => { const d = new Date(); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); const ss = String(d.getSeconds()).padStart(2, "0"); setClockTime(`${hh}:${mm}:${ss}`); }, 1000); return () => clearInterval(timer); }, []); const handleToggleSound = () => { const next = !soundActive; setSoundActive(next); if (onSoundChange) { onSoundChange(next); } }; const handleShuffle = () => { const shuffled = [...boardRows].map((r) => { const statuses: SplitFlapRowData["status"][] = [ "ON TIME", "BOARDING", "DELAYED", "FINAL CALL", "ON TIME", ]; const randomStatus = statuses[Math.floor(Math.random() * statuses.length)]; return { ...r, status: randomStatus, }; }); setBoardRows(shuffled); }; const filteredRows = React.useMemo(() => { if (filter === "ALL") return boardRows; return boardRows.filter((r) => r.status.toUpperCase().includes(filter)); }, [boardRows, filter]); return (
{/* Side Punch Notches */}