/** * Tug-of-war chart of a Tower Control / Rainmaker objective along its track: the middle line is * the start, up is toward the end alpha pushes to and fills in alpha's color, down toward bravo's * goal in bravo's. Shares the objective chart's x-axis and y-label gutter so both line up. */ import clsx from "clsx"; import { useElementSize } from "~/hooks/useElementSize"; import styles from "./ObjectivePositionTimeline.module.css"; import type { ObjectiveTimelineEvent } from "./ObjectiveTimeline"; import { TIMELINE_PLOT_GUTTER_PX, timelineTimeTicks, } from "./objective-timeline-utils"; const TRACK_END = 100; const POSITION_TICKS = [TRACK_END, 0, -TRACK_END]; const DEFAULT_WIDTH = 600; const DEFAULT_HEIGHT = 116; const PLOT_PADDING_Y_PX = 6; const Y_LABEL_GAP_PX = 6; export function ObjectivePositionTimeline({ events, domain, }: { /** sorted by `t`; events without a position read are skipped */ events: readonly ObjectiveTimelineEvent[]; domain: [number, number]; }) { const { ref: measureRef, size } = useElementSize(); const reads = events.flatMap((event) => event.data.position != null ? [{ t: event.t, position: event.data.position }] : [], ); if (reads.length === 0) return null; const { width, height } = size ?? { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, }; const [xMin, xMax] = domain; const xRange = Math.max(xMax - xMin, 1); const plotLeft = TIMELINE_PLOT_GUTTER_PX; const plotRight = width; const plotTop = PLOT_PADDING_Y_PX; const plotBottom = height - PLOT_PADDING_Y_PX; const xAt = (time: number) => plotLeft + ((time - xMin) / xRange) * (plotRight - plotLeft); const yAt = (position: number) => plotTop + ((TRACK_END - position) / (2 * TRACK_END)) * (plotBottom - plotTop); const withCrossings = withZeroCrossings(reads); const areaPath = (clamp: (position: number) => number) => [ `M${xAt(withCrossings[0]!.t).toFixed(1)} ${yAt(0).toFixed(1)}`, ...withCrossings.map( (read) => `L${xAt(read.t).toFixed(1)} ${yAt(clamp(read.position)).toFixed(1)}`, ), `L${xAt(withCrossings[withCrossings.length - 1]!.t).toFixed(1)} ${yAt(0).toFixed(1)}`, "Z", ].join(" "); return (
); } /** Inserts a read at each point the line crosses the middle, so clamping either side to zero keeps the exact shape. */ function withZeroCrossings(reads: Array<{ t: number; position: number }>) { return reads.flatMap((read, i) => { const previous = reads[i - 1]; if (!previous || previous.position * read.position >= 0) return [read]; const ratio = previous.position / (previous.position - read.position); return [ { t: previous.t + (read.t - previous.t) * ratio, position: 0 }, read, ]; }); }