From 484665e51c4f5cab9075351a7926147a34a8d009 Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:20:06 +0330 Subject: [PATCH] Import source: web/src/components/RadialDay Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality. --- web/src/components/RadialDay.tsx | 257 +++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 web/src/components/RadialDay.tsx diff --git a/web/src/components/RadialDay.tsx b/web/src/components/RadialDay.tsx new file mode 100644 index 0000000..5aa82a3 --- /dev/null +++ b/web/src/components/RadialDay.tsx @@ -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 ( + + {[0, 6, 12, 18].map((h) => { + const p = polar(CX, CY, 146, h * 15); + return ( + + {fmtHour(lang, h)} + + ); + })} + {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 ( + + ); + })} + + ); +}); + +interface RadialDayProps { + targets: LiveTarget[]; + dayBuckets: Record; + /** 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(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 ( +
+ {caption && ( +
+ {t.dial} + + {t.systemDay} +
+ )} + + + + + {/* structure hairlines */} + + + + {/* 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 ( + setHover(slot)} + onMouseLeave={() => setHover(null)} + /> + ); + })} + + {/* now hand — brass, sweeps the full day like a 24h watch */} + + + + + + {/* center: hovered hour detail, or today's date */} + {hover ? ( + + + {fmtHour(lang, hover.hourOfDay)}:00–{fmtHour(lang, (hover.hourOfDay + 1) % 24)}:00 + + {hover.total > 0 ? ( + <> + + {fmtNumber(lang, hover.total - hover.failed)}/{fmtNumber(lang, hover.total)} {t.checks} + + + {hover.avgLatency > 0 ? fmtLatency(lang, hover.avgLatency) : "—"} + + + ) : ( + + {t.noData} + + )} + + ) : ( + + + {today} + + + {fmtNumber(lang, current.total - current.failed)}/{fmtNumber(lang, current.total)} {t.checks} · {t.now} + + + )} + +
+ ); +}