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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user