1import { useEffect, useRef, type CSSProperties } from "react";
3export interface Points {
4 x: number[];
5 y: number[];
6}
8export interface Segment {
9 x0: number;
10 y0: number;
11 x1: number;
12 y1: number;
13}
15export interface Pt {
16 x: number;
17 y: number;
18}
20interface RegionViewProps {
21 /** Convex region boundary, in counterclockwise world coordinates. */
22 region: Points;
23 /** Sample points to scatter inside the region. */
24 samples: Points;
25 /** Animation overlay: the chord the current step samples along (a line
26 * through `from` clipped to the region). */
27 chord?: Segment | null;
28 /** Animation overlay: the current point the step starts from (ringed). */
29 from?: Pt | null;
30 /** Animation overlay: the freshly sampled point (highlighted). */
31 newPoint?: Pt | null;
32}
34const FILL = "rgba(37, 99, 235, 0.08)";
35const STROKE = "#2563eb";
36const DOT = "rgba(15, 23, 42, 0.55)";
37const DOT_RADIUS = 1.6;
38const MARGIN = 28;
40const CHORD = "#f59e0b"; // amber: the candidate segment + its boundary hits
41const PREV = "#d97706"; // deeper amber dot: the sample the chord starts from
42const NEW = "#0f172a"; // near-black: the freshly sampled point (circled)
44/** Renders the region outline and the samples on a 2D canvas, fitting the
45 * region to the available space (aspect-ratio preserved, y pointing up). The
46 * optional `chord` / `from` / `newPoint` overlay drives the sampling movie.
47 * The canvas is redrawn on data change and on resize, and is devicePixelRatio
48 * aware so dots and edges stay crisp. */
49export function RegionView({
50 region,
51 samples,
52 chord,
53 from,
54 newPoint,
55}: RegionViewProps) {
56 const canvasRef = useRef<HTMLCanvasElement>(null);
57 const containerRef = useRef<HTMLDivElement>(null);
59 useEffect(() => {
60 const canvas = canvasRef.current;
61 const container = containerRef.current;
62 if (!canvas || !container) return;
64 const draw = () => {
65 const ctx = canvas.getContext("2d");
66 if (!ctx) return;
68 const dpr = window.devicePixelRatio || 1;
69 const cssW = container.clientWidth;
70 const cssH = container.clientHeight;
71 if (cssW === 0 || cssH === 0) return;
73 canvas.width = Math.round(cssW * dpr);
74 canvas.height = Math.round(cssH * dpr);
75 canvas.style.width = `${cssW}px`;
76 canvas.style.height = `${cssH}px`;
77 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
78 ctx.clearRect(0, 0, cssW, cssH);
80 if (region.x.length < 3) return;
82 // World bounds from the region (samples lie inside it).
83 let minX = Infinity;
84 let maxX = -Infinity;
85 let minY = Infinity;
86 let maxY = -Infinity;
87 for (let i = 0; i < region.x.length; i++) {
88 minX = Math.min(minX, region.x[i]);
89 maxX = Math.max(maxX, region.x[i]);
90 minY = Math.min(minY, region.y[i]);
91 maxY = Math.max(maxY, region.y[i]);
92 }
93 const worldW = maxX - minX || 1;
94 const worldH = maxY - minY || 1;
96 // Fit preserving aspect ratio; center within the margins.
97 const scale = Math.min(
98 (cssW - 2 * MARGIN) / worldW,
99 (cssH - 2 * MARGIN) / worldH
100 );
101 const offX = (cssW - worldW * scale) / 2;
102 const offY = (cssH - worldH * scale) / 2;
103 // World (x right, y up) → pixel (x right, y down).
104 const toPx = (x: number) => offX + (x - minX) * scale;
105 const toPy = (y: number) => cssH - (offY + (y - minY) * scale);
107 // Region: translucent fill + crisp outline.
108 ctx.beginPath();
109 ctx.moveTo(toPx(region.x[0]), toPy(region.y[0]));
110 for (let i = 1; i < region.x.length; i++) {
111 ctx.lineTo(toPx(region.x[i]), toPy(region.y[i]));
112 }
113 ctx.closePath();
114 ctx.fillStyle = FILL;
115 ctx.fill();
116 ctx.lineWidth = 2;
117 ctx.strokeStyle = STROKE;
118 ctx.stroke();
120 // Samples: small filled dots.
121 ctx.fillStyle = DOT;
122 const n = Math.min(samples.x.length, samples.y.length);
123 for (let i = 0; i < n; i++) {
124 const cx = toPx(samples.x[i]);
125 const cy = toPy(samples.y[i]);
126 ctx.beginPath();
127 ctx.arc(cx, cy, DOT_RADIUS, 0, 2 * Math.PI);
128 ctx.fill();
129 }
131 // Animation overlay (movie mode).
132 if (chord) {
133 const x0 = toPx(chord.x0);
134 const y0 = toPy(chord.y0);
135 const x1 = toPx(chord.x1);
136 const y1 = toPy(chord.y1);
137 // The chord itself.
138 ctx.beginPath();
139 ctx.moveTo(x0, y0);
140 ctx.lineTo(x1, y1);
141 ctx.strokeStyle = CHORD;
142 ctx.lineWidth = 1.5;
143 ctx.setLineDash([4, 3]);
144 ctx.stroke();
145 ctx.setLineDash([]);
146 // Open circles where the line crosses the region boundary.
147 ctx.strokeStyle = CHORD;
148 ctx.lineWidth = 1.25;
149 for (const [ex, ey] of [
150 [x0, y0],
151 [x1, y1],
152 ]) {
153 ctx.beginPath();
154 ctx.arc(ex, ey, 3, 0, 2 * Math.PI);
155 ctx.stroke();
156 }
157 }
158 // Previous sample: the point the chord starts from — a filled amber dot.
159 if (from) {
160 ctx.beginPath();
161 ctx.arc(toPx(from.x), toPy(from.y), 4, 0, 2 * Math.PI);
162 ctx.fillStyle = PREV;
163 ctx.fill();
164 }
165 // New sample: a circled black point.
166 if (newPoint) {
167 const cx = toPx(newPoint.x);
168 const cy = toPy(newPoint.y);
169 ctx.beginPath();
170 ctx.arc(cx, cy, 6.5, 0, 2 * Math.PI);
171 ctx.strokeStyle = NEW;
172 ctx.lineWidth = 1.5;
173 ctx.stroke();
174 ctx.beginPath();
175 ctx.arc(cx, cy, 3.2, 0, 2 * Math.PI);
176 ctx.fillStyle = NEW;
177 ctx.fill();
178 }
179 };
181 draw();
182 const ro = new ResizeObserver(draw);
183 ro.observe(container);
184 return () => ro.disconnect();
185 }, [region, samples, chord, from, newPoint]);
187 return (
188 <div ref={containerRef} style={containerStyle}>
189 <canvas ref={canvasRef} style={{ display: "block" }} />
190 </div>
191 );
192}
194const containerStyle: CSSProperties = {
195 position: "absolute",
196 inset: 0,
197 overflow: "hidden",
198};