File size: 4,695 Bytes
0e24aff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
/** Single-variable trajectory plot: observed (cyan) overlaid with predicted
 * (coral, optionally clipped to a `predictedProgress` fraction). */

import { useEffect, useRef } from "react";

import type { TrajectorySample } from "@/types/physix";
import {
  DEFAULT_PADDING,
  buildProjection,
  computeBounds,
  drawAxes,
  drawSeries,
} from "@/lib/trajectory";

const OBSERVED_COLOR = "#5cc8f9";
const PREDICTED_COLOR = "#f97a5c";
const LEGEND_OBSERVED_LABEL = "Observed";
const LEGEND_PREDICTED_LABEL = "Predicted";

interface TrajectoryCanvasProps {
  observed: TrajectorySample[];
  predicted: TrajectorySample[];
  variable: string;
  variableLabel: string;
  /** 0..1, fraction of the predicted series to draw this frame. */
  predictedProgress: number;
  className?: string;
  /** Whether to show the legend (default true). */
  showLegend?: boolean;
}

export function TrajectoryCanvas({
  observed,
  predicted,
  variable,
  variableLabel,
  predictedProgress,
  className,
  showLegend = true,
}: TrajectoryCanvasProps): JSX.Element {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const dpr = window.devicePixelRatio || 1;
    const rect = canvas.getBoundingClientRect();
    const cssWidth = Math.max(280, rect.width);
    const cssHeight = Math.max(220, rect.height);

    canvas.width = cssWidth * dpr;
    canvas.height = cssHeight * dpr;
    canvas.style.width = `${cssWidth}px`;
    canvas.style.height = `${cssHeight}px`;

    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, cssWidth, cssHeight);

    const bounds = computeBounds([observed, predicted], variable);
    const projection = buildProjection(cssWidth, cssHeight, DEFAULT_PADDING, bounds);

    drawAxes(ctx, cssWidth, cssHeight, DEFAULT_PADDING, bounds, variableLabel);

    drawSeries(ctx, observed, variable, projection, {
      color: OBSERVED_COLOR,
      lineWidth: 2,
    });
    drawSeries(ctx, predicted, variable, projection, {
      color: PREDICTED_COLOR,
      lineWidth: 2.5,
      progress: predictedProgress,
    });

    if (showLegend) {
      drawLegend(ctx, cssWidth, cssHeight, predicted.length === 0);
    }
  }, [observed, predicted, predictedProgress, variable, variableLabel, showLegend]);

  return (
    <canvas
      ref={canvasRef}
      className={className ?? "h-[260px] w-full rounded-lg bg-surfaceMuted"}
      role="img"
      aria-label={`${variableLabel} vs time. Observed in cyan; predicted hypothesis in coral.`}
    />
  );
}

function drawLegend(
  ctx: CanvasRenderingContext2D,
  width: number,
  _height: number,
  predictedEmpty: boolean,
): void {
  const padding = 10;
  const swatchW = 14;
  const gap = 8;
  const lineGap = 6;
  ctx.save();
  ctx.font = "11px 'Inter', sans-serif";

  const observedText = LEGEND_OBSERVED_LABEL;
  const predictedText = predictedEmpty
    ? `${LEGEND_PREDICTED_LABEL} (none yet)`
    : LEGEND_PREDICTED_LABEL;
  const observedWidth = ctx.measureText(observedText).width;
  const predictedWidth = ctx.measureText(predictedText).width;
  const boxW =
    swatchW + gap + Math.max(observedWidth, predictedWidth) + padding * 2;
  const boxH = 11 * 2 + lineGap + padding * 2;

  const x = width - boxW - 14;
  const y = 14;

  ctx.fillStyle = "rgba(11,13,16,0.85)";
  ctx.strokeStyle = "rgba(255,255,255,0.08)";
  roundedRect(ctx, x, y, boxW, boxH, 6);
  ctx.fill();
  ctx.stroke();

  ctx.textBaseline = "middle";
  ctx.textAlign = "left";

  // Observed swatch + label
  ctx.fillStyle = OBSERVED_COLOR;
  ctx.fillRect(x + padding, y + padding + 5, swatchW, 2.5);
  ctx.fillStyle = "rgba(229,231,235,0.92)";
  ctx.fillText(observedText, x + padding + swatchW + gap, y + padding + 6);

  // Predicted swatch + label
  ctx.fillStyle = PREDICTED_COLOR;
  ctx.fillRect(
    x + padding,
    y + padding + 11 + lineGap + 5,
    swatchW,
    2.5,
  );
  ctx.fillStyle = predictedEmpty ? "rgba(229,231,235,0.55)" : "rgba(229,231,235,0.92)";
  ctx.fillText(
    predictedText,
    x + padding + swatchW + gap,
    y + padding + 11 + lineGap + 6,
  );

  ctx.restore();
}

function roundedRect(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  w: number,
  h: number,
  r: number,
): void {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + w - r, y);
  ctx.quadraticCurveTo(x + w, y, x + w, y + r);
  ctx.lineTo(x + w, y + h - r);
  ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
  ctx.lineTo(x + r, y + h);
  ctx.quadraticCurveTo(x, y + h, x, y + h - r);
  ctx.lineTo(x, y + r);
  ctx.quadraticCurveTo(x, y, x + r, y);
  ctx.closePath();
}