// ──────────────────────────────────────────────────────────────────────────── // export.js — Pro: Export selected day data as a genuine styled .xlsx file. // // Uses xlsx-js-style (SheetJS fork) for real XLSX output with cell colours. // ──────────────────────────────────────────────────────────────────────────── import { utciCategory, UTCI_BANDS, sunburnMinutes, burnLabel } from './utils.js'; // ── Palette (6-char hex, no #) ──────────────────────────────────────────── const P = { brass: 'C8922A', brassDeep: 'A06E18', honey: 'F0E4C4', cream: 'FFFCF2', warmCream: 'FDF8EE', espresso: '1E1208', warmDark: '4A3420', muted: 'B09870', border: 'C9B08A', line: 'EDE4CC', white: 'FFFFFF', alert: 'C44A3A', warn: 'C8601A', blue: '3A5A9B', }; // Group header row background colours const GROUP_BG = { timeSun: '7A5C2A', felt: '9B3A3A', air: '3A6B9B', uvSolar: '9A6B1A', surf: '4A7A4A', wind: '3A5A7A', sky: '3A6A6A', aq: '6A4A7A', }; // ── Style builder ───────────────────────────────────────────────────────── const thinBorder = (hex = P.line) => ({ style: 'thin', color: { rgb: hex } }); const allBorders = (hex = P.line) => ({ top: thinBorder(hex), bottom: thinBorder(hex), left: thinBorder(hex), right: thinBorder(hex), }); function S({ bg = P.cream, fg = P.warmDark, bold = false, align = 'right', sz = 10, numFmt, wrap = false, borderHex = P.line } = {}) { return { fill: { patternType: 'solid', fgColor: { rgb: bg } }, font: { sz, bold, color: { rgb: fg }, name: 'Calibri' }, alignment: { horizontal: align, vertical: 'center', wrapText: !!wrap }, border: allBorders(borderHex), ...(numFmt ? { numFmt } : {}), }; } // ── Column definitions ──────────────────────────────────────────────────── // [label, unit, align, wch (char width), numFmt] const COLS = [ // Time & Sun (2) ['Time', '', 'center', 7, null ], ['Solar Elev', '°', 'right', 9, '0.0'], // Felt Temperature (5) ['SunSoak', '°C', 'right', 9, '0.0'], ['Condition', '', 'left', 14, null ], ['UTCI', '°C', 'right', 7, '0.0'], ['Δ UTCI−Air', '°C', 'right', 9, '0.0'], ['Tmrt', '°C', 'right', 7, '0.0'], // Air (4) ['Air Temp', '°C', 'right', 8, '0.0'], ['Shade Air', '°C', 'right', 9, '0.0'], ['Dew Point', '°C', 'right', 9, '0.0'], ['Humidity', '%', 'right', 8, '0' ], // UV & Solar (7) ['UV Index', '', 'right', 8, '0.0'], ['UV-A', '', 'right', 7, '0.0'], ['UV-B', '', 'right', 7, '0.00'], ['Burn Time', '', 'right', 9, null ], ['Direct Rad', 'W/m²', 'right', 10, '0' ], ['Diffuse', 'W/m²', 'right', 9, '0' ], ['Global Rad', 'W/m²', 'right', 10, '0' ], // Surfaces (6) ['Vehicle', '°C', 'right', 8, '0.0'], ['Indoor', '°C', 'right', 8, '0.0'], ['Concrete', '°C', 'right', 9, '0.0'], ['Soil Surf', '°C', 'right', 9, '0.0'], ['Soil 6cm', '°C', 'right', 8, '0.0'], ['Soil Moist', '%', 'right', 9, '0' ], // Wind (3) ['Wind', 'm/s', 'right', 7, '0.0'], ['Gusts', 'm/s', 'right', 7, '0.0'], ['Direction', '', 'center', 9, null ], // Sky & Precip (8) ['Cloud', '%', 'right', 7, '0' ], ['Cloud Low', '%', 'right', 9, '0' ], ['Cloud Mid', '%', 'right', 9, '0' ], ['Cloud High', '%', 'right', 10, '0' ], ['Precip', 'mm/h', 'right', 8, '0.00'], ['Rain Prob', '%', 'right', 9, '0' ], ['Snowfall', 'cm/h', 'right', 9, '0.00'], ['Visibility', 'km', 'right', 10, '0.0'], // Air Quality (7) ['AQI', '', 'right', 7, '0' ], ['Grass Pollen','g/m³', 'right', 12, '0' ], ['Birch Pollen','g/m³', 'right', 12, '0' ], ['Alder Pollen','g/m³', 'right', 12, '0' ], ['Mugwort', 'g/m³', 'right', 10, '0' ], ['Olive Pollen','g/m³', 'right', 12, '0' ], ['Ragweed', 'g/m³', 'right', 10, '0' ], ]; const TOTAL_COLS = COLS.length; // 42 // Groups: [label, span, bgKey] const GROUPS = [ ['Time & Sun', 2, 'timeSun'], ['Felt Temperature', 5, 'felt' ], ['Air', 4, 'air' ], ['UV & Solar', 7, 'uvSolar'], ['Surfaces', 6, 'surf' ], ['Wind', 3, 'wind' ], ['Sky & Precipitation', 8, 'sky' ], ['Air Quality', 7, 'aq' ], ]; // ── Worksheet cell helpers (X = the XLSXStyle instance) ────────────────── function addr(X, r, c) { return X.utils.encode_cell({ r, c }); } function setStr(X, ws, r, c, value, styleOpts) { ws[addr(X, r, c)] = { v: value ?? '', t: 's', s: S(styleOpts) }; } function setNum(X, ws, r, c, value, styleOpts) { if (value == null) { ws[addr(X, r, c)] = { v: '', t: 's', s: S(styleOpts) }; } else { ws[addr(X, r, c)] = { v: Number(value), t: 'n', s: S(styleOpts) }; } } // ── UTCI colour helpers ─────────────────────────────────────────────────── function utciBgHex(val) { if (val == null) return P.cream; return (utciCategory(val)?.bg ?? '#' + P.cream).replace('#', ''); } function utciFgHex(val) { if (val == null) return P.warmDark; return (utciCategory(val)?.fg ?? '#' + P.warmDark).replace('#', ''); } // Blend a hex colour with white (ratio 0–1: 0 = full white, 1 = full colour) function blendWhite(hex, ratio = 0.4) { const h = hex.replace('#', ''); const n = parseInt(h, 16); const blend = (c) => Math.round(255 + (c - 255) * ratio); const r = blend((n >> 16) & 255); const g = blend((n >> 8) & 255); const b = blend( n & 255); return [r, g, b].map(x => x.toString(16).padStart(2, '0')).join(''); } function tempBg(val) { return blendWhite(utciBgHex(val), 0.42); } // ── Build Forecast worksheet ────────────────────────────────────────────── function buildForecastSheet(X, rows, locationName, dateLabel, skinType) { const ws = {}; const merges = []; // ── Row 0: Title ───────────────────────────────────────────────────── const titleStyle = S({ bg: P.brass, fg: P.white, bold: true, align: 'center', sz: 12, borderHex: P.brassDeep }); setStr(X, ws, 0, 0, `SunScope · ${locationName} · ${dateLabel}`, { bg: P.brass, fg: P.white, bold: true, align: 'center', sz: 12, borderHex: P.brassDeep }); for (let c = 1; c < TOTAL_COLS; c++) { ws[addr(X, 0, c)] = { v: '', t: 's', s: titleStyle }; } merges.push({ s: { r: 0, c: 0 }, e: { r: 0, c: TOTAL_COLS - 1 } }); // ── Row 1: Group headers ────────────────────────────────────────────── let colCursor = 0; for (const [label, span, bgKey] of GROUPS) { const bg = GROUP_BG[bgKey]; const groupStyle = S({ bg, fg: P.white, bold: true, align: 'center', sz: 10, borderHex: bg }); setStr(X, ws, 1, colCursor, label, { bg, fg: P.white, bold: true, align: 'center', sz: 10, borderHex: bg }); for (let i = 1; i < span; i++) { ws[addr(X, 1, colCursor + i)] = { v: '', t: 's', s: groupStyle }; } if (span > 1) merges.push({ s: { r: 1, c: colCursor }, e: { r: 1, c: colCursor + span - 1 } }); colCursor += span; } // ── Row 2: Column headers ───────────────────────────────────────────── for (let c = 0; c < TOTAL_COLS; c++) { const [label, unit] = COLS[c]; const text = unit ? `${label} (${unit})` : label; setStr(X, ws, 2, c, text, { bg: P.honey, fg: P.espresso, bold: true, align: 'center', sz: 10, borderHex: P.border }); } // ── Rows 3+: Data ───────────────────────────────────────────────────── rows.forEach((r, i) => { const rowIdx = 3 + i; const isNight = r.elev < -0.833; const rowBg = isNight ? 'EAE3D5' : (i % 2 === 1 ? 'FDF8EE' : P.cream); const timeFg = isNight ? P.muted : P.warmDark; const def = (bg = rowBg) => ({ bg, fg: P.warmDark, sz: 10, borderHex: P.line }); // Time & Sun setStr(X, ws, rowIdx, 0, r.iso.slice(11, 16), { ...def(), fg: timeFg, align: 'center', bold: true }); setNum(X, ws, rowIdx, 1, r.elev, { ...def(), align: 'right', numFmt: '0.0' }); // Felt Temperature — SunSoak with UTCI band colour const sBg = utciBgHex(r.utciAdj); const sFg = utciFgHex(r.utciAdj); const cond = r.utciAdj != null ? (utciCategory(r.utciAdj)?.label ?? '') : ''; setNum(X, ws, rowIdx, 2, r.utciAdj, { bg: sBg, fg: sFg, bold: true, align: 'right', sz: 10, numFmt: '0.0', borderHex: P.line }); setStr(X, ws, rowIdx, 3, cond, { bg: blendWhite(sBg, 0.55), fg: P.warmDark, align: 'left', sz: 10, borderHex: P.line }); setNum(X, ws, rowIdx, 4, r.utci, { bg: tempBg(r.utci), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line }); const delta = (r.utci != null && r.Ta != null) ? r.utci - r.Ta : null; setNum(X, ws, rowIdx, 5, delta, { bg: rowBg, fg: delta != null && delta > 3 ? P.warn : delta != null && delta < -3 ? '3F73C4' : P.warmDark, sz: 10, numFmt: '0.0', borderHex: P.line }); setNum(X, ws, rowIdx, 6, r.Tmrt, { bg: tempBg(r.Tmrt), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line }); // Air setNum(X, ws, rowIdx, 7, r.Ta, { bg: tempBg(r.Ta), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line }); setNum(X, ws, rowIdx, 8, r.shadeT, { bg: tempBg(r.shadeT), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line }); setNum(X, ws, rowIdx, 9, r.dew, { ...def(), numFmt: '0.0' }); setNum(X, ws, rowIdx, 10, r.RH, { ...def(), numFmt: '0' }); // UV & Solar const uvFg = r.uv >= 8 ? P.alert : r.uv >= 3 ? P.warn : P.warmDark; setNum(X, ws, rowIdx, 11, r.uv, { ...def(), fg: uvFg, numFmt: '0.0' }); setNum(X, ws, rowIdx, 12, r.uvA, { ...def(), numFmt: '0.0' }); setNum(X, ws, rowIdx, 13, r.uvB, { ...def(), numFmt: '0.00' }); const burnMins = sunburnMinutes(r.uv, skinType); const burnStr = r.uv > 0 ? burnLabel(burnMins) : '—'; const burnFg = r.uv > 0 ? (burnMins < 30 ? P.alert : P.warn) : P.muted; setStr(X, ws, rowIdx, 14, burnStr, { ...def(), fg: burnFg, align: 'right' }); setNum(X, ws, rowIdx, 15, r.dir, { ...def(), numFmt: '0' }); setNum(X, ws, rowIdx, 16, r.dif, { ...def(), numFmt: '0' }); setNum(X, ws, rowIdx, 17, r.glob, { ...def(), numFmt: '0' }); // Surfaces setNum(X, ws, rowIdx, 18, r.vehicleT, { bg: tempBg(r.vehicleT), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line }); setNum(X, ws, rowIdx, 19, r.indoorT, { bg: tempBg(r.indoorT), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line }); setNum(X, ws, rowIdx, 20, r.concreteT, { bg: tempBg(r.concreteT), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line }); setNum(X, ws, rowIdx, 21, r.soilT0, { ...def(), numFmt: '0.0' }); setNum(X, ws, rowIdx, 22, r.soilT6, { ...def(), numFmt: '0.0' }); setNum(X, ws, rowIdx, 23, r.soilM != null ? Math.round(r.soilM * 100) : null, { ...def(), numFmt: '0' }); // Wind setNum(X, ws, rowIdx, 24, r.va, { ...def(), numFmt: '0.0' }); setNum(X, ws, rowIdx, 25, r.gust, { ...def(), numFmt: '0.0' }); setStr(X, ws, rowIdx, 26, r.compass?.label ?? (r.wd != null ? `${Math.round(r.wd)}°` : '—'), { ...def(), align: 'center' }); // Sky & Precip setNum(X, ws, rowIdx, 27, r.cc, { ...def(), numFmt: '0' }); setNum(X, ws, rowIdx, 28, r.ccLow, { ...def(), numFmt: '0' }); setNum(X, ws, rowIdx, 29, r.ccMid, { ...def(), numFmt: '0' }); setNum(X, ws, rowIdx, 30, r.ccHigh, { ...def(), numFmt: '0' }); setNum(X, ws, rowIdx, 31, r.precip > 0 ? r.precip : null, { ...def(), fg: r.precip > 2 ? P.blue : P.warmDark, numFmt: '0.00' }); setNum(X, ws, rowIdx, 32, r.precipProb, { ...def(), fg: r.precipProb >= 70 ? P.blue : P.warmDark, numFmt: '0' }); setNum(X, ws, rowIdx, 33, r.snow > 0 ? r.snow : null, { ...def(), numFmt: '0.00' }); setNum(X, ws, rowIdx, 34, r.visKm, { ...def(), numFmt: '0.0' }); // Air Quality setNum(X, ws, rowIdx, 35, r.aqi, { ...def(), fg: r.aqi >= 50 ? P.alert : P.warmDark, numFmt: '0' }); setNum(X, ws, rowIdx, 36, r.grassPollen, { ...def(), numFmt: '0' }); setNum(X, ws, rowIdx, 37, r.birchPollen, { ...def(), numFmt: '0' }); setNum(X, ws, rowIdx, 38, r.alderPollen, { ...def(), numFmt: '0' }); setNum(X, ws, rowIdx, 39, r.mugwortPollen, { ...def(), numFmt: '0' }); setNum(X, ws, rowIdx, 40, r.olivePollen, { ...def(), numFmt: '0' }); setNum(X, ws, rowIdx, 41, r.ragweedPollen, { ...def(), numFmt: '0' }); }); // ── Sheet metadata ──────────────────────────────────────────────────── ws['!ref'] = X.utils.encode_range({ s: { r: 0, c: 0 }, e: { r: 3 + rows.length - 1, c: TOTAL_COLS - 1 } }); ws['!cols'] = COLS.map(([,,,wch]) => ({ wch })); ws['!merges'] = merges; ws['!freeze'] = { xSplit: 1, ySplit: 3 }; return ws; } // ── Build Legend worksheet ──────────────────────────────────────────────── function buildLegendSheet(X) { const ws = {}; const merges = []; setStr(X, ws, 0, 0, 'SunSoak / UTCI Condition Scale', { bg: P.brass, fg: P.white, bold: true, align: 'center', sz: 11, borderHex: P.brassDeep }); ws[addr(X, 0, 1)] = { v: '', t: 's', s: S({ bg: P.brass, fg: P.white, borderHex: P.brassDeep }) }; merges.push({ s: { r: 0, c: 0 }, e: { r: 0, c: 1 } }); setStr(X, ws, 1, 0, 'Condition', { bg: P.honey, fg: P.espresso, bold: true, align: 'center', sz: 10, borderHex: P.border }); setStr(X, ws, 1, 1, 'SunSoak / UTCI range', { bg: P.honey, fg: P.espresso, bold: true, align: 'center', sz: 10, borderHex: P.border }); let prev = -Infinity; UTCI_BANDS.forEach((band, i) => { const bg = band.bg.replace('#', ''); const fg = band.fg.replace('#', ''); const rangeStr = band.max != null ? `${prev === -Infinity ? 'below' : `${prev}°C to`} ${band.max}°C` : `${prev}°C and above`; setStr(X, ws, 2 + i, 0, band.label, { bg, fg, bold: true, align: 'center', sz: 10, borderHex: P.border }); setStr(X, ws, 2 + i, 1, rangeStr, { bg: P.cream, fg: P.warmDark, align: 'left', sz: 10, borderHex: P.line }); if (band.max != null) prev = band.max; }); ws['!ref'] = X.utils.encode_range({ s: { r: 0, c: 0 }, e: { r: 2 + UTCI_BANDS.length - 1, c: 1 } }); ws['!cols'] = [{ wch: 18 }, { wch: 22 }]; ws['!merges'] = merges; return ws; } // ── Main export function ────────────────────────────────────────────────── export async function exportDayXls(rows, { locationName = '', dateLabel = '', skinType = 'type2' } = {}) { if (!rows || rows.length === 0) return; const xlsxMod = await import('xlsx-js-style').catch(() => null); if (!xlsxMod) { alert('Export requires the built version of SunScope. Run npm run build and serve from dist/.'); return; } const X = xlsxMod.default ?? xlsxMod; const wb = X.utils.book_new(); X.utils.book_append_sheet(wb, buildForecastSheet(X, rows, locationName, dateLabel, skinType), 'Forecast'); X.utils.book_append_sheet(wb, buildLegendSheet(X), 'Legend'); const buf = X.write(wb, { bookType: 'xlsx', type: 'array' }); const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); const url = URL.createObjectURL(blob); const slug = (locationName || 'location').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); const dateSlug = (dateLabel || '').replace(/\s+/g, '-').replace(/[^a-z0-9-]/gi, '').toLowerCase(); const a = document.createElement('a'); a.href = url; a.download = `sunscope-${slug}-${dateSlug}.xlsx`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }