fuzzyworld

shadow-zone / files / src / renderer.ts

renderer.ts

6.3 KB · history · blame

1import type { EarthModel } from './earth';
2import type { RayPath } from './rayTracer';
3import type { ShadowZoneResult } from './shadowZone';
4
5export class Renderer {
6 private ctx: CanvasRenderingContext2D;
7 private w: number;
8 private h: number;
9
10 constructor(private canvas: HTMLCanvasElement) {
11 this.ctx = canvas.getContext('2d')!;
12 this.w = canvas.width;
13 this.h = canvas.height;
14 }
15
16 // Subsample rays for display so near-duplicate paths are skipped.
17 // Sorts by representative angle and keeps rays with at least minSep° separation.
18 private subsampleRays(rays: RayPath[]): RayPath[] {
19 // Show small counts exactly as-is — user explicitly chose that number.
20 if (rays.length <= 50) return rays;
21 const norm = (d: number): number => { const n = d % 360; return n > 180 ? 360 - n : n; };
22 // Scale min separation inversely with ray count so screen density is proportional
23 // to the slider value (5× more rays → ~5× denser). Floor of 0.3° prevents
24 // drawing exact-duplicate paths that trace the same route.
25 const minSep = Math.max(0.3, 180 / rays.length);
26
27 const withAngle = rays
28 .map(r => {
29 if (r.epicentralDistance !== null && !r.blocked) {
30 return { ray: r, angle: norm(r.epicentralDistance) };
31 }
32 if (r.blocked && r.points.length > 0) {
33 return { ray: r, angle: r.points[r.points.length - 1].theta * 180 / Math.PI };
34 }
35 return null;
36 })
37 .filter((x): x is { ray: RayPath; angle: number } => x !== null)
38 .sort((a, b) => a.angle - b.angle);
39
40 const kept: RayPath[] = [];
41 let last = -Infinity;
42 for (const { ray, angle } of withAngle) {
43 if (angle - last >= minSep) {
44 kept.push(ray);
45 last = angle;
46 }
47 }
48 return kept;
49 }
50
51 render(model: EarthModel, rays: RayPath[], shadows: ShadowZoneResult[]): void {
52 this.w = this.canvas.width;
53 this.h = this.canvas.height;
54 const ctx = this.ctx;
55 const cx = this.w / 2;
56 const cy = this.h / 2;
57 const scale = Math.min(this.w, this.h) * 0.40;
58 const R = model.radius;
59
60 // Clear to transparent — page background shows through, no visible square
61 ctx.clearRect(0, 0, this.w, this.h);
62
63 // Earth layers (inner → outer)
64 const layersSorted = [...model.layers].sort((a, b) => b.rMax - a.rMax);
65 for (const layer of layersSorted) {
66 ctx.beginPath();
67 ctx.arc(cx, cy, (layer.rMax / R) * scale, 0, Math.PI * 2);
68 ctx.fillStyle = layer.color;
69 ctx.fill();
70 }
71
72 // Rays — drawn on both sides (rays radiate symmetrically from the epicenter)
73 for (const ray of this.subsampleRays(rays)) {
74 if (ray.points.length < 2) continue;
75 let strokeStyle: string;
76 if (ray.blocked) {
77 strokeStyle = 'rgba(255,100,100,0.45)';
78 } else if (ray.wave === 'S') {
79 strokeStyle = 'rgba(100,255,150,0.50)';
80 } else {
81 strokeStyle = 'rgba(100,150,255,0.50)';
82 }
83 ctx.strokeStyle = strokeStyle;
84 ctx.lineWidth = 2;
85 for (const mirror of [1, -1] as const) {
86 ctx.beginPath();
87 for (let i = 0; i < ray.points.length; i++) {
88 const { r, theta } = ray.points[i];
89 const px = cx + mirror * (r / R) * scale * Math.sin(theta);
90 const py = cy - (r / R) * scale * Math.cos(theta);
91 if (i === 0) ctx.moveTo(px, py);
92 else ctx.lineTo(px, py);
93 }
94 ctx.stroke();
95 }
96 }
97
98 // Thin surface ring
99 ctx.beginPath();
100 ctx.arc(cx, cy, scale, 0, Math.PI * 2);
101 ctx.strokeStyle = 'rgba(180,180,220,0.4)';
102 ctx.lineWidth = 1;
103 ctx.stroke();
104
105 // Shadow zone arcs — drawn on top of the surface ring, centered on the surface
106 if (shadows.length > 0) {
107 const arcW = scale * 0.05;
108 ctx.lineWidth = arcW;
109 ctx.strokeStyle = 'rgba(255,60,60,0.70)';
110 for (const shadow of shadows) {
111 for (const side of [1, -1]) {
112 const startAngle = -Math.PI / 2 + side * (shadow.start * Math.PI) / 180;
113 const endAngle = -Math.PI / 2 + side * (shadow.end * Math.PI) / 180;
114 ctx.beginPath();
115 ctx.arc(cx, cy, scale, Math.min(startAngle, endAngle), Math.max(startAngle, endAngle));
116 ctx.stroke();
117 }
118 }
119 }
120
121 // Angle labels
122 ctx.font = '11px system-ui';
123 ctx.textAlign = 'center';
124 ctx.textBaseline = 'middle';
125 const labelAngles = [0, 30, 60, 90, 120, 150, 180];
126 for (const deg of labelAngles) {
127 for (const side of [1, -1]) {
128 if (deg === 0 || deg === 180) {
129 if (side === -1) continue; // only draw once
130 }
131 const rad = (deg * Math.PI) / 180 * side;
132 const labelR = scale + 18;
133 const lx = cx + labelR * Math.sin(rad);
134 const ly = cy - labelR * Math.cos(rad);
135 ctx.fillStyle = '#8888aa';
136 ctx.fillText(`${deg}°`, lx, ly);
137 }
138 }
139
140 // Earthquake marker at top
141 const eqX = cx;
142 const eqY = cy - scale;
143 ctx.font = '16px system-ui';
144 ctx.textAlign = 'center';
145 ctx.textBaseline = 'bottom';
146 ctx.fillText('⚡', eqX, eqY - 4);
147
148 // Layer name labels
149 ctx.textBaseline = 'middle';
150 ctx.font = '11px system-ui';
151 ctx.fillStyle = 'rgba(255,255,255,0.55)';
152 for (const layer of model.layers) {
153 const midR = ((layer.rMin + layer.rMax) / 2 / R) * scale;
154 // Only label if the ring is wide enough
155 if ((layer.rMax - layer.rMin) / R * scale < 14) continue;
156 ctx.textAlign = 'center';
157 ctx.fillText(layer.name, cx, cy + midR);
158 }
159
160 // Legend — top-left corner of the canvas
161 const legendItems = [
162 { color: 'rgba(100,150,255,0.90)', lineH: 2, label: 'P-waves' },
163 { color: 'rgba(100,255,150,0.85)', lineH: 2, label: 'S-waves' },
164 { color: 'rgba(255,60,60,0.80)', lineH: 6, label: 'Shadow zone' },
165 ];
166 const itemH = 15;
167 const swatchW = 18;
168 const lx0 = 12;
169 ctx.font = '11px system-ui';
170 ctx.textAlign = 'left';
171 ctx.textBaseline = 'middle';
172 for (let i = 0; i < legendItems.length; i++) {
173 const item = legendItems[i];
174 const ly = 12 + i * itemH;
175 ctx.strokeStyle = item.color;
176 ctx.lineWidth = item.lineH;
177 ctx.beginPath();
178 ctx.moveTo(lx0, ly);
179 ctx.lineTo(lx0 + swatchW, ly);
180 ctx.stroke();
181 ctx.fillStyle = 'rgba(200,200,220,0.85)';
182 ctx.fillText(item.label, lx0 + swatchW + 6, ly);
183 }
184
185 }
186}