/* RADAR1 — shared primitives: clock hooks, framed panels, wipes */
const { useState, useEffect, useMemo, useRef, useCallback } = React;
/* Ticking clock */
function useNow(interval = 1000) {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), interval);
return () => clearInterval(id);
}, [interval]);
return now;
}
function fmtTime(date, tz, h24, seconds = false) {
try {
return new Intl.DateTimeFormat("en-GB", {
hour: "2-digit", minute: "2-digit", ...(seconds ? { second: "2-digit" } : {}),
hour12: !h24, timeZone: tz,
}).format(date).toUpperCase();
} catch (e) { return "--:--"; }
}
function fmtDate(date, tz) {
try {
return new Intl.DateTimeFormat("en-US", {
weekday: "short", month: "short", day: "numeric", timeZone: tz,
}).format(date).toUpperCase();
} catch (e) { return ""; }
}
/* Interval-driven index cycling with fade key */
function useCycle(length, ms, paused = false) {
const [i, setI] = useState(0);
useEffect(() => {
if (paused || length < 2) return;
const id = setInterval(() => setI((v) => (v + 1) % length), ms);
return () => clearInterval(id);
}, [length, ms, paused]);
return i;
}
/* Caps eyebrow label */
function Eyebrow({ children, color = "var(--text-muted)", size = "var(--fs-caption)", style }) {
return (
{children}
);
}
/* Framed rail panel — hairline frame, sweep corner, accent spine */
function RailPanel({ accent = "var(--signal-cyan)", title, right = null, children, flush = false, style }) {
return (
{title && (
{title}
{right}
)}
{children}
);
}
/* Pulsing live dot */
function LiveDot({ color = "var(--radar-red)", size = 8 }) {
return (
);
}
/* Delta value (markets) */
function Delta({ v }) {
const up = v >= 0;
return (
{up ? "▲" : "▼"} {Math.abs(v).toFixed(2)}%
);
}
/* Full-stage sweep wipe: red diagonal band crosses the frame; fires on `signal` change */
function SweepWipe({ signal }) {
const [active, setActive] = useState(false);
const first = useRef(true);
useEffect(() => {
if (first.current) { first.current = false; return; }
setActive(true);
const id = setTimeout(() => setActive(false), 950);
return () => clearTimeout(id);
}, [signal]);
if (!active) return null;
return (
);
}
/* Simple keyed fade/slide-in wrapper (re-mounts on key change upstream) */
function Enter({ dir = "up", dist = 14, dur = 360, delay = 0, children, style }) {
return (
{children}
);
}
/* Broadcast armor: a region that crashes renders nothing and quietly retries.
The stage itself must never unmount — a bad payload costs one region for
15 seconds, not the broadcast. */
class R1Boundary extends React.Component {
constructor(props) { super(props); this.state = { broken: false }; this.timer = null; }
static getDerivedStateFromError() { return { broken: true }; }
componentDidCatch(err) { try { console.error("R1Boundary[" + (this.props.name || "?") + "]", err); } catch (e) {} }
componentDidUpdate() {
if (this.state.broken && !this.timer) {
this.timer = setTimeout(() => { this.timer = null; this.setState({ broken: false }); }, 15000);
}
}
componentWillUnmount() { if (this.timer) clearTimeout(this.timer); }
render() { return this.state.broken ? null : this.props.children; }
}
/* Lucide refresh helper — call after any render that adds data-lucide nodes */
function useLucide(deps) {
useEffect(() => {
const id = requestAnimationFrame(() => { window.lucide && window.lucide.createIcons(); });
return () => cancelAnimationFrame(id);
}, deps);
}
Object.assign(window, {
useNow, fmtTime, fmtDate, useCycle, Eyebrow, RailPanel, LiveDot, Delta,
SweepWipe, Enter, useLucide, R1Boundary,
});