diff --git a/js/pattern-editor/viewers/mollweide-viewer.js b/js/pattern-editor/viewers/mollweide-viewer.js index eedfe0c..7f756ee 100644 --- a/js/pattern-editor/viewers/mollweide-viewer.js +++ b/js/pattern-editor/viewers/mollweide-viewer.js @@ -12,6 +12,9 @@ * Output coordinates are converted to degrees for consistent axis labeling. * The projection boundary is an ellipse. * + * Supports optional eye FOV overlay from CSV data files containing + * Mollweide-projected boundary coordinates (in radians). + * * @module mollweide-viewer */ @@ -23,6 +26,12 @@ const PI = Math.PI; class MollweideViewer extends ProjectionViewer { constructor(container) { super(container, 'mollweide'); + + // Eye FOV overlay state + this.showEyeFOV = false; + this.eyeFOVLeft = null; + this.eyeFOVRight = null; + this._eyeFOVLoading = false; } /** @@ -107,15 +116,123 @@ class MollweideViewer extends ProjectionViewer { }; } + // ======================================== + // Eye FOV overlay + // ======================================== + + /** + * Toggle eye FOV overlay visibility. + * Loads CSV data lazily on first enable. + * @param {boolean} show - Whether to show the eye FOV overlay + */ + setShowEyeFOV(show) { + this.showEyeFOV = show; + if (show && !this.eyeFOVLeft && !this._eyeFOVLoading) { + this._loadEyeFOV().then(() => this._render()); + } else { + this._render(); + } + } + + /** + * Fetch and parse eye FOV boundary CSV files. + * CSV files contain Mollweide-projected coordinates in radians (x1, y1). + * Converts to degrees to match the viewer's coordinate system. + */ + async _loadEyeFOV() { + this._eyeFOVLoading = true; + try { + const [leftResp, rightResp] = await Promise.all([ + fetch('data/fov_left_Mo.csv'), + fetch('data/fov_right_Mo.csv') + ]); + + if (!leftResp.ok || !rightResp.ok) { + console.error('Eye FOV: Failed to load CSV files'); + this._eyeFOVLoading = false; + return; + } + + const [leftText, rightText] = await Promise.all([leftResp.text(), rightResp.text()]); + + this.eyeFOVLeft = this._parseEyeFOVCSV(leftText); + this.eyeFOVRight = this._parseEyeFOVCSV(rightText); + + console.log( + 'Eye FOV loaded:', + this.eyeFOVLeft.length, + 'left pts,', + this.eyeFOVRight.length, + 'right pts' + ); + } catch (err) { + console.error('Eye FOV: Load error:', err.message); + } + this._eyeFOVLoading = false; + } + /** - * Draw Mollweide-specific decorations: the elliptical boundary. + * Parse a CSV string of Mollweide coordinates (radians) into degree points. + * @param {string} csvText - CSV with "x1","y1" header and radian values + * @returns {Array<{x: number, y: number}>} Points in degrees (Mollweide map space) + */ + _parseEyeFOVCSV(csvText) { + const lines = csvText.trim().split('\n'); + const points = []; + // Skip header row + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + const parts = line.split(','); + if (parts.length < 2) continue; + const xRad = parseFloat(parts[0]); + const yRad = parseFloat(parts[1]); + if (isNaN(xRad) || isNaN(yRad)) continue; + // Convert from Mollweide radians to degrees + points.push({ + x: (xRad * 180) / PI, + y: (yRad * 180) / PI + }); + } + return points; + } + + /** + * Draw eye FOV boundary polygons on the projection. + * @param {CanvasRenderingContext2D} ctx + * @param {Function} mapToCanvas - Convert (mapX, mapY) => { cx, cy } + */ + _drawEyeFOV(ctx, mapToCanvas) { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; + ctx.lineWidth = 2.5; + + for (const points of [this.eyeFOVLeft, this.eyeFOVRight]) { + if (!points || points.length < 3) continue; + + ctx.beginPath(); + for (let i = 0; i < points.length; i++) { + const { cx, cy } = mapToCanvas(points[i].x, points[i].y); + if (i === 0) { + ctx.moveTo(cx, cy); + } else { + ctx.lineTo(cx, cy); + } + } + ctx.closePath(); + ctx.stroke(); + } + } + + // ======================================== + // Decorations + // ======================================== + + /** + * Draw Mollweide-specific decorations: elliptical boundary + optional eye FOV. */ _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.strokeStyle = 'rgba(255, 255, 255, 0.4)'; ctx.lineWidth = 1; ctx.beginPath(); @@ -142,6 +259,11 @@ class MollweideViewer extends ProjectionViewer { } ctx.closePath(); ctx.stroke(); + + // Draw eye FOV overlay if enabled and loaded + if (this.showEyeFOV && this.eyeFOVLeft && this.eyeFOVRight) { + this._drawEyeFOV(ctx, mapToCanvas); + } } } diff --git a/js/pattern-editor/viewers/projection-viewer.js b/js/pattern-editor/viewers/projection-viewer.js index b18fb85..06a1f08 100644 --- a/js/pattern-editor/viewers/projection-viewer.js +++ b/js/pattern-editor/viewers/projection-viewer.js @@ -389,37 +389,19 @@ class ProjectionViewer { } /** - * Set initial FOV based on arena coverage. - * Matches MATLAB: lat FOV = max lat extent + 5°, lon FOV from coverage. + * Set initial FOV to full sphere view. + * Always defaults to ±180° × ±90° so the full projection is visible. */ _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.lonFOV = 180; + this.latFOV = 90; this.lonCenter = 0; this.latCenter = 0; } /** * Main render function. Clears canvas and draws everything. + * Uses a margin system so axis labels remain visible when zoomed. */ _render() { if (!this.canvas || !this.ctx) return; @@ -428,6 +410,11 @@ class ProjectionViewer { const w = this.canvas.width; const h = this.canvas.height; + // Margins for axis labels (pixels) + const margin = { left: 38, right: 8, top: 8, bottom: 18 }; + const plotW = w - margin.left - margin.right; + const plotH = h - margin.top - margin.bottom; + // Clear with background color ctx.fillStyle = '#0f1419'; ctx.fillRect(0, 0, w, h); @@ -442,26 +429,26 @@ class ProjectionViewer { const mapW = bounds.xMax - bounds.xMin; const mapH = bounds.yMax - bounds.yMin; - // Map coordinate to canvas pixel + // Map coordinate to canvas pixel (within plot area) const mapToCanvas = (mx, my) => ({ - cx: ((mx - bounds.xMin) / mapW) * w, - cy: ((bounds.yMax - my) / mapH) * h // y-axis inverted (top = max lat) + cx: margin.left + ((mx - bounds.xMin) / mapW) * plotW, + cy: margin.top + ((bounds.yMax - my) / mapH) * plotH }); // Canvas pixel to map coordinate (for background fill) const canvasToMap = (cx, cy) => ({ - mx: bounds.xMin + (cx / w) * mapW, - my: bounds.yMax - (cy / h) * mapH + mx: bounds.xMin + ((cx - margin.left) / plotW) * mapW, + my: bounds.yMax - ((cy - margin.top) / plotH) * mapH }); // Draw sphere background (areas inside projection but outside arena) - this._drawBackground(ctx, w, h, canvasToMap); + this._drawBackground(ctx, w, h, canvasToMap, margin); - // Draw gridlines - this._drawGridlines(ctx, w, h, bounds, mapToCanvas); + // Draw gridlines (with labels in margin area) + this._drawGridlines(ctx, w, h, bounds, mapToCanvas, margin); // Draw arena pixels - this._drawArenaPixels(ctx, w, h, bounds, mapToCanvas); + this._drawArenaPixels(ctx, w, h, bounds, mapToCanvas, margin); // Draw panel boundaries if (this.state.showPanelBoundaries) { @@ -484,20 +471,29 @@ class ProjectionViewer { /** * 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 + _drawBackground(ctx, w, h, canvasToMap, margin) { + // Fill plot area with sphere background color ctx.fillStyle = '#1a1f26'; - - // Simple fill — subclass decorations will handle ellipse clipping if needed - ctx.fillRect(0, 0, w, h); + const m = margin || { left: 0, right: 0, top: 0, bottom: 0 }; + ctx.fillRect(m.left, m.top, w - m.left - m.right, h - m.top - m.bottom); } /** * Draw latitude/longitude gridlines at 30° intervals. + * Labels are drawn in the margin area so they remain visible when zoomed. */ - _drawGridlines(ctx, w, h, bounds, mapToCanvas) { - ctx.strokeStyle = 'rgba(45, 54, 64, 0.6)'; + _drawGridlines(ctx, w, h, bounds, mapToCanvas, margin) { + const m = margin || { left: 0, right: 0, top: 0, bottom: 0 }; + const plotRight = w - m.right; + const plotBottom = h - m.bottom; + + // Clip gridlines to plot area + ctx.save(); + ctx.beginPath(); + ctx.rect(m.left, m.top, plotRight - m.left, plotBottom - m.top); + ctx.clip(); + + ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; ctx.lineWidth = 0.5; // Longitude lines (vertical) @@ -513,7 +509,6 @@ class ProjectionViewer { 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; @@ -537,7 +532,6 @@ class ProjectionViewer { 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; @@ -549,7 +543,7 @@ class ProjectionViewer { } // Equator highlight - ctx.strokeStyle = 'rgba(45, 54, 64, 0.9)'; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)'; ctx.lineWidth = 1; ctx.beginPath(); let started = false; @@ -568,51 +562,61 @@ class ProjectionViewer { } ctx.stroke(); - // Axis labels - ctx.fillStyle = '#4a5568'; + ctx.restore(); // Remove clip + + // Axis labels — drawn in the margin area (always visible) + ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; ctx.font = '10px "IBM Plex Mono", monospace'; - ctx.textAlign = 'center'; - // Longitude labels along bottom + // Longitude labels along bottom margin + ctx.textAlign = 'center'; 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); + const proj = this._forwardProject(lon, this.latCenter); 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)); + const { cx } = mapToCanvas(proj.x, proj.y); + if (cx >= m.left && cx <= plotRight) { + ctx.fillText(lon + '\u00b0', cx, h - 3); } } - // Latitude labels along left + // Latitude labels along left margin 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); + const proj = this._forwardProject(this.lonCenter, 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); + const { cy } = mapToCanvas(proj.x, proj.y); + if (cy >= m.top && cy <= plotBottom) { + ctx.fillText(lat + '\u00b0', m.left - 4, cy + 3); } } } /** - * Draw arena pixels as colored rectangles. + * Draw arena pixels as colored rectangles (clipped to plot area). */ - _drawArenaPixels(ctx, w, h, bounds, mapToCanvas) { + _drawArenaPixels(ctx, w, h, bounds, mapToCanvas, margin) { + const m = margin || { left: 0, right: 0, top: 0, bottom: 0 }; + const plotW = w - m.left - m.right; + const plotH = h - m.top - m.bottom; 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; + // Clip to plot area + ctx.save(); + ctx.beginPath(); + ctx.rect(m.left, m.top, plotW, plotH); + ctx.clip(); + for (const px of this.pixelData) { // Forward project this pixel const proj = this._forwardProject(px.lonDeg, px.latDeg); @@ -646,12 +650,14 @@ class ProjectionViewer { // 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); + // Dot size: scale pRad to canvas pixels (use plot dimensions, not full canvas) + const dotW = Math.max(1.5, (pRadDeg / mapW) * plotW * 0.95); + const dotH = Math.max(1.5, (pRadDeg / mapH) * plotH * 0.95); ctx.fillRect(cx - dotW / 2, cy - dotH / 2, dotW, dotH); } + + ctx.restore(); // Remove clip } /** diff --git a/pattern_editor.html b/pattern_editor.html index f6b8a8e..ea894f5 100644 --- a/pattern_editor.html +++ b/pattern_editor.html @@ -1706,7 +1706,7 @@