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