From 53662ac36fe0986f61c3e68468bfb2e8830f1886 Mon Sep 17 00:00:00 2001 From: Arnaud Nelissen Date: Fri, 17 Apr 2026 14:09:59 +0200 Subject: [PATCH] Interpolate sparse horizon CSVs instead of rejecting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any CSV with ≥ 2 az_deg,alt_deg rows now works. Points are sorted by azimuth, then linearly interpolated to fill all 360 degrees. A 28-point sparse horizon profile interpolates cleanly; an exact 360-point file is used as-is. Also resets the file input after upload so re-uploading the same file works without picking a different one first. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/Settings.tsx | 42 ++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index f9fc3a7..a9ad6cc 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -359,20 +359,44 @@ export default function Settings() { const handleHorizonCSV = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; + // Reset input so the same file can be re-uploaded after a reset + e.target.value = ''; const text = await file.text(); - const lines = text.trim().split('\n').slice(1); // skip header - const points: HorizonPoint[] = []; - for (const line of lines) { + const raw: HorizonPoint[] = []; + for (const line of text.trim().split('\n')) { const [az, alt] = line.split(',').map(Number); if (!isNaN(az) && !isNaN(alt)) { - points.push({ az_deg: Math.round(az) % 360, alt_deg: Math.max(0, Math.min(90, alt)) }); + raw.push({ az_deg: ((Math.round(az) % 360) + 360) % 360, alt_deg: Math.max(0, Math.min(90, alt)) }); } } - if (points.length === 360) { - setHorizon.mutate(points); - } else { - alert(`CSV must have exactly 360 rows (got ${points.length}). Format: az_deg,alt_deg`); + if (raw.length < 2) { + alert('CSV must have at least 2 valid az_deg,alt_deg rows.'); + return; } + // Sort by azimuth + raw.sort((a, b) => a.az_deg - b.az_deg); + + // If already 360 points, use as-is; otherwise interpolate to fill every degree + let full: HorizonPoint[]; + if (raw.length === 360) { + full = raw; + } else { + // Wrap around: append first point at az+360 to close the circle + const pts = [...raw, { az_deg: raw[0].az_deg + 360, alt_deg: raw[0].alt_deg }]; + full = Array.from({ length: 360 }, (_, deg) => { + // Find the two surrounding control points + let lo = pts[0], hi = pts[pts.length - 1]; + for (let i = 0; i < pts.length - 1; i++) { + if (pts[i].az_deg <= deg && pts[i + 1].az_deg >= deg) { + lo = pts[i]; hi = pts[i + 1]; break; + } + } + const span = hi.az_deg - lo.az_deg; + const t = span > 0 ? (deg - lo.az_deg) / span : 0; + return { az_deg: deg, alt_deg: Math.max(0, lo.alt_deg + t * (hi.alt_deg - lo.alt_deg)) }; + }); + } + setHorizon.mutate(full); }; const resetHorizon = () => { @@ -396,7 +420,7 @@ export default function Settings() { {horizonData?.points && }
- Upload a CSV file with columns az_deg,alt_deg, one row per degree (360 rows total). + Upload a CSV with columns az_deg,alt_deg. Sparse files (any number of points ≥ 2) are linearly interpolated to 360°.