1const FORECAST_NOTE = { 2full: 'Surf forecast for open water near the coast.', 3short: 'Open-water surf forecast.', 4}; 5const RECORD_NOTE_SHORT = 'Past conditions, not a forecast.'; 6function recordNoteFull(historical) { 7const d = historical?.date ? new Date(`${historical.date}T12:00:00Z`) : null; 8if (!d || Number.isNaN(d.getTime())) return RECORD_NOTE_SHORT; 9const day = new Intl.DateTimeFormat('en-GB', { 10day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC', 11}).format(d); 12return `Conditions on ${day}, not a forecast.`; 13} 14function seaTempDegrees(celsius, unit) { 15if (typeof celsius !== 'number' || !Number.isFinite(celsius)) return null; 16return unit === 'ft' ? Math.round(celsius * 9 / 5 + 32) : Math.round(celsius); 17} 18export function formatSeaTemp(celsius, unit) { 19const n = seaTempDegrees(celsius, unit); 20return n === null ? null : `${n}°${unit === 'ft' ? 'F' : 'C'}`; 21} 22function spokenSeaTemp(celsius, unit) { 23const n = seaTempDegrees(celsius, unit); 24return n === null ? '' : `Water temperature, ${n} degrees ${unit === 'ft' ? 'Fahrenheit' : 'Celsius'}`; 25} 26let els = null; 27const last = { placeFull: null, placeShort: null, temp: null, tempShown: null, noteFull: null, noteShort: null, timeText: '', timeShown: null, historical: false }; 28function refs() { 29if (els) return els; 30const root = document.getElementById('spot-header'); 31if (!root) return null; 32els = { 33root, 34place: document.getElementById('spot-header-place'), 35temp: document.getElementById('spot-header-temp'), 36tempValue: document.getElementById('spot-header-temp-value'), 37tempSpoken: document.getElementById('spot-header-temp-sr'), 38note: document.getElementById('spot-header-note'), 39time: document.getElementById('spot-header-time'), 40sep: document.getElementById('spot-header-sep'), 41}; 42window.addEventListener('resize', fitSpotHeader); 43document.fonts?.ready?.then(fitSpotHeader); 44if (typeof ResizeObserver !== 'undefined') new ResizeObserver(fitSpotHeader).observe(root); 45return els; 46} 47function setText(el, key, text) { 48if (last[key] === text) return; 49last[key] = text; 50el.textContent = text; 51}
52export function renderSpotHeader(state, { ready } = {}) { 53const r = refs(); 54if (!r) return; 55const show = !!ready && !!state.locationName; 56if (r.root.hidden === show) r.root.hidden = !show; 57if (!show) return; 58const placeFull = state.section 59? `${state.locationName} (${state.section})` 60: state.locationName; 61const placeChanged = last.placeFull !== placeFull; 62last.placeFull = placeFull; 63last.placeShort = state.locationName; 64const historical = state.historicalRecord; 65const temp = historical ? null : formatSeaTemp(state.seaSurfaceTemp, state.unit); 66r.temp.hidden = !temp; 67if (temp) { 68setText(r.tempValue, 'temp', temp); 69if (r.tempSpoken) setText(r.tempSpoken, 'tempSpoken', spokenSeaTemp(state.seaSurfaceTemp, state.unit)); 70} 71const tempChanged = last.tempShown !== (temp || ''); 72last.tempShown = temp || ''; 73if (last.historical !== !!historical) { 74last.historical = !!historical; 75applyTime(); 76} 77const noteFull = historical ? recordNoteFull(historical) : FORECAST_NOTE.full; 78const noteChanged = last.noteFull !== noteFull; 79if (noteChanged) { 80last.noteFull = noteFull; 81last.noteShort = historical ? RECORD_NOTE_SHORT : FORECAST_NOTE.short; 82} 83if (placeChanged || tempChanged || noteChanged) fitSpotHeader(); 84} 85export function setSpotHeaderTime(text) { 86last.timeText = text || ''; 87applyTime(); 88} 89function applyTime() { 90const r = refs(); 91if (!r || !r.time) return; 92const shown = last.historical ? '' : last.timeText; 93if (shown === last.timeShown) return; 94last.timeShown = shown; 95r.time.textContent = shown; 96r.time.hidden = !shown; 97if (r.sep) r.sep.hidden = !shown; 98fitSpotHeader(); 99} 100export function fitSpotHeader() { 101const r = refs(); 102if (!r) return; 103syncCapLead(r); 104if (r.root.hidden) return; 105if (last.placeFull) fitLine(r.place, last.placeFull, last.placeShort); 106if (last.noteFull) { 107const widest = widestTimeWidth(r); 108if (widest) r.time.style.minWidth = `${widest}px`; 109fitLine(r.note, last.noteFull, last.noteShort); 110if (widest) r.time.style.removeProperty('min-width'); 111} 112} 113const TIME_PREFIXES = ['Now', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; 114let timeCtx = null; 115const widestCache = new Map(); 116function widestTimeWidth(r) { 117const text = last.timeShown; 118if (!text || !r.time || r.time.hidden) return 0; 119const cs = getComputedStyle(r.time); 120const font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; 121timeCtx = timeCtx || document.createElement('canvas').getContext('2d'); 122if (!timeCtx) return 0; 123timeCtx.font = font; 124const twelveHour = /[AP]M$/.test(text); 125const key = `${font}|${twelveHour}`; 126let widest = widestCache.get(key); 127if (widest == null) { 128const w = (t) => timeCtx.measureText(t).width; 129const digit = [...'0123456789'].reduce((a, d) => (w(d) > w(a) ? d : a)); 130const prefix = TIME_PREFIXES.reduce((a, p) => (w(p) > w(a) ? p : a)); 131const hm = `${digit}${digit}:${digit}${digit}`; 132const suffix = twelveHour ? (w('AM') > w('PM') ? ' AM' : ' PM') : ''; 133widest = w(`${prefix} ${hm}${suffix}`); 134widestCache.set(key, widest); 135} 136return Math.ceil(widest); 137} 138let capCtx = null; 139let lastCapLead = null; 140function syncCapLead(r) { 141if (!r.place) return; 142const cs = getComputedStyle(r.place); 143const size = parseFloat(cs.fontSize); 144if (!(size > 0)) return; 145capCtx = capCtx || document.createElement('canvas').getContext('2d'); 146if (!capCtx) return; 147capCtx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; 148const m = capCtx.measureText('H'); 149const ascent = m.fontBoundingBoxAscent; 150const descent = m.fontBoundingBoxDescent; 151const cap = m.actualBoundingBoxAscent; 152if (!(ascent > 0) || !(cap > 0)) return; 153const lineHeight = parseFloat(cs.lineHeight); 154const halfLeading = lineHeight > 0 ? (lineHeight - (ascent + (descent || 0))) / 2 : 0; 155const lead = Math.max(0, halfLeading + ascent - cap); 156const px = `${lead.toFixed(2)}px`; 157if (px === lastCapLead) return; 158lastCapLead = px; 159r.root.style.setProperty('--spot-header-cap-lead', px); 160} 161function fitLine(el, full, short) { 162if (el.textContent !== full) el.textContent = full; 163if (el.clientWidth === 0 || short === full) return; 164if (el.scrollWidth > el.clientWidth + 1) el.textContent = short; 165}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.