@@ -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 ) ;
}