diff --git a/js/pattern-editor/viewers/mercator-viewer.js b/js/pattern-editor/viewers/mercator-viewer.js
new file mode 100644
index 0000000..7057117
--- /dev/null
+++ b/js/pattern-editor/viewers/mercator-viewer.js
@@ -0,0 +1,61 @@
+/**
+ * Mercator (equirectangular) projection viewer.
+ *
+ * Plots arena pixels on a longitude × latitude grid, matching
+ * the MATLAB PatternPreviewerApp Mercator view.
+ *
+ * In this projection:
+ * x = longitude (degrees)
+ * y = latitude (degrees)
+ * which is technically an equirectangular (Plate Carrée) projection.
+ * This matches the companion MATLAB implementation.
+ *
+ * @module mercator-viewer
+ */
+
+import { ProjectionViewer } from './projection-viewer.js';
+
+class MercatorViewer extends ProjectionViewer {
+ constructor(container) {
+ super(container, 'mercator');
+ }
+
+ /**
+ * Forward project: longitude/latitude directly map to x/y.
+ * @param {number} lonDeg - Longitude in degrees
+ * @param {number} latDeg - Latitude in degrees
+ * @returns {{x: number, y: number}}
+ */
+ _forwardProject(lonDeg, latDeg) {
+ return { x: lonDeg, y: latDeg };
+ }
+
+ /**
+ * Get map bounds for current FOV.
+ * @returns {{xMin: number, xMax: number, yMin: number, yMax: number}}
+ */
+ _getMapBounds() {
+ return {
+ xMin: this.lonCenter - this.lonFOV,
+ xMax: this.lonCenter + this.lonFOV,
+ yMin: this.latCenter - this.latFOV,
+ yMax: this.latCenter + this.latFOV
+ };
+ }
+
+ /**
+ * Draw Mercator-specific decorations.
+ */
+ _drawDecorations(ctx, mapToCanvas) {
+ // Mercator has a simple rectangular boundary — no special decoration needed
+ // Draw a thin border around the visible area
+ const w = this.canvas.width;
+ const h = this.canvas.height;
+ ctx.strokeStyle = '#2d3640';
+ ctx.lineWidth = 1;
+ ctx.strokeRect(0.5, 0.5, w - 1, h - 1);
+ }
+}
+
+export default MercatorViewer;
+export { MercatorViewer };
diff --git a/js/pattern-editor/viewers/mollweide-viewer.js b/js/pattern-editor/viewers/mollweide-viewer.js
new file mode 100644
index 0000000..eedfe0c
--- /dev/null
+++ b/js/pattern-editor/viewers/mollweide-viewer.js
@@ -0,0 +1,149 @@
+/**
+ * Mollweide (equal-area) projection viewer.
+ *
+ * Plots arena pixels using the Mollweide pseudocylindrical projection,
+ * matching the MATLAB PatternPreviewerApp Mollweide view.
+ *
+ * The Mollweide projection:
+ * 1. Solve iteratively: 2θ + sin(2θ) = π·sin(latitude)
+ * 2. x = (2√2/π) · longitude · cos(θ)
+ * 3. y = √2 · sin(θ)
+ *
+ * Output coordinates are converted to degrees for consistent axis labeling.
+ * The projection boundary is an ellipse.
+ *
+ * @module mollweide-viewer
+ */
+
+import { ProjectionViewer } from './projection-viewer.js';
+
+const SQRT2 = Math.SQRT2;
+const PI = Math.PI;
+
+class MollweideViewer extends ProjectionViewer {
+ constructor(container) {
+ super(container, 'mollweide');
+ }
+
+ /**
+ * Compute the Mollweide auxiliary angle θ for a given latitude.
+ * Solves 2θ + sin(2θ) = π·sin(lat) using Newton-Raphson.
+ * Matches MATLAB computeMollweideTheta exactly.
+ *
+ * @param {number} latRad - Latitude in radians
+ * @returns {number} Auxiliary angle θ in radians
+ */
+ _computeTheta(latRad) {
+ // Special cases at poles
+ if (Math.abs(latRad) >= PI / 2 - 1e-10) {
+ return latRad > 0 ? PI / 2 : -PI / 2;
+ }
+
+ let theta = latRad; // initial guess
+ const target = PI * Math.sin(latRad);
+ for (let i = 0; i < 10; i++) {
+ const delta =
+ -(2 * theta + Math.sin(2 * theta) - target) / (2 + 2 * Math.cos(2 * theta));
+ theta = theta + delta;
+ if (Math.abs(delta) < 1e-6) break;
+ }
+ return theta;
+ }
+
+ /**
+ * Forward project longitude/latitude to Mollweide map coordinates.
+ * Returns coordinates in degrees for consistent labeling with Mercator.
+ *
+ * @param {number} lonDeg - Longitude in degrees
+ * @param {number} latDeg - Latitude in degrees
+ * @returns {{x: number, y: number}} Map coordinates in degrees
+ */
+ _forwardProject(lonDeg, latDeg) {
+ const lonRad = (lonDeg * PI) / 180;
+ const latRad = (latDeg * PI) / 180;
+
+ const theta = this._computeTheta(latRad);
+
+ // Mollweide formulas (in radians)
+ const xRad = ((2 * SQRT2) / PI) * lonRad * Math.cos(theta);
+ const yRad = SQRT2 * Math.sin(theta);
+
+ // Convert to degrees for axis labeling
+ return {
+ x: (xRad * 180) / PI,
+ y: (yRad * 180) / PI
+ };
+ }
+
+ /**
+ * Get map bounds for current FOV.
+ * Applies the Mollweide transform to the FOV limits.
+ */
+ _getMapBounds() {
+ // Transform FOV limits through Mollweide
+ const xScale = (2 * SQRT2) / PI;
+ const yScale = SQRT2;
+
+ // X limit from longitude FOV
+ const lonRad = (this.lonFOV * PI) / 180;
+ const xLimRad = xScale * lonRad; // at equator, cos(theta)=1
+ let xLimDeg = (xLimRad * 180) / PI;
+
+ // Y limit from latitude FOV
+ const latRad = (this.latFOV * PI) / 180;
+ const thetaLim = this._computeTheta(latRad);
+ const yLimRad = yScale * Math.sin(thetaLim);
+ let yLimDeg = (yLimRad * 180) / PI;
+
+ // Safety bounds
+ if (xLimDeg <= 0 || !isFinite(xLimDeg)) xLimDeg = 180;
+ if (yLimDeg <= 0 || !isFinite(yLimDeg)) yLimDeg = 90;
+
+ return {
+ xMin: -xLimDeg,
+ xMax: xLimDeg,
+ yMin: -yLimDeg,
+ yMax: yLimDeg
+ };
+ }
+
+ /**
+ * Draw Mollweide-specific decorations: the elliptical boundary.
+ */
+ _drawDecorations(ctx, mapToCanvas) {
+ // Draw the full-sphere ellipse outline
+ // The Mollweide boundary at full FOV is an ellipse:
+ // x = (2√2/π)·λ·cos(θ), y = √2·sin(θ)
+ // For the full boundary: λ = ±π, lat varies
+ ctx.strokeStyle = '#3d4a58';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+
+ const steps = 100;
+ for (let s = 0; s <= steps; s++) {
+ const latDeg = -90 + (180 / steps) * s;
+ // Right boundary (lon = +180)
+ const proj = this._forwardProject(180, latDeg);
+ if (!proj) continue;
+ const { cx, cy } = mapToCanvas(proj.x, proj.y);
+ if (s === 0) {
+ ctx.moveTo(cx, cy);
+ } else {
+ ctx.lineTo(cx, cy);
+ }
+ }
+ // Continue with left boundary (lon = -180), going back down
+ for (let s = steps; s >= 0; s--) {
+ const latDeg = -90 + (180 / steps) * s;
+ const proj = this._forwardProject(-180, latDeg);
+ if (!proj) continue;
+ const { cx, cy } = mapToCanvas(proj.x, proj.y);
+ ctx.lineTo(cx, cy);
+ }
+ ctx.closePath();
+ ctx.stroke();
+ }
+}
+
+export default MollweideViewer;
+export { MollweideViewer };
diff --git a/js/pattern-editor/viewers/projection-viewer.js b/js/pattern-editor/viewers/projection-viewer.js
new file mode 100644
index 0000000..b18fb85
--- /dev/null
+++ b/js/pattern-editor/viewers/projection-viewer.js
@@ -0,0 +1,758 @@
+/**
+ * Base class for map projection viewers (Mercator, Mollweide).
+ *
+ * Uses forward-projection of each arena pixel onto a 2D canvas,
+ * matching the approach used in the companion MATLAB PatternPreviewerApp.
+ *
+ * Subclasses implement:
+ * _forwardProject(lonDeg, latDeg) => { x, y } in map units
+ * _inverseProject(x, y) => { lonDeg, latDeg } or null
+ * _isInsideProjection(x, y) => boolean
+ * _getMapBounds() => { xMin, xMax, yMin, yMax }
+ * _drawDecorations(ctx, mapToCanvas)
+ *
+ * @module projection-viewer
+ */
+
+class ProjectionViewer {
+ constructor(container, projectionType) {
+ this.container = container;
+ this.projectionType = projectionType;
+ this.canvas = null;
+ this.ctx = null;
+
+ this.arenaConfig = null;
+ this.panelSpecs = null;
+
+ this.state = {
+ pattern: null,
+ currentFrame: 0,
+ showPanelBoundaries: true,
+ showPanelNumbers: false
+ };
+
+ // Viewport (FOV half-widths in degrees, matching MATLAB)
+ this.lonFOV = 180; // ±180° longitude
+ this.latFOV = 90; // ±90° latitude
+ this.lonCenter = 0;
+ this.latCenter = 0;
+
+ // Precomputed arena pixel data
+ this.pixelData = null; // Array of { lonDeg, latDeg, row, col }
+ this.totalPixelRows = 0;
+ this.totalPixelCols = 0;
+ this.panelSize = 0;
+
+ this._resizeHandler = null;
+ this._initialized = false;
+ }
+
+ // ========================================
+ // Public API (mirrors ThreeViewer)
+ // ========================================
+
+ /**
+ * Initialize the projection viewer
+ * @param {Object} arenaConfig - Arena configuration from arena-configs.js
+ * @param {Object} panelSpecs - Panel specifications from arena-configs.js
+ */
+ init(arenaConfig, panelSpecs) {
+ if (this._initialized) return;
+
+ this.arenaConfig = arenaConfig;
+ this.panelSpecs = panelSpecs;
+
+ this._createCanvas();
+ this._computeArenaCoordinates();
+ this._setInitialFOV();
+ this._render();
+
+ this._resizeHandler = () => this._onResize();
+ window.addEventListener('resize', this._resizeHandler);
+ this._initialized = true;
+ }
+
+ /**
+ * Reinitialize with a new arena configuration.
+ * Rebuilds coordinate data but preserves zoom state if reasonable.
+ * @param {Object} arenaConfig - Arena configuration
+ * @param {Object} panelSpecs - Panel specifications
+ */
+ reinit(arenaConfig, panelSpecs) {
+ this.arenaConfig = arenaConfig;
+ this.panelSpecs = panelSpecs;
+ this._computeArenaCoordinates();
+ this._setInitialFOV();
+ this._render();
+ }
+
+ /**
+ * Set the pattern data to display
+ * @param {Object} patternData - Pattern with frames, pixelRows, pixelCols, gsMode
+ */
+ setPattern(patternData) {
+ this.state.pattern = patternData;
+ this.state.currentFrame = 0;
+ this._render();
+ }
+
+ /**
+ * Set the current frame to display
+ * @param {number} frameIndex - 0-indexed frame number
+ */
+ setFrame(frameIndex) {
+ if (!this.state.pattern) return;
+ this.state.currentFrame = Math.max(
+ 0,
+ Math.min(frameIndex, this.state.pattern.numFrames - 1)
+ );
+ this._render();
+ }
+
+ /**
+ * Update display options
+ * @param {Object} options - { showPanelBoundaries, showPanelNumbers }
+ */
+ setOptions(options) {
+ if (options.showPanelBoundaries !== undefined) {
+ this.state.showPanelBoundaries = options.showPanelBoundaries;
+ }
+ if (options.showPanelNumbers !== undefined) {
+ this.state.showPanelNumbers = options.showPanelNumbers;
+ }
+ this._render();
+ }
+
+ /**
+ * Take a screenshot of the current view
+ * @returns {string} Data URL of the screenshot
+ */
+ screenshot() {
+ return this.canvas.toDataURL('image/png');
+ }
+
+ /**
+ * Zoom in: decrease FOV
+ */
+ zoomIn() {
+ this.lonFOV = Math.max(10, this.lonFOV - 20);
+ this.latFOV = Math.max(5, this.latFOV - 10);
+ this._render();
+ }
+
+ /**
+ * Zoom out: increase FOV
+ */
+ zoomOut() {
+ this.lonFOV = Math.min(180, this.lonFOV + 20);
+ this.latFOV = Math.min(90, this.latFOV + 10);
+ this._render();
+ }
+
+ /**
+ * Reset FOV to full sphere view
+ */
+ resetFOV() {
+ this.lonFOV = 180;
+ this.latFOV = 90;
+ this.lonCenter = 0;
+ this.latCenter = 0;
+ this._render();
+ }
+
+ /**
+ * Get current FOV description
+ * @returns {string} FOV label like "±180° × ±90°"
+ */
+ getFOVLabel() {
+ return `\u00b1${this.lonFOV}\u00b0 \u00d7 \u00b1${this.latFOV}\u00b0`;
+ }
+
+ /**
+ * Clean up and destroy the viewer
+ */
+ destroy() {
+ if (this._resizeHandler) {
+ window.removeEventListener('resize', this._resizeHandler);
+ }
+ if (this.canvas && this.canvas.parentNode) {
+ this.canvas.parentNode.removeChild(this.canvas);
+ }
+ this.canvas = null;
+ this.ctx = null;
+ this.pixelData = null;
+ this._initialized = false;
+ }
+
+ // ========================================
+ // Abstract methods (override in subclass)
+ // ========================================
+
+ /**
+ * Forward project geographic coordinates to map coordinates.
+ * @param {number} lonDeg - Longitude in degrees
+ * @param {number} latDeg - Latitude in degrees
+ * @returns {{x: number, y: number}} Map coordinates
+ */
+ _forwardProject(lonDeg, latDeg) {
+ throw new Error('_forwardProject not implemented');
+ }
+
+ /**
+ * Get the map coordinate bounds for the current FOV.
+ * @returns {{xMin: number, xMax: number, yMin: number, yMax: number}}
+ */
+ _getMapBounds() {
+ throw new Error('_getMapBounds not implemented');
+ }
+
+ /**
+ * Check if a point in map coordinates is inside the projection boundary.
+ * @param {number} x - Map x coordinate
+ * @param {number} y - Map y coordinate
+ * @returns {boolean}
+ */
+ _isInsideProjection(x, y) {
+ return true;
+ }
+
+ /**
+ * Draw projection-specific decorations (ellipse outline, etc.)
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {Function} mapToCanvas - Convert (mapX, mapY) => { cx, cy }
+ */
+ _drawDecorations(ctx, mapToCanvas) {
+ // Override in subclass
+ }
+
+ // ========================================
+ // Shared implementation
+ // ========================================
+
+ _createCanvas() {
+ // Remove any existing content (coming-soon overlay)
+ this.container.innerHTML = '';
+
+ // Create wrapper for centering
+ const wrapper = document.createElement('div');
+ wrapper.style.cssText =
+ 'width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column;';
+
+ // Create controls bar
+ const controls = document.createElement('div');
+ controls.className = 'projection-controls';
+ controls.innerHTML = `
+
+
+
+
+
+ \u00b1180\u00b0 \u00d7 \u00b190\u00b0
+
+
+
+
+
+ `;
+ wrapper.appendChild(controls);
+
+ // Wire up controls
+ controls.querySelector('.proj-zoom-in').addEventListener('click', () => {
+ this.zoomIn();
+ this._updateFOVLabel(controls);
+ });
+ controls.querySelector('.proj-zoom-out').addEventListener('click', () => {
+ this.zoomOut();
+ this._updateFOVLabel(controls);
+ });
+ controls.querySelector('.proj-reset-fov').addEventListener('click', () => {
+ this.resetFOV();
+ this._updateFOVLabel(controls);
+ });
+ controls.querySelector('.proj-screenshot').addEventListener('click', () => {
+ this._downloadScreenshot();
+ });
+
+ this._controlsEl = controls;
+
+ // Create canvas
+ this.canvas = document.createElement('canvas');
+ this.canvas.style.cssText =
+ 'max-width:100%;max-height:calc(100% - 40px);image-rendering:auto;';
+ wrapper.appendChild(this.canvas);
+
+ this.container.appendChild(wrapper);
+ this._sizeCanvas();
+ }
+
+ _sizeCanvas() {
+ const maxW = this.container.clientWidth - 20;
+ const maxH = this.container.clientHeight - 60; // leave room for controls
+
+ // 2:1 aspect ratio for both Mercator and Mollweide
+ const aspect = 2;
+ let canvasW, canvasH;
+ if (maxW / maxH > aspect) {
+ canvasH = maxH;
+ canvasW = Math.floor(canvasH * aspect);
+ } else {
+ canvasW = maxW;
+ canvasH = Math.floor(canvasW / aspect);
+ }
+
+ // Cap internal resolution for performance
+ const maxRes = 900;
+ const scale = Math.min(1, maxRes / canvasW);
+ this.canvas.width = Math.max(200, Math.floor(canvasW * scale));
+ this.canvas.height = Math.max(100, Math.floor(canvasH * scale));
+ this.canvas.style.width = Math.max(200, canvasW) + 'px';
+ this.canvas.style.height = Math.max(100, canvasH) + 'px';
+
+ this.ctx = this.canvas.getContext('2d');
+ }
+
+ _updateFOVLabel(controls) {
+ const label = controls.querySelector('.proj-fov-label');
+ if (label) {
+ label.textContent = this.getFOVLabel();
+ }
+ }
+
+ _downloadScreenshot() {
+ const dataURL = this.screenshot();
+ const link = document.createElement('a');
+ const gen = this.arenaConfig?.arena?.generation || 'arena';
+ const cols = this.arenaConfig?.arena?.num_cols || '';
+ const rows = this.arenaConfig?.arena?.num_rows || '';
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ link.download = `${this.projectionType}_${gen}_${cols}c${rows}r_${ts}.png`;
+ link.href = dataURL;
+ link.click();
+ }
+
+ /**
+ * Compute spherical coordinates for every arena pixel.
+ * Stores results as an array of { lonDeg, latDeg, row, col } objects.
+ */
+ _computeArenaCoordinates() {
+ if (!this.arenaConfig || !this.panelSpecs) return;
+
+ const arena = this.arenaConfig.arena;
+ const specs = this.panelSpecs;
+ this.panelSize = specs.pixels_per_panel;
+ const numCols = arena.num_cols;
+ const numRows = arena.num_rows;
+
+ // Determine installed columns
+ const columnsInstalled = arena.columns_installed;
+ const installedCols = columnsInstalled ? columnsInstalled.length : numCols;
+
+ this.totalPixelRows = numRows * this.panelSize;
+ this.totalPixelCols = installedCols * this.panelSize;
+
+ // Generate 3D coordinates using ArenaGeometry (loaded as browser global)
+ const AG = window.ArenaGeometry;
+ if (!AG) {
+ console.error('ProjectionViewer: ArenaGeometry not available');
+ return;
+ }
+
+ const coords = AG.arenaCoordinates({
+ panelSize: this.panelSize,
+ numCols: installedCols,
+ numRows: numRows,
+ numCircle: numCols,
+ model: 'smooth'
+ });
+
+ // Convert to spherical coordinates
+ const spherical = AG.cart2sphere(coords.x, coords.y, coords.z);
+
+ // Build pixel data array
+ this.pixelData = [];
+ for (let r = 0; r < this.totalPixelRows; r++) {
+ for (let c = 0; c < this.totalPixelCols; c++) {
+ const phi = spherical.phi[r][c]; // azimuth [-PI, PI]
+ const theta = spherical.theta[r][c]; // polar from north [0, PI]
+ const latDeg = ((Math.PI / 2 - theta) * 180) / Math.PI;
+ const lonDeg = (phi * 180) / Math.PI;
+
+ this.pixelData.push({
+ lonDeg,
+ latDeg,
+ row: r,
+ col: c,
+ patternIndex: r * this.totalPixelCols + c
+ });
+ }
+ }
+ }
+
+ /**
+ * Set initial FOV based on arena coverage.
+ * Matches MATLAB: lat FOV = max lat extent + 5°, lon FOV from coverage.
+ */
+ _setInitialFOV() {
+ if (!this.pixelData || this.pixelData.length === 0) return;
+
+ // Find lat/lon extent of arena pixels
+ let maxAbsLat = 0;
+ for (const px of this.pixelData) {
+ const absLat = Math.abs(px.latDeg);
+ if (absLat > maxAbsLat) maxAbsLat = absLat;
+ }
+
+ // Check for partial azimuth coverage
+ const arena = this.arenaConfig.arena;
+ const columnsInstalled = arena.columns_installed;
+ if (columnsInstalled && columnsInstalled.length < arena.num_cols) {
+ // Partial arena: tighten lon FOV
+ const azCoverage = (columnsInstalled.length / arena.num_cols) * 360;
+ this.lonFOV = azCoverage / 2 + 10;
+ } else {
+ this.lonFOV = 180;
+ }
+
+ this.latFOV = Math.min(90, maxAbsLat + 5);
+ this.lonCenter = 0;
+ this.latCenter = 0;
+ }
+
+ /**
+ * Main render function. Clears canvas and draws everything.
+ */
+ _render() {
+ if (!this.canvas || !this.ctx) return;
+
+ const ctx = this.ctx;
+ const w = this.canvas.width;
+ const h = this.canvas.height;
+
+ // Clear with background color
+ ctx.fillStyle = '#0f1419';
+ ctx.fillRect(0, 0, w, h);
+
+ if (!this.pixelData || this.pixelData.length === 0) {
+ this._drawPlaceholder(ctx, w, h);
+ return;
+ }
+
+ // Get map bounds for current FOV
+ const bounds = this._getMapBounds();
+ const mapW = bounds.xMax - bounds.xMin;
+ const mapH = bounds.yMax - bounds.yMin;
+
+ // Map coordinate to canvas pixel
+ const mapToCanvas = (mx, my) => ({
+ cx: ((mx - bounds.xMin) / mapW) * w,
+ cy: ((bounds.yMax - my) / mapH) * h // y-axis inverted (top = max lat)
+ });
+
+ // Canvas pixel to map coordinate (for background fill)
+ const canvasToMap = (cx, cy) => ({
+ mx: bounds.xMin + (cx / w) * mapW,
+ my: bounds.yMax - (cy / h) * mapH
+ });
+
+ // Draw sphere background (areas inside projection but outside arena)
+ this._drawBackground(ctx, w, h, canvasToMap);
+
+ // Draw gridlines
+ this._drawGridlines(ctx, w, h, bounds, mapToCanvas);
+
+ // Draw arena pixels
+ this._drawArenaPixels(ctx, w, h, bounds, mapToCanvas);
+
+ // Draw panel boundaries
+ if (this.state.showPanelBoundaries) {
+ this._drawPanelBoundaries(ctx, w, h, mapToCanvas);
+ }
+
+ // Draw projection-specific decorations
+ this._drawDecorations(ctx, mapToCanvas);
+ }
+
+ _drawPlaceholder(ctx, w, h) {
+ ctx.fillStyle = '#8b949e';
+ ctx.font = '14px "IBM Plex Mono", monospace';
+ ctx.textAlign = 'center';
+ ctx.fillText('No Pattern Loaded', w / 2, h / 2 - 10);
+ ctx.font = '12px "IBM Plex Mono", monospace';
+ ctx.fillText('Load a .pat file or generate a pattern to view.', w / 2, h / 2 + 10);
+ }
+
+ /**
+ * Draw background: sphere-but-no-arena region in surface dark color.
+ */
+ _drawBackground(ctx, w, h, canvasToMap) {
+ // For Mollweide, fill the ellipse interior with sphere color
+ // For Mercator, fill the entire canvas with sphere color
+ ctx.fillStyle = '#1a1f26';
+
+ // Simple fill — subclass decorations will handle ellipse clipping if needed
+ ctx.fillRect(0, 0, w, h);
+ }
+
+ /**
+ * Draw latitude/longitude gridlines at 30° intervals.
+ */
+ _drawGridlines(ctx, w, h, bounds, mapToCanvas) {
+ ctx.strokeStyle = 'rgba(45, 54, 64, 0.6)';
+ ctx.lineWidth = 0.5;
+
+ // Longitude lines (vertical)
+ for (let lon = -180; lon <= 180; lon += 30) {
+ if (lon < this.lonCenter - this.lonFOV - 5 || lon > this.lonCenter + this.lonFOV + 5)
+ continue;
+
+ ctx.beginPath();
+ const steps = 60;
+ let started = false;
+ for (let s = 0; s <= steps; s++) {
+ const lat = -this.latFOV + ((2 * this.latFOV) / steps) * s + this.latCenter;
+ const proj = this._forwardProject(lon, lat);
+ if (!proj) continue;
+ const { cx, cy } = mapToCanvas(proj.x, proj.y);
+ if (cx < -10 || cx > w + 10 || cy < -10 || cy > h + 10) continue;
+ if (!started) {
+ ctx.moveTo(cx, cy);
+ started = true;
+ } else {
+ ctx.lineTo(cx, cy);
+ }
+ }
+ ctx.stroke();
+ }
+
+ // Latitude lines (horizontal)
+ for (let lat = -90; lat <= 90; lat += 30) {
+ if (lat < this.latCenter - this.latFOV - 5 || lat > this.latCenter + this.latFOV + 5)
+ continue;
+
+ ctx.beginPath();
+ const steps = 120;
+ let started = false;
+ for (let s = 0; s <= steps; s++) {
+ const lon = -this.lonFOV + ((2 * this.lonFOV) / steps) * s + this.lonCenter;
+ const proj = this._forwardProject(lon, lat);
+ if (!proj) continue;
+ const { cx, cy } = mapToCanvas(proj.x, proj.y);
+ if (cx < -10 || cx > w + 10 || cy < -10 || cy > h + 10) continue;
+ if (!started) {
+ ctx.moveTo(cx, cy);
+ started = true;
+ } else {
+ ctx.lineTo(cx, cy);
+ }
+ }
+ ctx.stroke();
+ }
+
+ // Equator highlight
+ ctx.strokeStyle = 'rgba(45, 54, 64, 0.9)';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ let started = false;
+ const steps = 120;
+ for (let s = 0; s <= steps; s++) {
+ const lon = -this.lonFOV + ((2 * this.lonFOV) / steps) * s + this.lonCenter;
+ const proj = this._forwardProject(lon, 0);
+ if (!proj) continue;
+ const { cx, cy } = mapToCanvas(proj.x, proj.y);
+ if (!started) {
+ ctx.moveTo(cx, cy);
+ started = true;
+ } else {
+ ctx.lineTo(cx, cy);
+ }
+ }
+ ctx.stroke();
+
+ // Axis labels
+ ctx.fillStyle = '#4a5568';
+ ctx.font = '10px "IBM Plex Mono", monospace';
+ ctx.textAlign = 'center';
+
+ // Longitude labels along bottom
+ for (let lon = -180; lon <= 180; lon += 30) {
+ if (lon < this.lonCenter - this.lonFOV || lon > this.lonCenter + this.lonFOV) continue;
+ const proj = this._forwardProject(lon, this.latCenter - this.latFOV);
+ if (!proj) continue;
+ const { cx, cy } = mapToCanvas(proj.x, proj.y);
+ if (cx > 20 && cx < w - 20 && cy > 0 && cy < h) {
+ ctx.fillText(lon + '\u00b0', cx, Math.min(cy + 12, h - 2));
+ }
+ }
+
+ // Latitude labels along left
+ ctx.textAlign = 'right';
+ for (let lat = -90; lat <= 90; lat += 30) {
+ if (lat < this.latCenter - this.latFOV || lat > this.latCenter + this.latFOV) continue;
+ const proj = this._forwardProject(this.lonCenter - this.lonFOV, lat);
+ if (!proj) continue;
+ const { cx, cy } = mapToCanvas(proj.x, proj.y);
+ if (cy > 12 && cy < h - 5 && cx >= 0) {
+ ctx.fillText(lat + '\u00b0', Math.max(cx - 4, 30), cy + 3);
+ }
+ }
+ }
+
+ /**
+ * Draw arena pixels as colored rectangles.
+ */
+ _drawArenaPixels(ctx, w, h, bounds, mapToCanvas) {
+ const frame = this.state.pattern?.frames?.[this.state.currentFrame] ?? null;
+ const maxVal = this.state.pattern?.gsMode === 2 ? 1 : 15;
+ const mapW = bounds.xMax - bounds.xMin;
+ const mapH = bounds.yMax - bounds.yMin;
+
+ // Compute dot size based on pixel angular spacing
+ // Each pixel subtends approximately pRad radians
+ const pRadDeg =
+ this.pixelData.length > 1
+ ? Math.abs(this.pixelData[1].lonDeg - this.pixelData[0].lonDeg) || 1
+ : 1;
+
+ for (const px of this.pixelData) {
+ // Forward project this pixel
+ const proj = this._forwardProject(px.lonDeg, px.latDeg);
+ if (!proj) continue;
+
+ // Check if within current map bounds
+ if (
+ proj.x < bounds.xMin ||
+ proj.x > bounds.xMax ||
+ proj.y < bounds.yMin ||
+ proj.y > bounds.yMax
+ )
+ continue;
+
+ // Get brightness
+ let brightness;
+ if (frame && px.patternIndex < frame.length) {
+ brightness = frame[px.patternIndex] / maxVal;
+ } else {
+ brightness = 1.0; // Default full brightness when no pattern
+ }
+
+ // Green phosphor color (matching MATLAB: pure green channel)
+ if (brightness > 0) {
+ const g = Math.round(brightness * 255);
+ ctx.fillStyle = `rgb(0,${g},0)`;
+ } else {
+ ctx.fillStyle = '#1e2328';
+ }
+
+ // Convert to canvas coordinates
+ const { cx, cy } = mapToCanvas(proj.x, proj.y);
+
+ // Dot size: scale pRad to canvas pixels
+ const dotW = Math.max(1.5, (pRadDeg / mapW) * w * 0.95);
+ const dotH = Math.max(1.5, (pRadDeg / mapH) * h * 0.95);
+
+ ctx.fillRect(cx - dotW / 2, cy - dotH / 2, dotW, dotH);
+ }
+ }
+
+ /**
+ * Draw panel boundary lines on the projection.
+ */
+ _drawPanelBoundaries(ctx, w, h, mapToCanvas) {
+ if (!this.pixelData || this.pixelData.length === 0) return;
+
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)';
+ ctx.lineWidth = 0.5;
+
+ // Collect unique panel boundary longitudes and latitudes
+ // Panel boundaries are between every panelSize pixels in azimuth and elevation
+
+ // Get longitude of each column boundary
+ const colBoundaryLons = [];
+ for (let c = this.panelSize; c < this.totalPixelCols; c += this.panelSize) {
+ // Boundary is between pixel c-1 and pixel c
+ // Average their longitudes
+ const idx1 = c - 1; // last pixel in previous panel
+ const idx2 = c; // first pixel in next panel
+ // Use row 0 as reference
+ const px1 = this.pixelData[idx1];
+ const px2 = this.pixelData[idx2];
+ if (px1 && px2) {
+ colBoundaryLons.push((px1.lonDeg + px2.lonDeg) / 2);
+ }
+ }
+
+ // Get latitude of each row boundary
+ const rowBoundaryLats = [];
+ for (let r = this.panelSize; r < this.totalPixelRows; r += this.panelSize) {
+ // Boundary between row r-1 and row r
+ const idx1 = (r - 1) * this.totalPixelCols;
+ const idx2 = r * this.totalPixelCols;
+ const px1 = this.pixelData[idx1];
+ const px2 = this.pixelData[idx2];
+ if (px1 && px2) {
+ rowBoundaryLats.push((px1.latDeg + px2.latDeg) / 2);
+ }
+ }
+
+ // Get arena lat/lon extents
+ let minLat = Infinity;
+ let maxLat = -Infinity;
+ let minLon = Infinity;
+ let maxLon = -Infinity;
+ for (const px of this.pixelData) {
+ if (px.latDeg < minLat) minLat = px.latDeg;
+ if (px.latDeg > maxLat) maxLat = px.latDeg;
+ if (px.lonDeg < minLon) minLon = px.lonDeg;
+ if (px.lonDeg > maxLon) maxLon = px.lonDeg;
+ }
+
+ // Draw vertical boundaries (panel column separators)
+ for (const lon of colBoundaryLons) {
+ ctx.beginPath();
+ const steps = 40;
+ let started = false;
+ for (let s = 0; s <= steps; s++) {
+ const lat = minLat + ((maxLat - minLat) / steps) * s;
+ const proj = this._forwardProject(lon, lat);
+ if (!proj) continue;
+ const { cx, cy } = mapToCanvas(proj.x, proj.y);
+ if (!started) {
+ ctx.moveTo(cx, cy);
+ started = true;
+ } else {
+ ctx.lineTo(cx, cy);
+ }
+ }
+ ctx.stroke();
+ }
+
+ // Draw horizontal boundaries (panel row separators)
+ for (const lat of rowBoundaryLats) {
+ ctx.beginPath();
+ const steps = 80;
+ let started = false;
+ for (let s = 0; s <= steps; s++) {
+ const lon = minLon + ((maxLon - minLon) / steps) * s;
+ const proj = this._forwardProject(lon, lat);
+ if (!proj) continue;
+ const { cx, cy } = mapToCanvas(proj.x, proj.y);
+ if (!started) {
+ ctx.moveTo(cx, cy);
+ started = true;
+ } else {
+ ctx.lineTo(cx, cy);
+ }
+ }
+ ctx.stroke();
+ }
+ }
+
+ _onResize() {
+ if (!this.canvas) return;
+ this._sizeCanvas();
+ this._render();
+ }
+}
+
+export default ProjectionViewer;
+export { ProjectionViewer };
diff --git a/pattern_editor.html b/pattern_editor.html
index e14a774..f6b8a8e 100644
--- a/pattern_editor.html
+++ b/pattern_editor.html
@@ -1086,6 +1086,64 @@
background: var(--border);
}
+ /* Projection Controls Bar (Mercator, Mollweide) */
+ .projection-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.4rem 0.75rem;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+ flex-wrap: wrap;
+ width: 100%;
+ }
+
+ .projection-controls label {
+ font-size: 0.65rem;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ white-space: nowrap;
+ }
+
+ .projection-controls .ctrl-group {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ }
+
+ .projection-controls .ctrl-btn {
+ padding: 0.2rem 0.5rem;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ color: var(--text-dim);
+ font-family: 'IBM Plex Mono', monospace;
+ font-size: 0.65rem;
+ cursor: pointer;
+ transition: all 0.15s;
+ white-space: nowrap;
+ }
+
+ .projection-controls .ctrl-btn:hover {
+ border-color: var(--accent);
+ color: var(--text);
+ }
+
+ .projection-controls .separator {
+ width: 1px;
+ height: 1.2rem;
+ background: var(--border);
+ }
+
+ .proj-fov-label {
+ font-size: 0.65rem;
+ color: var(--text);
+ min-width: 8em;
+ font-family: 'IBM Plex Mono', monospace;
+ }
+
/* 3D Stats Overlay */
.three-stats-overlay {
position: absolute;
@@ -1642,40 +1700,13 @@
}
/* Coming Soon overlay for disabled features */
- .coming-soon-overlay {
- position: absolute;
- inset: 0;
- background: rgba(15, 20, 25, 0.9);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 10;
- }
-
- .coming-soon-badge {
- background: var(--surface);
- border: 1px solid var(--border);
- padding: 1rem 2rem;
- border-radius: 8px;
- text-align: center;
- }
-
- .coming-soon-badge h3 {
- font-family: 'JetBrains Mono', monospace;
- color: var(--accent);
- margin-bottom: 0.5rem;
- }
-
- .coming-soon-badge p {
- font-size: 0.8rem;
- color: var(--text-dim);
- }
+ /* coming-soon styles removed — Mercator/Mollweide now implemented */
@@ -2122,8 +2153,8 @@
Pattern Editor
-
-
+
+
@@ -2362,6 +2383,8 @@
Find and Replace