1.43 - Extra Pulldown & New Cloud Icons
This commit is contained in:
+152
-62
@@ -223,6 +223,60 @@ export function UTCIForecast() {
|
||||
const [indoorMode, setIndoorMode] = useState('off');
|
||||
const searchTimeout = useRef(null);
|
||||
|
||||
const activateProfile = (key) => {
|
||||
const profile = FILTER_PROFILES[key];
|
||||
setActiveProfile(key);
|
||||
if (key !== 'custom') {
|
||||
setVisibleCols({ ...profile.cols });
|
||||
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
|
||||
setIndoorMode(hasIndoor ? 'on' : 'off');
|
||||
setIndoorManaged(false);
|
||||
}
|
||||
};
|
||||
|
||||
const profileButtonOrder = ['basic', 'home', 'vehicle', 'alltemps', 'custom'];
|
||||
const variantIcons = {
|
||||
urban: '🏙️',
|
||||
beach: '🏖️',
|
||||
events: '🎪',
|
||||
festival: '⛺',
|
||||
sailing: '⛵',
|
||||
wintersports: '🎿',
|
||||
naturist: '☀️',
|
||||
};
|
||||
const activityVariantKeys = ['sailing', 'wintersports', 'naturist'];
|
||||
const placeVariantKeys = ['urban', 'beach', 'events', 'festival'];
|
||||
const activityOptions = [
|
||||
{
|
||||
value: 'farming',
|
||||
label: `${FILTER_PROFILES.farming.icon} ${FILTER_PROFILES.farming.label}`,
|
||||
},
|
||||
...activityVariantKeys.map((k) => {
|
||||
const v = OUTDOORS_VARIANTS[k];
|
||||
return {
|
||||
value: k,
|
||||
label: `${variantIcons[k]} ${v.name}${v.proOnly && !isPro ? ' 🔒' : ''}`,
|
||||
};
|
||||
}),
|
||||
];
|
||||
const placeOptions = placeVariantKeys.map((k) => {
|
||||
const v = OUTDOORS_VARIANTS[k];
|
||||
return {
|
||||
value: k,
|
||||
label: `${variantIcons[k]} ${v.name}${v.proOnly && !isPro ? ' 🔒' : ''}`,
|
||||
};
|
||||
});
|
||||
const activityValue = activeProfile === 'farming'
|
||||
? 'farming'
|
||||
: activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)
|
||||
? outdoorsVariant
|
||||
: 'off';
|
||||
const activityLabel = activityOptions.find((option) => option.value === activityValue)?.label;
|
||||
const placeValue = activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)
|
||||
? outdoorsVariant
|
||||
: 'off';
|
||||
const placeLabel = placeOptions.find((option) => option.value === placeValue)?.label;
|
||||
|
||||
// Refs for the two-scroller table layout (sticky-to-viewport header +
|
||||
// horizontally-scrolling body). The header is clipped (overflow:hidden)
|
||||
// and its inner "track" gets translateX'd via JS to follow the body's
|
||||
@@ -874,7 +928,7 @@ export function UTCIForecast() {
|
||||
<div class="event-banner-title">${ev.title}</div>
|
||||
<div class="event-banner-msg">${ev.message}</div>
|
||||
${dateLine && html`
|
||||
<div class="event-banner-dates" style=${{ opacity: 0.75, fontSize: '11px', fontFamily: 'JetBrains Mono, monospace', letterSpacing: '0.08em', textTransform: 'uppercase', marginTop: '5px' }}>
|
||||
<div class="event-banner-dates" style=${{ opacity: 0.75, fontSize: '11px', fontFamily: 'Manrope, sans-serif', fontWeight: 700, letterSpacing: '0.05em', textTransform: 'uppercase', marginTop: '5px' }}>
|
||||
${dateLine}
|
||||
</div>
|
||||
`}
|
||||
@@ -1072,7 +1126,7 @@ export function UTCIForecast() {
|
||||
const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long', timeZone: 'UTC' });
|
||||
const dayLong = promptDate.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short', timeZone: 'UTC' });
|
||||
const extraPromptCopy = {
|
||||
'profile:sailing': {
|
||||
'variant:sailing': {
|
||||
title: 'Sailing is part of SunScope Extra',
|
||||
detail: 'Extra adds specialist planning views for higher-commitment trips, including wind, exposure, UV, and wet-weather comfort for water conditions.',
|
||||
},
|
||||
@@ -1136,10 +1190,11 @@ export function UTCIForecast() {
|
||||
${promptCopy.detail}
|
||||
</div>
|
||||
<div style=${{
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontFamily: 'Manrope, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '11px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.14em',
|
||||
letterSpacing: '0.07em',
|
||||
color: '#c8922a',
|
||||
marginTop: '10px',
|
||||
}}>
|
||||
@@ -1162,10 +1217,11 @@ export function UTCIForecast() {
|
||||
background: '#c8922a',
|
||||
color: '#fff',
|
||||
textDecoration: 'none',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontFamily: 'Manrope, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '11px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.14em',
|
||||
letterSpacing: '0.07em',
|
||||
borderRadius: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
@@ -1175,10 +1231,11 @@ export function UTCIForecast() {
|
||||
<a
|
||||
href="/restore.php"
|
||||
style=${{
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontFamily: 'Manrope, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.14em',
|
||||
letterSpacing: '0.07em',
|
||||
color: '#c8922a',
|
||||
textDecoration: 'none',
|
||||
borderBottom: '1px solid rgba(200,146,42,0.4)',
|
||||
@@ -1192,10 +1249,11 @@ export function UTCIForecast() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style=${{
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontFamily: 'Manrope, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.14em',
|
||||
letterSpacing: '0.07em',
|
||||
color: '#b09870',
|
||||
textDecoration: 'none',
|
||||
borderBottom: '1px solid rgba(176,152,112,0.4)',
|
||||
@@ -1210,10 +1268,11 @@ export function UTCIForecast() {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontFamily: 'Manrope, sans-serif',
|
||||
fontWeight: 700,
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.14em',
|
||||
letterSpacing: '0.07em',
|
||||
color: '#b09870',
|
||||
padding: '2px 4px',
|
||||
}}
|
||||
@@ -1239,10 +1298,11 @@ export function UTCIForecast() {
|
||||
borderRadius: '0 4px 4px 0',
|
||||
fontSize: '12px',
|
||||
color: '#3a2a18',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontFamily: 'Manrope, sans-serif',
|
||||
fontWeight: 600,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<span style=${{ letterSpacing: '1px', textTransform: 'uppercase', fontWeight: 700 }}>
|
||||
<span style=${{ letterSpacing: '0.06em', textTransform: 'uppercase', fontWeight: 700 }}>
|
||||
Day ${selectedDay + 1} of 14 · Forecast clarity: ${band.label}
|
||||
</span>
|
||||
${isOutlook && html`
|
||||
@@ -1261,45 +1321,10 @@ export function UTCIForecast() {
|
||||
-->
|
||||
<div class="filter-profiles" style=${{ borderBottom: isPro ? 'none' : '' }}>
|
||||
<span class="filter-profiles-label">Profile:</span>
|
||||
${Object.entries(FILTER_PROFILES).map(([key, profile]) => {
|
||||
${profileButtonOrder.slice(0, 3).map((key) => {
|
||||
const profile = FILTER_PROFILES[key];
|
||||
const locked = profile.proOnly && !isPro;
|
||||
|
||||
// The 'outdoors' profile becomes a CustomSelect dropdown,
|
||||
// followed by a separator dividing general-use from technical profiles.
|
||||
if (key === 'outdoors') {
|
||||
return html`
|
||||
<${CustomSelect}
|
||||
key="outdoors"
|
||||
value=${activeProfile === 'outdoors' ? outdoorsVariant : 'off'}
|
||||
isOn=${activeProfile === 'outdoors'}
|
||||
noHide=${true}
|
||||
hideLabel=${`${profile.icon} ${profile.label}`}
|
||||
options=${Object.entries(OUTDOORS_VARIANTS).map(([k, v]) => ({
|
||||
value: k,
|
||||
label: v.name + (v.proOnly && !isPro ? ' 🔒' : ''),
|
||||
disabled: false,
|
||||
}))}
|
||||
onChange=${(v) => {
|
||||
if (v === 'off') {
|
||||
setActiveProfile('basic');
|
||||
setVisibleCols({ ...FILTER_PROFILES.basic.cols });
|
||||
setIndoorMode('off');
|
||||
return;
|
||||
}
|
||||
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
|
||||
setProPromptSource(`variant:${v}`);
|
||||
setProPromptDay(0);
|
||||
return;
|
||||
}
|
||||
setActiveProfile('outdoors');
|
||||
setOutdoorsVariant(v);
|
||||
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
|
||||
setIndoorMode('off');
|
||||
}}
|
||||
/>
|
||||
<span class="profile-divider" aria-hidden="true"></span>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<button
|
||||
key=${key}
|
||||
@@ -1312,17 +1337,82 @@ export function UTCIForecast() {
|
||||
setProPromptDay(0);
|
||||
return;
|
||||
}
|
||||
setActiveProfile(key);
|
||||
if (key !== 'custom') {
|
||||
setVisibleCols({ ...profile.cols });
|
||||
}
|
||||
// Auto-enable indoor column for profiles that include it;
|
||||
// reset to off for profiles that don't.
|
||||
if (key !== 'custom') {
|
||||
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
|
||||
setIndoorMode(hasIndoor ? 'on' : 'off');
|
||||
setIndoorManaged(false);
|
||||
activateProfile(key);
|
||||
}}
|
||||
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
|
||||
})}
|
||||
<${CustomSelect}
|
||||
key="activities"
|
||||
value=${activityValue}
|
||||
isOn=${activeProfile === 'farming' || (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant))}
|
||||
noHide=${true}
|
||||
hideLabel="🎯 Activities"
|
||||
buttonLabel=${activityValue === 'off' ? '🎯 Activities' : `🎯 Activities: ${activityLabel}`}
|
||||
options=${activityOptions}
|
||||
onChange=${(v) => {
|
||||
if (v === 'off') {
|
||||
activateProfile('basic');
|
||||
return;
|
||||
}
|
||||
if (v === 'farming') {
|
||||
activateProfile('farming');
|
||||
return;
|
||||
}
|
||||
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
|
||||
setProPromptSource(`variant:${v}`);
|
||||
setProPromptDay(0);
|
||||
return;
|
||||
}
|
||||
setActiveProfile('outdoors');
|
||||
setOutdoorsVariant(v);
|
||||
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
|
||||
setIndoorMode('off');
|
||||
setIndoorManaged(false);
|
||||
}}
|
||||
/>
|
||||
<${CustomSelect}
|
||||
key="places"
|
||||
value=${placeValue}
|
||||
isOn=${activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)}
|
||||
noHide=${true}
|
||||
hideLabel="🌤️ Places"
|
||||
buttonLabel=${placeValue === 'off' ? '🌤️ Places' : `🌤️ Places: ${placeLabel}`}
|
||||
options=${placeOptions}
|
||||
onChange=${(v) => {
|
||||
if (v === 'off') {
|
||||
activateProfile('basic');
|
||||
return;
|
||||
}
|
||||
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
|
||||
setProPromptSource(`variant:${v}`);
|
||||
setProPromptDay(0);
|
||||
return;
|
||||
}
|
||||
setActiveProfile('outdoors');
|
||||
setOutdoorsVariant(v);
|
||||
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
|
||||
setIndoorMode('off');
|
||||
setIndoorManaged(false);
|
||||
}}
|
||||
/>
|
||||
<span class="profile-divider" aria-hidden="true"></span>
|
||||
${profileButtonOrder.slice(3).map((key) => {
|
||||
const profile = FILTER_PROFILES[key];
|
||||
const locked = profile.proOnly && !isPro;
|
||||
|
||||
return html`
|
||||
<button
|
||||
key=${key}
|
||||
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
|
||||
style=${{ opacity: locked ? 0.6 : 1, cursor: locked ? 'not-allowed' : 'pointer', position: 'relative' }}
|
||||
title=${locked ? `🔒 ${profile.label} is part of SunScope Extra` : ''}
|
||||
onClick=${() => {
|
||||
if (locked) {
|
||||
setProPromptSource(`profile:${key}`);
|
||||
setProPromptDay(0);
|
||||
return;
|
||||
}
|
||||
activateProfile(key);
|
||||
}}
|
||||
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
|
||||
})}
|
||||
@@ -1601,7 +1691,7 @@ ${isPro && (activeProfile === 'custom' || FILTER_PROFILES[activeProfile].cols['a
|
||||
${visibleCols.dir && html`<td class="utci-dir-cell">
|
||||
<span style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', verticalAlign: 'middle' }}>
|
||||
<${WindVane} bearing=${r.wd} size=${28} />
|
||||
<span class="wind-dir-label" style=${{ fontFamily: 'JetBrains Mono, monospace', fontSize: '11px' }}>${r.compass.label}</span>
|
||||
<span class="wind-dir-label" style=${{ fontFamily: 'Manrope, sans-serif', fontSize: '11px', fontWeight: 700 }}>${r.compass.label}</span>
|
||||
</span>
|
||||
</td>`}
|
||||
${visibleCols.cloud && html`<td style=${{ background: scaleBg(r.cc, 0, 100, '110,130,150', 0.07) }}>
|
||||
|
||||
+74
-104
@@ -37,7 +37,7 @@ const html = htm.bind(h);
|
||||
// groupedLeft — bool — fuse right side with a VentPill
|
||||
// isLastChild — bool — restore right border-radius when no sibling follows
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel, isOn, noHide, groupedLeft, isLastChild }) {
|
||||
export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel, buttonLabel, isOn, noHide, groupedLeft, isLastChild }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
@@ -60,9 +60,9 @@ export function CustomSelect({ value, options, onChange, hideLabel, hidingLabel,
|
||||
|
||||
// Build the label shown on the button
|
||||
const activeOpt = options.find(o => o.value === value);
|
||||
const btnLabel = isOn
|
||||
const btnLabel = buttonLabel || (isOn
|
||||
? (activeOpt ? activeOpt.label : hideLabel)
|
||||
: hideLabel;
|
||||
: hideLabel);
|
||||
|
||||
const btnClass = [
|
||||
'cs-btn',
|
||||
@@ -157,8 +157,6 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
|
||||
const isRising = localHour < 12;
|
||||
|
||||
const skyGrad = skyGradientForElev(elev, isRising);
|
||||
// Flat horizon colour used for moon shadow fill
|
||||
const skyHorizon = skyGrad.bot;
|
||||
|
||||
const isDay = elev > 0; // sun only drawn when actually above horizon
|
||||
const isTwil = elev > -8 && !isDay; // moon shown in twilight too
|
||||
@@ -173,7 +171,6 @@ export function SkyScope({ elev, dt, glob = 0, size = 26 }) {
|
||||
// so it's intense at the sun centre and fades smoothly to transparent.
|
||||
const radFrac = Math.min(1, (glob || 0) / 900); // 0 (dark) → 1 (blazing)
|
||||
const horizFrac = Math.max(0, 1 - elev / 20); // extra warmth near horizon
|
||||
const glowColor = isRising ? '#ffb040' : '#ffc060';
|
||||
const glowR = sunR + 4 + radFrac * 6; // total glow radius
|
||||
const glowPeak = 0.35 + radFrac * 0.45 + horizFrac * 0.15; // centre opacity
|
||||
|
||||
@@ -291,7 +288,7 @@ export function WindVane({ bearing, size = 30 }) {
|
||||
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
|
||||
<!-- Static N marker — small letter above the centre, doesn't rotate -->
|
||||
<text x=${r} y=${size * 0.18} text-anchor="middle"
|
||||
font-family="JetBrains Mono, monospace"
|
||||
font-family="Manrope, sans-serif"
|
||||
font-size=${size * 0.26} font-weight="700"
|
||||
fill=${nColor} opacity="0.55">N</text>
|
||||
<!-- Rotating arrow. Shaft + ONE arrowhead. A small round nub on
|
||||
@@ -312,9 +309,8 @@ export function WindVane({ bearing, size = 30 }) {
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// CLOUDICON — clouds + sun/moon on a fully transparent background.
|
||||
// No rim, no disc. Day/night aware: when elev < 0, the sun is
|
||||
// replaced with a phased moon (same logic as SkyScope).
|
||||
// CLOUDICON — Brass Line cloud / haze / overcast glyphs.
|
||||
// No sun or moon here; the time-cell SkyScope owns day/night imagery.
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// category: 'clear' | 'wispy' | 'scattered' | 'overcast'
|
||||
// elev: solar elevation in degrees (negative = night)
|
||||
@@ -322,78 +318,48 @@ export function WindVane({ bearing, size = 30 }) {
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
export function CloudIcon({ category, size = 30, elev = 90, dt = new Date() }) {
|
||||
const r = size / 2;
|
||||
const u = r; // half-extent for layout
|
||||
const isNight = elev < -3;
|
||||
// Phased moon helper — returns an SVG <g> with the moon + shadow.
|
||||
// Lit fraction direction matches SkyScope (waxing = lit from right).
|
||||
const renderMoon = (cx, cy, moonR) => {
|
||||
const phase = moonPhaseFraction(dt);
|
||||
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR;
|
||||
const moonLitFromRight = phase < 0.5;
|
||||
const shadowCx = cx + (moonLitFromRight ? -phaseOffset : phaseOffset);
|
||||
const clipId = `cmoon-${size}-${Math.round(cx)}-${Math.round(cy)}-${Math.round(phase * 1000)}`;
|
||||
return html`
|
||||
<g>
|
||||
<defs>
|
||||
<clipPath id=${clipId}>
|
||||
<circle cx=${cx} cy=${cy} r=${moonR} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<circle cx=${cx} cy=${cy} r=${moonR} fill="#f5edd6" stroke="#9a8c6a" stroke-width="0.4" />
|
||||
<circle cx=${shadowCx} cy=${cy} r=${moonR} fill="#2a2438" clip-path=${`url(#${clipId})`} />
|
||||
<circle cx=${cx} cy=${cy} r=${moonR} fill="none" stroke="#9a8c6a" stroke-width="0.4" />
|
||||
</g>`;
|
||||
const brass = '#c8922a';
|
||||
const ink = '#2a1a08';
|
||||
const muted = '#b09870';
|
||||
const sw = Math.max(1.25, size * 0.058);
|
||||
const cap = 'round';
|
||||
const join = 'round';
|
||||
const cloudPath = (dx = 0, dy = 0, scale = 1) => {
|
||||
const x = (n) => r + n * r * scale + dx;
|
||||
const y = (n) => r + n * r * scale + dy;
|
||||
return `M ${x(-0.76)} ${y(0.25)}
|
||||
H ${x(0.56)}
|
||||
C ${x(0.82)} ${y(0.25)}, ${x(0.86)} ${y(-0.08)}, ${x(0.58)} ${y(-0.10)}
|
||||
C ${x(0.48)} ${y(-0.44)}, ${x(0.08)} ${y(-0.52)}, ${x(-0.10)} ${y(-0.22)}
|
||||
C ${x(-0.42)} ${y(-0.33)}, ${x(-0.76)} ${y(-0.08)}, ${x(-0.76)} ${y(0.25)} Z`;
|
||||
};
|
||||
const line = (x1, y1, x2, y2, color = muted, width = sw, opacity = 1) => html`
|
||||
<line x1=${x1} y1=${y1} x2=${x2} y2=${y2}
|
||||
stroke=${color} stroke-width=${width} stroke-linecap=${cap} opacity=${opacity} />`;
|
||||
const mist = (y0, color = muted) => html`
|
||||
<g>
|
||||
${line(size * 0.18, y0, size * 0.80, y0, color, sw * 0.9, 0.88)}
|
||||
${line(size * 0.30, y0 + size * 0.18, size * 0.92, y0 + size * 0.18, ink, sw * 0.82, 0.72)}
|
||||
</g>`;
|
||||
|
||||
return html`
|
||||
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
|
||||
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
|
||||
${category === 'clear' && (isNight
|
||||
? html`<!-- clear night: phased moon, no rays -->
|
||||
${renderMoon(r, r, u*0.50)}`
|
||||
: html`<!-- clear day: bright sun with rays -->
|
||||
<g>
|
||||
${[0,45,90,135,180,225,270,315].map(a => {
|
||||
const x1 = r + Math.sin(a*Math.PI/180) * u*0.65;
|
||||
const y1 = r - Math.cos(a*Math.PI/180) * u*0.65;
|
||||
const x2 = r + Math.sin(a*Math.PI/180) * u*0.95;
|
||||
const y2 = r - Math.cos(a*Math.PI/180) * u*0.95;
|
||||
return html`<line key=${a} x1=${x1} y1=${y1} x2=${x2} y2=${y2}
|
||||
stroke="#e6a32a" stroke-width=${Math.max(1, size*0.05)}
|
||||
stroke-linecap="round" />`;
|
||||
})}
|
||||
<circle cx=${r} cy=${r} r=${u*0.45} fill="#ffd84a" stroke="#e6a32a" stroke-width="0.6" />
|
||||
</g>`)}
|
||||
${category === 'clear' && html`
|
||||
${line(size * 0.26, size * 0.50, size * 0.74, size * 0.50, brass, sw, 0.9)}
|
||||
${line(size * 0.38, size * 0.64, size * 0.62, size * 0.64, muted, sw * 0.75, 0.7)}`}
|
||||
${category === 'wispy' && html`
|
||||
<!-- sun OR moon with thin cirrus streaks across it -->
|
||||
${isNight
|
||||
? renderMoon(r, r*0.95, u*0.42)
|
||||
: html`<circle cx=${r} cy=${r*0.95} r=${u*0.42} fill="#ffd84a" stroke="#e6a32a" stroke-width="0.6" />`}
|
||||
<path d=${`M ${r-u*0.95} ${r*0.85} Q ${r} ${r*0.65} ${r+u*0.95} ${r*0.95}`}
|
||||
stroke=${isNight ? '#cfd6dd' : '#ffffff'} stroke-width=${Math.max(1.4, size*0.07)}
|
||||
fill="none" stroke-linecap="round" opacity="0.92" />
|
||||
<path d=${`M ${r-u*0.75} ${r*1.25} Q ${r} ${r*1.08} ${r+u*0.85} ${r*1.30}`}
|
||||
stroke=${isNight ? '#b8c0c8' : '#e6ebf0'} stroke-width=${Math.max(1.1, size*0.055)}
|
||||
fill="none" stroke-linecap="round" opacity="0.85" />`}
|
||||
${mist(size * 0.38, brass)}
|
||||
${line(size * 0.18, size * 0.78, size * 0.60, size * 0.78, muted, sw * 0.7, 0.6)}`}
|
||||
${category === 'scattered' && html`
|
||||
<!-- sun OR moon behind a fluffy cumulus cloud -->
|
||||
${isNight
|
||||
? renderMoon(r+u*0.45, r-u*0.40, u*0.32)
|
||||
: html`<circle cx=${r+u*0.45} cy=${r-u*0.40} r=${u*0.34} fill="#ffd84a" stroke="#e6a32a" stroke-width="0.6" />`}
|
||||
<g fill=${isNight ? '#b8c0c8' : '#ffffff'} stroke=${isNight ? '#5a6470' : '#8a96a4'} stroke-width="0.6" stroke-linejoin="round">
|
||||
<ellipse cx=${r-u*0.35} cy=${r+u*0.10} rx=${u*0.50} ry=${u*0.30} />
|
||||
<ellipse cx=${r+u*0.10} cy=${r-u*0.05} rx=${u*0.46} ry=${u*0.36} />
|
||||
<ellipse cx=${r+u*0.40} cy=${r+u*0.20} rx=${u*0.44} ry=${u*0.28} />
|
||||
<!-- flat-ish base, soft underneath -->
|
||||
<rect x=${r-u*0.75} y=${r+u*0.18} width=${u*1.55} height=${u*0.20} rx=${u*0.10} />
|
||||
</g>`}
|
||||
<path d=${cloudPath(0, 0, 0.82)} fill="none" stroke=${ink}
|
||||
stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />
|
||||
${line(size * 0.20, size * 0.78, size * 0.48, size * 0.78, brass, sw * 0.75, 0.78)}`}
|
||||
${category === 'overcast' && html`
|
||||
<!-- thick layered stratus, no sun OR moon (would be hidden anyway) -->
|
||||
<g fill=${isNight ? '#7a828c' : '#d8dee5'} stroke=${isNight ? '#3a424c' : '#6e7a88'} stroke-width="0.7" stroke-linejoin="round">
|
||||
<ellipse cx=${r-u*0.40} cy=${r-u*0.20} rx=${u*0.55} ry=${u*0.32} />
|
||||
<ellipse cx=${r+u*0.20} cy=${r-u*0.40} rx=${u*0.48} ry=${u*0.30} />
|
||||
<ellipse cx=${r+u*0.40} cy=${r+u*0.10} rx=${u*0.55} ry=${u*0.34} />
|
||||
<ellipse cx=${r-u*0.10} cy=${r+u*0.30} rx=${u*0.60} ry=${u*0.32} />
|
||||
</g>`}
|
||||
<path d=${cloudPath(-size * 0.02, -size * 0.10, 0.70)} fill="none" stroke=${muted}
|
||||
stroke-width=${sw * 0.9} stroke-linecap=${cap} stroke-linejoin=${join} opacity="0.82" />
|
||||
<path d=${cloudPath(0, size * 0.14, 0.88)} fill="none" stroke=${ink}
|
||||
stroke-width=${sw} stroke-linecap=${cap} stroke-linejoin=${join} />`}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
@@ -626,18 +592,18 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
|
||||
</text>
|
||||
<text x=${cx} y=${cy + 15} text-anchor="middle"
|
||||
fill=${readoutMutedColor} font-size="6.5"
|
||||
font-family="JetBrains Mono, monospace" letter-spacing="2">
|
||||
font-family="Manrope, sans-serif" font-weight="700" letter-spacing="1.2">
|
||||
UTCI NOW
|
||||
</text>
|
||||
<text x=${cx} y=${cy + 23} text-anchor="middle"
|
||||
fill=${glowColor} font-size="7"
|
||||
font-family="JetBrains Mono, monospace" letter-spacing="0.8">
|
||||
font-family="Manrope, sans-serif" font-weight="700" letter-spacing="0.5">
|
||||
${cat.label.toUpperCase()}
|
||||
</text>
|
||||
</>`
|
||||
: html`<text x=${cx} y=${cy + 5} text-anchor="middle"
|
||||
fill=${readoutMutedColor} font-size="8"
|
||||
font-family="JetBrains Mono, monospace" letter-spacing="1.2">
|
||||
font-family="Manrope, sans-serif" font-weight="700" letter-spacing="0.8">
|
||||
AWAITING
|
||||
</text>`}
|
||||
</svg>`;
|
||||
@@ -664,25 +630,28 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28 }) {
|
||||
const hasRain = precip > 0;
|
||||
const hasSnow = snow > 0;
|
||||
const mixed = hasRain && hasSnow;
|
||||
|
||||
// Drop shape: teardrop path centred on (cx, cy), radius dr
|
||||
const drop = (cx, cy, dr) => {
|
||||
const tip = cy + dr * 1.55;
|
||||
const cw = dr * 0.72;
|
||||
return `M ${cx} ${tip} C ${cx - cw} ${cy + dr * 0.6}, ${cx - dr} ${cy - dr * 0.3}, ${cx} ${cy - dr} C ${cx + dr} ${cy - dr * 0.3}, ${cx + cw} ${cy + dr * 0.6}, ${cx} ${tip} Z`;
|
||||
};
|
||||
|
||||
// Six-pointed snowflake: centre lines + diagonal lines
|
||||
const brass = '#c8922a';
|
||||
const ink = '#2a1a08';
|
||||
const rain = '#2a6a90';
|
||||
const snowBlue = '#3f73c4';
|
||||
const sw = Math.max(1.25, size * 0.055);
|
||||
const cloud = html`
|
||||
<path d=${`M ${size*0.18} ${size*0.36}
|
||||
H ${size*0.72}
|
||||
C ${size*0.86} ${size*0.36}, ${size*0.88} ${size*0.20}, ${size*0.72} ${size*0.19}
|
||||
C ${size*0.66} ${size*0.04}, ${size*0.42} ${size*0.02}, ${size*0.34} ${size*0.17}
|
||||
C ${size*0.22} ${size*0.14}, ${size*0.14} ${size*0.24}, ${size*0.18} ${size*0.36} Z`}
|
||||
fill="none" stroke=${ink} stroke-width=${sw} stroke-linecap="round" stroke-linejoin="round" />`;
|
||||
const rainStroke = (cx, cy, heavy = false) => html`
|
||||
<line x1=${cx + size * 0.04} y1=${cy} x2=${cx - size * 0.04} y2=${cy + size * 0.18}
|
||||
stroke=${rain} stroke-width=${heavy ? sw * 1.1 : sw}
|
||||
stroke-linecap="round" />`;
|
||||
const flake = (cx, cy, fr) => html`
|
||||
<g stroke="#6090c8" stroke-width=${Math.max(1, size * 0.055)} stroke-linecap="round">
|
||||
<g stroke=${snowBlue} stroke-width=${Math.max(1, size * 0.045)} stroke-linecap="round">
|
||||
<line x1=${cx - fr} y1=${cy} x2=${cx + fr} y2=${cy} />
|
||||
<line x1=${cx} y1=${cy - fr} x2=${cx} y2=${cy + fr} />
|
||||
<line x1=${cx - fr * 0.7} y1=${cy - fr * 0.7} x2=${cx + fr * 0.7} y2=${cy + fr * 0.7} />
|
||||
<line x1=${cx + fr * 0.7} y1=${cy - fr * 0.7} x2=${cx - fr * 0.7} y2=${cy + fr * 0.7} />
|
||||
${[-1, 1].map(sx => [-1, 1].map(sy => html`
|
||||
<line key=${`${sx}${sy}`}
|
||||
x1=${cx + sx * fr * 0.55} y1=${cy + sy * 0}
|
||||
x2=${cx + sx * fr * 0.75} y2=${cy + sy * fr * 0.3} />`))}
|
||||
<line x1=${cx - fr * 0.68} y1=${cy - fr * 0.68} x2=${cx + fr * 0.68} y2=${cy + fr * 0.68} />
|
||||
<line x1=${cx + fr * 0.68} y1=${cy - fr * 0.68} x2=${cx - fr * 0.68} y2=${cy + fr * 0.68} />
|
||||
</g>`;
|
||||
|
||||
// Dry — just a faint dash
|
||||
@@ -691,7 +660,7 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28 }) {
|
||||
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
|
||||
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle' }}>
|
||||
<line x1=${r - size * 0.2} y1=${r} x2=${r + size * 0.2} y2=${r}
|
||||
stroke="#c0a880" stroke-width="1.5" stroke-linecap="round" />
|
||||
stroke=${brass} stroke-width="1.5" stroke-linecap="round" opacity="0.75" />
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
@@ -699,9 +668,7 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28 }) {
|
||||
const rainDrops = !hasRain ? 0 : precip < 1 ? 1 : precip < 4 ? 2 : 3;
|
||||
// Snow intensity → number of flakes
|
||||
const snowFlakes = !hasSnow ? 0 : snow < 0.5 ? 1 : 2;
|
||||
const dropColor = '#5090b0';
|
||||
const dr = size * 0.13; // drop radius
|
||||
const fr = size * 0.18; // flake arm length
|
||||
const fr = size * 0.08; // flake arm length
|
||||
|
||||
// Layout: spread items evenly across the icon width
|
||||
const items = (mixed ? rainDrops + snowFlakes : hasRain ? rainDrops : snowFlakes);
|
||||
@@ -710,20 +677,23 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28 }) {
|
||||
return html`
|
||||
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
|
||||
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
|
||||
${cloud}
|
||||
${mixed
|
||||
? html`
|
||||
${Array.from({ length: rainDrops }, (_, i) => html`
|
||||
<path key=${'d' + i} d=${drop(spacing * (i + 1), r - dr * 0.3, dr)}
|
||||
fill=${dropColor} opacity="0.9" />`)}
|
||||
<${Fragment} key=${'d' + i}>
|
||||
${rainStroke(spacing * (i + 1), size * 0.52, false)}
|
||||
</>`)}
|
||||
${Array.from({ length: snowFlakes }, (_, i) =>
|
||||
flake(spacing * (rainDrops + i + 1), r, fr)
|
||||
flake(spacing * (rainDrops + i + 1), size * 0.68, fr)
|
||||
)}`
|
||||
: hasRain
|
||||
? Array.from({ length: rainDrops }, (_, i) => html`
|
||||
<path key=${i} d=${drop(spacing * (i + 1), r - dr * 0.3, dr)}
|
||||
fill=${dropColor} opacity="0.9" />`)
|
||||
<${Fragment} key=${i}>
|
||||
${rainStroke(spacing * (i + 1), size * 0.53, precip >= 4)}
|
||||
</>`)
|
||||
: Array.from({ length: snowFlakes }, (_, i) =>
|
||||
flake(spacing * (i + 1), r, fr)
|
||||
flake(spacing * (i + 1), size * 0.66, fr)
|
||||
)}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user