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