340 lines
17 KiB
JavaScript
340 lines
17 KiB
JavaScript
// ────────────────────────────────────────────────────────────────────────────
|
||
// 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 Felt', '°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);
|
||
}
|