Files
fraxle ffb5ed1d7d 3.8.5
Correct 'Shade' temps for night
2026-08-02 17:38:59 +01:00

340 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ────────────────────────────────────────────────────────────────────────────
// 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'],
['Δ UTCIAir', '°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 01: 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);
}