d43d8eba86
Two refinements after the previous label change: 1. Outage labels rendered at position="top" were clipping against the chart's 8px top margin. Bumped to 24px so #N · Hh Mm sits above the band fully visible. 2. Fast Lane line was only rendered when the ride's metadata flag has_fast_lane was true. Some rides report Fast Lane waits without getting flagged, so we now also render the line whenever today's samples carry any non-null fastLaneMinutes — catches rides that are walk-on all day with a flat line at 0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
211 lines
6.6 KiB
TypeScript
211 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
LineChart,
|
|
Line,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
Legend,
|
|
ReferenceArea,
|
|
} from "recharts";
|
|
import { computeOutages, outageLookup, formatOutageDuration } from "@/lib/outage";
|
|
|
|
export interface TodaySample {
|
|
recordedAt: string;
|
|
localTime: string;
|
|
isOpen: boolean;
|
|
waitMinutes: number | null;
|
|
fastLaneMinutes: number | null;
|
|
}
|
|
|
|
interface Props {
|
|
samples: TodaySample[];
|
|
hasFastLane: boolean;
|
|
}
|
|
|
|
const TIME_FMT = new Intl.DateTimeFormat([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
});
|
|
|
|
export default function WaitTimeTodayChart({ samples, hasFastLane }: Props) {
|
|
const outages = computeOutages(samples);
|
|
const outageByTime = outageLookup(samples, outages);
|
|
|
|
// Show the Fast Lane line if either the ride metadata flags it as a Fast
|
|
// Lane ride OR we have any actual Fast Lane data in today's samples. The
|
|
// metadata flag can lag (or read false even when SF is reporting waits),
|
|
// so the data-driven check rescues those rides.
|
|
const showFastLane = hasFastLane || samples.some((s) => s.fastLaneMinutes !== null);
|
|
|
|
// X-axis time is rendered in the viewer's local timezone (Intl with no
|
|
// tz arg) so an Eastern-time user sees ET regardless of which park.
|
|
// Each point also carries its outage (if any) so the custom tooltip can
|
|
// render "Outage #N — Hh Mm" without re-scanning.
|
|
const data = samples.map((s) => {
|
|
const time = TIME_FMT.format(new Date(s.recordedAt));
|
|
const outage = !s.isOpen ? outageByTime.get(time) ?? null : null;
|
|
return {
|
|
time,
|
|
wait: s.isOpen ? s.waitMinutes : null,
|
|
fl: s.isOpen ? s.fastLaneMinutes : null,
|
|
outageN: outage?.n ?? null,
|
|
outageDurationMin: outage?.durationMin ?? null,
|
|
};
|
|
});
|
|
|
|
const tickInterval = Math.max(1, Math.floor(data.length / 8));
|
|
|
|
return (
|
|
<div style={{ width: "100%", height: 280 }}>
|
|
<ResponsiveContainer>
|
|
{/* top margin leaves room for outage labels rendered with position="top" */}
|
|
<LineChart data={data} margin={{ top: 24, right: 16, left: 0, bottom: 4 }}>
|
|
<CartesianGrid stroke="#272727" strokeDasharray="3 3" vertical={false} />
|
|
<XAxis
|
|
dataKey="time"
|
|
stroke="#737373"
|
|
tick={{ fontSize: 11, fill: "#737373" }}
|
|
interval={tickInterval}
|
|
tickLine={false}
|
|
/>
|
|
<YAxis
|
|
stroke="#737373"
|
|
tick={{ fontSize: 11, fill: "#737373" }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
domain={[0, "auto"]}
|
|
padding={{ bottom: 6 }}
|
|
label={{ value: "min", position: "insideLeft", angle: -90, offset: 16, style: { fontSize: 10, fill: "#737373" } }}
|
|
/>
|
|
{/* Outage markers — render before the lines so they sit underneath. */}
|
|
{outages.map((o) => (
|
|
<ReferenceArea
|
|
key={`outage-${o.n}`}
|
|
x1={o.startTimeLabel}
|
|
x2={o.endTimeLabel}
|
|
fill="#ff4d8d"
|
|
fillOpacity={0.08}
|
|
stroke="#ff4d8d"
|
|
strokeOpacity={0.35}
|
|
strokeDasharray="3 3"
|
|
ifOverflow="extendDomain"
|
|
label={{
|
|
value: `#${o.n} · ${formatOutageDuration(o.durationMin)}`,
|
|
position: "top",
|
|
fill: "#ff4d8d",
|
|
fontSize: 10,
|
|
fontWeight: 600,
|
|
offset: 4,
|
|
}}
|
|
/>
|
|
))}
|
|
<Tooltip content={<RideTooltip hasFastLane={showFastLane} />} />
|
|
<Legend wrapperStyle={{ fontSize: "0.72rem", paddingTop: 4 }} />
|
|
<Line
|
|
name="Wait"
|
|
type="monotone"
|
|
dataKey="wait"
|
|
stroke="#4ade80"
|
|
strokeWidth={2}
|
|
dot={false}
|
|
connectNulls={false}
|
|
isAnimationActive={false}
|
|
/>
|
|
{showFastLane && (
|
|
<Line
|
|
name="Fast Lane"
|
|
type="monotone"
|
|
dataKey="fl"
|
|
stroke="#ff4d8d"
|
|
strokeWidth={2}
|
|
dot={false}
|
|
connectNulls={false}
|
|
isAnimationActive={false}
|
|
/>
|
|
)}
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Custom tooltip ─────────────────────────────────────────────────────────
|
|
// When hovering on an outage span, replace the standard wait/Fast Lane rows
|
|
// with a single "Outage #N — duration" line. Otherwise show the per-series
|
|
// values, formatting 0 explicitly as "walk-on".
|
|
|
|
interface TooltipDatum {
|
|
outageN: number | null;
|
|
outageDurationMin: number | null;
|
|
}
|
|
|
|
interface TooltipEntry {
|
|
value?: number | null;
|
|
name?: string | number;
|
|
dataKey?: string | number;
|
|
color?: string;
|
|
payload?: TooltipDatum;
|
|
}
|
|
|
|
interface RideTooltipProps {
|
|
active?: boolean;
|
|
payload?: TooltipEntry[];
|
|
label?: string | number;
|
|
hasFastLane: boolean;
|
|
}
|
|
|
|
function RideTooltip({ active, payload, label, hasFastLane }: RideTooltipProps) {
|
|
if (!active || !payload || payload.length === 0) return null;
|
|
|
|
const datum = payload[0]?.payload;
|
|
const cardStyle: React.CSSProperties = {
|
|
background: "#1c1c1c",
|
|
border: "1px solid #333",
|
|
borderRadius: 6,
|
|
fontSize: "0.75rem",
|
|
color: "#f5f5f5",
|
|
padding: "8px 10px",
|
|
lineHeight: 1.4,
|
|
};
|
|
|
|
if (datum?.outageN && datum.outageDurationMin !== null) {
|
|
return (
|
|
<div style={cardStyle}>
|
|
<div style={{ color: "#b0b0b0", fontSize: "0.68rem", marginBottom: 2 }}>{label}</div>
|
|
<div style={{ color: "#ff4d8d", fontWeight: 600 }}>
|
|
Outage #{datum.outageN} — {formatOutageDuration(datum.outageDurationMin)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={cardStyle}>
|
|
<div style={{ color: "#b0b0b0", fontSize: "0.68rem", marginBottom: 4 }}>{label}</div>
|
|
{payload.map((entry, i) => {
|
|
const v = entry.value;
|
|
const display =
|
|
v === null || v === undefined
|
|
? "—"
|
|
: v === 0
|
|
? "walk-on"
|
|
: `${v} min`;
|
|
const dotColor = entry.color ?? "#fff";
|
|
return (
|
|
<div key={String(entry.dataKey ?? i)} style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
<span style={{ width: 8, height: 2, background: dotColor, borderRadius: 1 }} />
|
|
<span style={{ color: "#b0b0b0", flex: 1 }}>{entry.name}</span>
|
|
<span style={{ fontWeight: 600 }}>{display}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{!hasFastLane && null}
|
|
</div>
|
|
);
|
|
}
|