2.7.5
Export data spreadsheet
This commit is contained in:
@@ -79,7 +79,6 @@ body {
|
||||
.utci-shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1258,6 +1258,65 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Pro: Export day data ──────────────────────────────────────────────── */
|
||||
.export-panel {
|
||||
margin-top: 10px;
|
||||
background: #fffcf2;
|
||||
border: 1px solid #c9b08a;
|
||||
border-radius: 4px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.export-day-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: linear-gradient(135deg, #c8922a 0%, #a06e18 100%);
|
||||
color: #fffcf2;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-family: Manrope, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.02em;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.export-day-btn:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.export-day-btn:active {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.export-day-btn--locked {
|
||||
background: linear-gradient(135deg, #c0b090 0%, #9a8060 100%);
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.export-day-btn--locked:hover {
|
||||
opacity: 0.90;
|
||||
}
|
||||
|
||||
.export-day-btn__icon {
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.export-day-hint {
|
||||
margin-top: 7px;
|
||||
font-family: Manrope, sans-serif;
|
||||
font-size: 10px;
|
||||
color: #9a7d5a;
|
||||
text-align: center;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.insight-panel {
|
||||
background: #fffcf2;
|
||||
border: 1px solid #c9b08a;
|
||||
|
||||
+31
-2
@@ -35,6 +35,7 @@ import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS } from './config.js';
|
||||
import { useAppState } from './hooks/useAppState.js';
|
||||
import { DayTabs } from './components/DayTabs.js';
|
||||
import { computeWhyFeelsLike, computeGlanceSummary } from './compute.js';
|
||||
import { exportDayXls } from './export.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
@@ -226,6 +227,15 @@ export function UTCIForecast() {
|
||||
return `${wd} ${day}${ord} ${mon} ${yr}`;
|
||||
})();
|
||||
|
||||
// Pro: export the selected day's full hourly data as a styled spreadsheet.
|
||||
const handleExportDay = () => {
|
||||
exportDayXls(visible, {
|
||||
locationName: location?.name ?? 'Unknown',
|
||||
dateLabel: glanceDate,
|
||||
skinType,
|
||||
});
|
||||
};
|
||||
|
||||
// Day's events surfaced in the "Day at a glance" box. Reuses the same
|
||||
// row shape as glanceSummary items: { icon, label, value, alert }.
|
||||
const hhmm = (iso) => (iso ? iso.slice(11, 16) : '');
|
||||
@@ -1008,8 +1018,9 @@ export function UTCIForecast() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0) && html`
|
||||
${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0 || visible.length > 0) && html`
|
||||
<aside class="glance-rail">
|
||||
${((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0) && html`
|
||||
<div class="insight-panel insight-panel--glance">
|
||||
<div class="insight-panel-title">At a glance${glanceDate ? ` · ${glanceDate}` : ''}</div>
|
||||
${glanceSummary.map(({ icon, label, value, alert }) => html`
|
||||
@@ -1026,7 +1037,25 @@ export function UTCIForecast() {
|
||||
<span class="insight-value">${value}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>`}
|
||||
${visible.length > 0 && html`
|
||||
<div class="export-panel">
|
||||
${isPro
|
||||
? html`
|
||||
<button class="export-day-btn" onClick=${handleExportDay} title="Download full hourly data for this day as a spreadsheet">
|
||||
<span class="export-day-btn__icon">⬇</span>
|
||||
Export day data
|
||||
</button>
|
||||
<div class="export-day-hint">Excel / LibreOffice · 41 columns · hourly</div>`
|
||||
: html`
|
||||
<button class="export-day-btn export-day-btn--locked"
|
||||
onClick=${() => { setProPromptSource('export'); setProPromptDay(0); }}
|
||||
title="Export day data is part of SunScope Extra">
|
||||
<span class="export-day-btn__icon">🔒</span>
|
||||
Export day data
|
||||
</button>
|
||||
<div class="export-day-hint">SunScope Extra feature</div>`}
|
||||
</div>`}
|
||||
</aside>`}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -447,6 +447,10 @@ export function DayTabs({
|
||||
title: 'Office is part of SunScope Extra',
|
||||
detail: 'Extra adds the Office profile with solar gain, managed indoor temperature, humidity, air quality, and rain probability — useful for commute planning and building comfort.',
|
||||
},
|
||||
'export': {
|
||||
title: 'Export day data is part of SunScope Extra',
|
||||
detail: 'Extra lets you download a full hourly spreadsheet for any day — all 41 columns including felt temperature, UV, soil, wind, and air quality, styled and ready to use in Excel or LibreOffice.',
|
||||
},
|
||||
};
|
||||
const promptCopy = extraPromptCopy[proPromptSource] || {
|
||||
title: `${dayName}'s forecast is part of SunScope Extra`,
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 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 (3)
|
||||
['Air Temp', '°C', 'right', 8, '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; // 41
|
||||
|
||||
// Groups: [label, span, bgKey]
|
||||
const GROUPS = [
|
||||
['Time & Sun', 2, 'timeSun'],
|
||||
['Felt Temperature', 5, 'felt' ],
|
||||
['Air', 3, '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.dew, { ...def(), numFmt: '0.0' });
|
||||
setNum(X, ws, rowIdx, 9, 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, 10, r.uv, { ...def(), fg: uvFg, numFmt: '0.0' });
|
||||
setNum(X, ws, rowIdx, 11, r.uvA, { ...def(), numFmt: '0.0' });
|
||||
setNum(X, ws, rowIdx, 12, 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, 13, burnStr, { ...def(), fg: burnFg, align: 'right' });
|
||||
setNum(X, ws, rowIdx, 14, r.dir, { ...def(), numFmt: '0' });
|
||||
setNum(X, ws, rowIdx, 15, r.dif, { ...def(), numFmt: '0' });
|
||||
setNum(X, ws, rowIdx, 16, r.glob, { ...def(), numFmt: '0' });
|
||||
|
||||
// Surfaces
|
||||
setNum(X, ws, rowIdx, 17, r.vehicleT, { bg: tempBg(r.vehicleT), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line });
|
||||
setNum(X, ws, rowIdx, 18, r.indoorT, { bg: tempBg(r.indoorT), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line });
|
||||
setNum(X, ws, rowIdx, 19, r.concreteT, { bg: tempBg(r.concreteT), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line });
|
||||
setNum(X, ws, rowIdx, 20, r.soilT0, { ...def(), numFmt: '0.0' });
|
||||
setNum(X, ws, rowIdx, 21, r.soilT6, { ...def(), numFmt: '0.0' });
|
||||
setNum(X, ws, rowIdx, 22, r.soilM != null ? Math.round(r.soilM * 100) : null, { ...def(), numFmt: '0' });
|
||||
|
||||
// Wind
|
||||
setNum(X, ws, rowIdx, 23, r.va, { ...def(), numFmt: '0.0' });
|
||||
setNum(X, ws, rowIdx, 24, r.gust, { ...def(), numFmt: '0.0' });
|
||||
setStr(X, ws, rowIdx, 25, r.compass?.label ?? (r.wd != null ? `${Math.round(r.wd)}°` : '—'), { ...def(), align: 'center' });
|
||||
|
||||
// Sky & Precip
|
||||
setNum(X, ws, rowIdx, 26, r.cc, { ...def(), numFmt: '0' });
|
||||
setNum(X, ws, rowIdx, 27, r.ccLow, { ...def(), numFmt: '0' });
|
||||
setNum(X, ws, rowIdx, 28, r.ccMid, { ...def(), numFmt: '0' });
|
||||
setNum(X, ws, rowIdx, 29, r.ccHigh, { ...def(), numFmt: '0' });
|
||||
setNum(X, ws, rowIdx, 30, r.precip > 0 ? r.precip : null, { ...def(), fg: r.precip > 2 ? P.blue : P.warmDark, numFmt: '0.00' });
|
||||
setNum(X, ws, rowIdx, 31, r.precipProb, { ...def(), fg: r.precipProb >= 70 ? P.blue : P.warmDark, numFmt: '0' });
|
||||
setNum(X, ws, rowIdx, 32, r.snow > 0 ? r.snow : null, { ...def(), numFmt: '0.00' });
|
||||
setNum(X, ws, rowIdx, 33, r.visKm, { ...def(), numFmt: '0.0' });
|
||||
|
||||
// Air Quality
|
||||
setNum(X, ws, rowIdx, 34, r.aqi, { ...def(), fg: r.aqi >= 50 ? P.alert : P.warmDark, numFmt: '0' });
|
||||
setNum(X, ws, rowIdx, 35, r.grassPollen, { ...def(), numFmt: '0' });
|
||||
setNum(X, ws, rowIdx, 36, r.birchPollen, { ...def(), numFmt: '0' });
|
||||
setNum(X, ws, rowIdx, 37, r.alderPollen, { ...def(), numFmt: '0' });
|
||||
setNum(X, ws, rowIdx, 38, r.mugwortPollen, { ...def(), numFmt: '0' });
|
||||
setNum(X, ws, rowIdx, 39, r.olivePollen, { ...def(), numFmt: '0' });
|
||||
setNum(X, ws, rowIdx, 40, 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);
|
||||
}
|
||||
@@ -44,6 +44,13 @@ function cleanDist() {
|
||||
for (const f of targets) {
|
||||
try { fs.unlinkSync(f); } catch (_) { /* ok if missing */ }
|
||||
}
|
||||
// Clean up any previously-generated ESM chunks
|
||||
const chunksDir = path.join(ASSETS, 'chunks');
|
||||
if (fs.existsSync(chunksDir)) {
|
||||
for (const f of fs.readdirSync(chunksDir)) {
|
||||
try { fs.unlinkSync(path.join(chunksDir, f)); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively resolve CSS @import statements into one flat string
|
||||
@@ -80,7 +87,8 @@ function rewriteHTML(html, version) {
|
||||
'$1about.min.css?v=' + version + '$2'
|
||||
);
|
||||
const css = ' <link rel="stylesheet" href="./assets/bundle.min.css?v=' + version + '">';
|
||||
const js = ' <script defer src="./assets/bundle.min.js?v=' + version + '"></script>';
|
||||
// ESM module — browser loads chunks automatically on demand (e.g. xlsx-js-style)
|
||||
const js = ' <script type="module" src="./assets/bundle.min.js?v=' + version + '"></script>';
|
||||
html = html.replace('</head>', css + '\n' + js + '\n</head>');
|
||||
return html;
|
||||
}
|
||||
@@ -110,15 +118,17 @@ async function build() {
|
||||
fs.mkdirSync(ASSETS, { recursive: true });
|
||||
console.log(' Output dir ready: dist/');
|
||||
|
||||
// 2. Bundle + minify JavaScript
|
||||
// 2. Bundle + minify JavaScript (ESM with code splitting for lazy chunks)
|
||||
console.log(' Bundling JS...');
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(ROOT, 'assets/js/main.js')],
|
||||
bundle: true,
|
||||
splitting: true,
|
||||
minify: true,
|
||||
format: 'iife',
|
||||
globalName: 'SunScope',
|
||||
outfile: path.join(ASSETS, 'bundle.min.js'),
|
||||
format: 'esm',
|
||||
entryNames: 'bundle.min',
|
||||
chunkNames: 'chunks/[name]-[hash]',
|
||||
outdir: ASSETS,
|
||||
logLevel: 'warning',
|
||||
plugins: [stripJSXCommentsPlugin],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user