Skip to content

Commit 7cd5973

Browse files
committed
feat: Add Mercator and Mollweide projection viewers to Pattern Editor
Implement two new map projection viewer tabs that show arena LED patterns as they would appear projected from the fly's perspective at the arena center. New files: - projection-viewer.js: Shared base class with Canvas rendering, coordinate computation, gridlines, panel boundary overlay, zoom controls, screenshot - mercator-viewer.js: Equirectangular projection (lon × lat), matching the companion MATLAB PatternPreviewerApp implementation - mollweide-viewer.js: Equal-area Mollweide projection with Newton-Raphson solver for the auxiliary angle, elliptical boundary Integration into pattern_editor.html: - Enable previously disabled Mercator/Mollweide tab buttons - Replace coming-soon placeholders with canvas containers - Lazy initialization (viewers created on first tab click) - Arena config change detection and automatic reinit - Panel boundaries and panel numbers options synced to projection viewers - Zoom in/out/reset controls with FOV label (matching MATLAB ±FOV display) - Screenshot download support - Coordinated frame scrubbing and playback Pattern Editor v0.9.30 https://claude.ai/code/session_01WYQgLAxA9VS9XrRhTvccqi
1 parent f334ede commit 7cd5973

4 files changed

Lines changed: 1206 additions & 45 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Mercator (equirectangular) projection viewer.
3+
*
4+
* Plots arena pixels on a longitude × latitude grid, matching
5+
* the MATLAB PatternPreviewerApp Mercator view.
6+
*
7+
* In this projection:
8+
* x = longitude (degrees)
9+
* y = latitude (degrees)
10+
* which is technically an equirectangular (Plate Carrée) projection.
11+
* This matches the companion MATLAB implementation.
12+
*
13+
* @module mercator-viewer
14+
*/
15+
16+
import { ProjectionViewer } from './projection-viewer.js';
17+
18+
class MercatorViewer extends ProjectionViewer {
19+
constructor(container) {
20+
super(container, 'mercator');
21+
}
22+
23+
/**
24+
* Forward project: longitude/latitude directly map to x/y.
25+
* @param {number} lonDeg - Longitude in degrees
26+
* @param {number} latDeg - Latitude in degrees
27+
* @returns {{x: number, y: number}}
28+
*/
29+
_forwardProject(lonDeg, latDeg) {
30+
return { x: lonDeg, y: latDeg };
31+
}
32+
33+
/**
34+
* Get map bounds for current FOV.
35+
* @returns {{xMin: number, xMax: number, yMin: number, yMax: number}}
36+
*/
37+
_getMapBounds() {
38+
return {
39+
xMin: this.lonCenter - this.lonFOV,
40+
xMax: this.lonCenter + this.lonFOV,
41+
yMin: this.latCenter - this.latFOV,
42+
yMax: this.latCenter + this.latFOV
43+
};
44+
}
45+
46+
/**
47+
* Draw Mercator-specific decorations.
48+
*/
49+
_drawDecorations(ctx, mapToCanvas) {
50+
// Mercator has a simple rectangular boundary — no special decoration needed
51+
// Draw a thin border around the visible area
52+
const w = this.canvas.width;
53+
const h = this.canvas.height;
54+
ctx.strokeStyle = '#2d3640';
55+
ctx.lineWidth = 1;
56+
ctx.strokeRect(0.5, 0.5, w - 1, h - 1);
57+
}
58+
}
59+
60+
export default MercatorViewer;
61+
export { MercatorViewer };
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* Mollweide (equal-area) projection viewer.
3+
*
4+
* Plots arena pixels using the Mollweide pseudocylindrical projection,
5+
* matching the MATLAB PatternPreviewerApp Mollweide view.
6+
*
7+
* The Mollweide projection:
8+
* 1. Solve iteratively: 2θ + sin(2θ) = π·sin(latitude)
9+
* 2. x = (2√2/π) · longitude · cos(θ)
10+
* 3. y = √2 · sin(θ)
11+
*
12+
* Output coordinates are converted to degrees for consistent axis labeling.
13+
* The projection boundary is an ellipse.
14+
*
15+
* @module mollweide-viewer
16+
*/
17+
18+
import { ProjectionViewer } from './projection-viewer.js';
19+
20+
const SQRT2 = Math.SQRT2;
21+
const PI = Math.PI;
22+
23+
class MollweideViewer extends ProjectionViewer {
24+
constructor(container) {
25+
super(container, 'mollweide');
26+
}
27+
28+
/**
29+
* Compute the Mollweide auxiliary angle θ for a given latitude.
30+
* Solves 2θ + sin(2θ) = π·sin(lat) using Newton-Raphson.
31+
* Matches MATLAB computeMollweideTheta exactly.
32+
*
33+
* @param {number} latRad - Latitude in radians
34+
* @returns {number} Auxiliary angle θ in radians
35+
*/
36+
_computeTheta(latRad) {
37+
// Special cases at poles
38+
if (Math.abs(latRad) >= PI / 2 - 1e-10) {
39+
return latRad > 0 ? PI / 2 : -PI / 2;
40+
}
41+
42+
let theta = latRad; // initial guess
43+
const target = PI * Math.sin(latRad);
44+
for (let i = 0; i < 10; i++) {
45+
const delta =
46+
-(2 * theta + Math.sin(2 * theta) - target) / (2 + 2 * Math.cos(2 * theta));
47+
theta = theta + delta;
48+
if (Math.abs(delta) < 1e-6) break;
49+
}
50+
return theta;
51+
}
52+
53+
/**
54+
* Forward project longitude/latitude to Mollweide map coordinates.
55+
* Returns coordinates in degrees for consistent labeling with Mercator.
56+
*
57+
* @param {number} lonDeg - Longitude in degrees
58+
* @param {number} latDeg - Latitude in degrees
59+
* @returns {{x: number, y: number}} Map coordinates in degrees
60+
*/
61+
_forwardProject(lonDeg, latDeg) {
62+
const lonRad = (lonDeg * PI) / 180;
63+
const latRad = (latDeg * PI) / 180;
64+
65+
const theta = this._computeTheta(latRad);
66+
67+
// Mollweide formulas (in radians)
68+
const xRad = ((2 * SQRT2) / PI) * lonRad * Math.cos(theta);
69+
const yRad = SQRT2 * Math.sin(theta);
70+
71+
// Convert to degrees for axis labeling
72+
return {
73+
x: (xRad * 180) / PI,
74+
y: (yRad * 180) / PI
75+
};
76+
}
77+
78+
/**
79+
* Get map bounds for current FOV.
80+
* Applies the Mollweide transform to the FOV limits.
81+
*/
82+
_getMapBounds() {
83+
// Transform FOV limits through Mollweide
84+
const xScale = (2 * SQRT2) / PI;
85+
const yScale = SQRT2;
86+
87+
// X limit from longitude FOV
88+
const lonRad = (this.lonFOV * PI) / 180;
89+
const xLimRad = xScale * lonRad; // at equator, cos(theta)=1
90+
let xLimDeg = (xLimRad * 180) / PI;
91+
92+
// Y limit from latitude FOV
93+
const latRad = (this.latFOV * PI) / 180;
94+
const thetaLim = this._computeTheta(latRad);
95+
const yLimRad = yScale * Math.sin(thetaLim);
96+
let yLimDeg = (yLimRad * 180) / PI;
97+
98+
// Safety bounds
99+
if (xLimDeg <= 0 || !isFinite(xLimDeg)) xLimDeg = 180;
100+
if (yLimDeg <= 0 || !isFinite(yLimDeg)) yLimDeg = 90;
101+
102+
return {
103+
xMin: -xLimDeg,
104+
xMax: xLimDeg,
105+
yMin: -yLimDeg,
106+
yMax: yLimDeg
107+
};
108+
}
109+
110+
/**
111+
* Draw Mollweide-specific decorations: the elliptical boundary.
112+
*/
113+
_drawDecorations(ctx, mapToCanvas) {
114+
// Draw the full-sphere ellipse outline
115+
// The Mollweide boundary at full FOV is an ellipse:
116+
// x = (2√2/π)·λ·cos(θ), y = √2·sin(θ)
117+
// For the full boundary: λ = ±π, lat varies
118+
ctx.strokeStyle = '#3d4a58';
119+
ctx.lineWidth = 1;
120+
ctx.beginPath();
121+
122+
const steps = 100;
123+
for (let s = 0; s <= steps; s++) {
124+
const latDeg = -90 + (180 / steps) * s;
125+
// Right boundary (lon = +180)
126+
const proj = this._forwardProject(180, latDeg);
127+
if (!proj) continue;
128+
const { cx, cy } = mapToCanvas(proj.x, proj.y);
129+
if (s === 0) {
130+
ctx.moveTo(cx, cy);
131+
} else {
132+
ctx.lineTo(cx, cy);
133+
}
134+
}
135+
// Continue with left boundary (lon = -180), going back down
136+
for (let s = steps; s >= 0; s--) {
137+
const latDeg = -90 + (180 / steps) * s;
138+
const proj = this._forwardProject(-180, latDeg);
139+
if (!proj) continue;
140+
const { cx, cy } = mapToCanvas(proj.x, proj.y);
141+
ctx.lineTo(cx, cy);
142+
}
143+
ctx.closePath();
144+
ctx.stroke();
145+
}
146+
}
147+
148+
export default MollweideViewer;
149+
export { MollweideViewer };

0 commit comments

Comments
 (0)