Import source: web/src/components/RadialDay

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:20:06 +03:30
parent 65bec9e950
commit 484665e51c
+257
View File
@@ -0,0 +1,257 @@
import { memo, useMemo, useState } from "react";
import { motion } from "motion/react";
import type { Bucket, LiveTarget } from "../lib/types";
import { arcPath, polar } from "../lib/geometry";
import { fmtHour, fmtLatency, fmtNumber } from "../lib/format";
import { useUI } from "../lib/ui";
const CX = 160;
const CY = 160;
const R_SEG = 118; // segment centerline radius
const SEG_W = 17; // segment stroke width
const GAP_DEG = 2.6;
const HOUR_MS = 3_600_000;
interface HourSlot {
start: number; // unix ms, aligned to the hour
hourOfDay: number; // local clock hour 0..23
total: number;
failed: number;
avgLatency: number;
isCurrent: boolean;
}
/** Folds per-target hourly buckets into 24 wall-clock slots. */
function buildSlots(bucketLists: Bucket[][]): HourSlot[] {
const nowHour = Math.floor(Date.now() / HOUR_MS) * HOUR_MS;
const slots: HourSlot[] = [];
for (let i = 23; i >= 0; i--) {
const start = nowHour - i * HOUR_MS;
slots.push({
start,
hourOfDay: new Date(start).getHours(),
total: 0,
failed: 0,
avgLatency: 0,
isCurrent: i === 0,
});
}
const byStart = new Map(slots.map((s) => [s.start, s]));
for (const buckets of bucketLists) {
for (const b of buckets) {
const slot = byStart.get(Math.floor(b.start / HOUR_MS) * HOUR_MS);
if (!slot) continue;
// latency: weighted mean across targets
const okHere = b.total - b.failed;
const okBefore = slot.total - slot.failed;
if (okHere > 0) {
slot.avgLatency =
(slot.avgLatency * okBefore + b.avg_latency_ms * okHere) /
Math.max(1, okBefore + okHere);
}
slot.total += b.total;
slot.failed += b.failed;
}
}
return slots;
}
function slotColor(s: HourSlot): string {
if (s.total === 0) return "var(--color-ink-800)";
if (s.failed === s.total) return "var(--color-down)";
if (s.failed > 0) return "var(--color-warn)";
return "var(--color-up)";
}
const HourMarks = memo(function HourMarks({ lang }: { lang: "en" | "fa" }) {
return (
<g>
{[0, 6, 12, 18].map((h) => {
const p = polar(CX, CY, 146, h * 15);
return (
<text
key={h}
x={p.x}
y={p.y + 3}
textAnchor="middle"
fontFamily="var(--font-mono)"
fontSize={9}
fill="var(--color-paper-faint)"
className="tabular"
>
{fmtHour(lang, h)}
</text>
);
})}
{Array.from({ length: 24 }, (_, h) => {
if (h % 6 === 0) return null;
const p1 = polar(CX, CY, 143, h * 15);
const p2 = polar(CX, CY, 147, h * 15);
return (
<line
key={h}
x1={p1.x}
y1={p1.y}
x2={p2.x}
y2={p2.y}
stroke="var(--color-ink-700)"
strokeWidth={0.6}
opacity={0.7}
/>
);
})}
</g>
);
});
interface RadialDayProps {
targets: LiveTarget[];
dayBuckets: Record<string, Bucket[]>;
/** Restrict to a single target id (detail view). */
only?: string;
size?: number;
caption?: boolean;
}
/**
* Circular visualization #3 — the day dial.
* A clock face where each of the last 24 hours is one segment placed at
* its true wall-clock position, colored by status. A full day, one glance.
*/
export function RadialDay({
targets,
dayBuckets,
only,
size,
caption = true,
}: RadialDayProps) {
const { t, lang, reduced } = useUI();
const [hover, setHover] = useState<HourSlot | null>(null);
const slots = useMemo(() => {
const lists = only
? [dayBuckets[only] ?? []]
: targets.map((x) => dayBuckets[x.id] ?? []);
return buildSlots(lists);
}, [targets, dayBuckets, only]);
const today = new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
weekday: "short",
day: "numeric",
month: "short",
}).format(new Date());
const current = slots.find((s) => s.isCurrent)!;
const handAngle = ((Date.now() % (24 * HOUR_MS)) / (24 * HOUR_MS)) * 360;
return (
<figure className="relative select-none">
{caption && (
<figcaption className="microlabel mb-3 flex items-baseline gap-4">
<span>{t.dial}</span>
<span className="rule-fade flex-1" />
<span>{t.systemDay}</span>
</figcaption>
)}
<svg
viewBox="0 0 320 320"
style={size ? { width: size, height: size } : undefined}
className={size ? "block" : "w-full max-w-[400px] mx-auto block"}
role="img"
aria-label={t.systemDay}
>
<HourMarks lang={lang} />
{/* structure hairlines */}
<circle cx={CX} cy={CY} r={R_SEG - SEG_W / 2 - 4} fill="none" stroke="var(--color-ink-800)" strokeWidth={0.5} opacity={0.6} />
<circle cx={CX} cy={CY} r={R_SEG + SEG_W / 2 + 4} fill="none" stroke="var(--color-ink-800)" strokeWidth={0.5} opacity={0.6} />
{/* the 24 hour segments at their true clock positions —
hours without data stay hairline-thin; data carries the weight */}
{slots.map((slot, i) => {
const a0 = slot.hourOfDay * 15 + GAP_DEG / 2;
const a1 = (slot.hourOfDay + 1) * 15 - GAP_DEG / 2;
const color = slotColor(slot);
const hasData = slot.total > 0;
const isHover = hover?.start === slot.start;
const restOpacity = !hasData ? 0.55 : isHover ? 1 : slot.isCurrent ? 0.95 : 0.8;
return (
<motion.path
key={slot.start}
d={arcPath(CX, CY, R_SEG, a0, a1)}
fill="none"
stroke={color}
strokeWidth={!hasData ? 2.5 : isHover ? SEG_W + 4 : SEG_W}
initial={reduced ? false : { opacity: 0, scale: 0.92 }}
animate={{ opacity: restOpacity, scale: 1 }}
transition={
reduced
? { duration: 0 }
: { type: "spring", stiffness: 110, damping: 17, delay: 0.25 + i * 0.028 }
}
style={{ transformOrigin: "160px 160px", cursor: "pointer", transition: "stroke-width 0.18s" }}
onMouseEnter={() => setHover(slot)}
onMouseLeave={() => setHover(null)}
/>
);
})}
{/* now hand — brass, sweeps the full day like a 24h watch */}
<motion.g
initial={reduced ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: reduced ? 0 : 1, duration: 0.6 }}
>
<line
x1={polar(CX, CY, 88, handAngle).x}
y1={polar(CX, CY, 88, handAngle).y}
x2={polar(CX, CY, R_SEG - SEG_W / 2 - 7, handAngle).x}
y2={polar(CX, CY, R_SEG - SEG_W / 2 - 7, handAngle).y}
stroke="var(--color-brass)"
strokeWidth={1}
strokeLinecap="round"
/>
<circle
cx={polar(CX, CY, 88, handAngle).x}
cy={polar(CX, CY, 88, handAngle).y}
r={1.75}
fill="var(--color-brass)"
/>
</motion.g>
{/* center: hovered hour detail, or today's date */}
{hover ? (
<g>
<text x={CX} y={CY - 14} textAnchor="middle" fontFamily="var(--font-mono)" fontSize={11} fill="var(--color-paper)" className="tabular">
{fmtHour(lang, hover.hourOfDay)}:00–{fmtHour(lang, (hover.hourOfDay + 1) % 24)}:00
</text>
{hover.total > 0 ? (
<>
<text x={CX} y={CY + 6} textAnchor="middle" fontFamily="var(--font-mono)" fontSize={10} fill={slotColor(hover)} className="tabular">
{fmtNumber(lang, hover.total - hover.failed)}/{fmtNumber(lang, hover.total)} {t.checks}
</text>
<text x={CX} y={CY + 24} textAnchor="middle" fontFamily="var(--font-mono)" fontSize={9} fill="var(--color-paper-faint)" className="tabular">
{hover.avgLatency > 0 ? fmtLatency(lang, hover.avgLatency) : "—"}
</text>
</>
) : (
<text x={CX} y={CY + 8} textAnchor="middle" fontFamily="var(--font-mono)" fontSize={9} fill="var(--color-paper-faint)">
{t.noData}
</text>
)}
</g>
) : (
<g>
<text x={CX} y={CY - 2} textAnchor="middle" fontFamily="var(--font-display)" fontSize={22} fill="var(--color-paper)">
{today}
</text>
<text x={CX} y={CY + 20} textAnchor="middle" fontFamily="var(--font-mono)" fontSize={8} letterSpacing={lang === "fa" ? 0 : 1.5} fill="var(--color-paper-faint)" className="tabular">
{fmtNumber(lang, current.total - current.failed)}/{fmtNumber(lang, current.total)} {t.checks} · {t.now}
</text>
</g>
)}
</svg>
</figure>
);
}