1.43 - Extra Pulldown & New Cloud Icons

This commit is contained in:
fraxle
2026-05-16 23:34:57 +01:00
parent 40f651eb98
commit 048e52a177
8 changed files with 305 additions and 221 deletions
+3 -2
View File
@@ -23,9 +23,10 @@ header {
}
header a {
font-family: 'JetBrains Mono', monospace;
font-family: 'Manrope', sans-serif;
font-size: 16px;
letter-spacing: 0.10em;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #b09870;
text-decoration: none;
+5 -4
View File
@@ -27,8 +27,8 @@
FONTS
──────────────────────────────────────────────────────────────────────
Fraunces serif display titles, day labels, hour cell
Manrope body sans-serif paragraphs
JetBrains Mono numeric monospace table data, small labels
Manrope body sans-serif paragraphs, small labels
JetBrains Mono numeric monospace table data
════════════════════════════════════════════════════════════════════════ */
@@ -92,9 +92,10 @@ body {
}
.utci-topnav a {
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 16px;
letter-spacing: 0.1em;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #b09870;
text-decoration: none;
+9 -6
View File
@@ -65,10 +65,11 @@
/* The credits line under the tagline */
.utci-subtitle {
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.18em;
letter-spacing: 0.08em;
margin-top: 10px;
color: #b09870;
}
@@ -121,10 +122,11 @@
}
.utci-search-label {
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.18em;
letter-spacing: 0.08em;
color: #b09870;
display: block;
margin-bottom: 7px;
@@ -193,8 +195,9 @@
margin-top: 7px;
font-size: 10px;
color: #b09870;
font-family: JetBrains Mono, monospace;
letter-spacing: 0.14em;
font-family: Manrope, sans-serif;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
+25 -18
View File
@@ -88,10 +88,11 @@
flex-shrink: 0;
padding: 12px 24px;
cursor: pointer;
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.14em;
letter-spacing: 0.08em;
background: transparent;
border: none;
color: #b09870;
@@ -137,10 +138,11 @@
}
.filter-profiles-label {
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.18em;
letter-spacing: 0.08em;
color: #7a5c2a;
margin-right: 4px;
white-space: nowrap;
@@ -162,10 +164,11 @@
border: 1.5px solid #c9b08a;
background: #f5edd6;
color: #6b4228;
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
letter-spacing: 0.06em;
transition: all 0.15s;
-webkit-user-select: none;
user-select: none;
@@ -198,10 +201,11 @@
}
.col-toggles-label {
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.18em;
letter-spacing: 0.08em;
color: #7a5c2a;
margin-right: 4px;
white-space: nowrap;
@@ -214,10 +218,11 @@
border: 1.5px solid #c9b08a;
background: #f5edd6;
color: #6b4228;
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
letter-spacing: 0.06em;
transition: all 0.15s;
-webkit-user-select: none;
user-select: none;
@@ -297,8 +302,9 @@
gap: 5px;
padding: 5px 12px 5px 10px;
font-size: 12px;
font-family: JetBrains Mono, monospace;
letter-spacing: 0.08em;
font-family: Manrope, sans-serif;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
cursor: pointer;
border: 1.5px solid #c9b08a;
@@ -465,11 +471,11 @@
.utci-table th {
background: #f0e4c4;
color: #6b4f2a;
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 600;
letter-spacing: 0.07em;
font-weight: 700;
padding: 14px 12px;
text-align: right;
white-space: nowrap;
@@ -478,7 +484,7 @@
@media (max-width: 800px) {
.utci-table th {
font-size: 10px;
letter-spacing: 0.14em;
letter-spacing: 0.08em;
}
}
@@ -703,8 +709,9 @@
}
.utci-band-label {
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.06em;
}
+31 -21
View File
@@ -128,10 +128,11 @@
letter-spacing: 0;
}
.almanac-subtitle {
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
letter-spacing: 0.07em;
color: #9a7d5a;
}
.almanac-list {
@@ -177,16 +178,18 @@
line-height: 1.2;
}
.almanac-entry-date {
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
letter-spacing: 0.06em;
color: #c8922a;
white-space: nowrap;
}
.almanac-entry-countdown {
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
color: #9a7d5a;
white-space: nowrap;
}
@@ -200,10 +203,11 @@
.almanac-entry-visibility {
display: inline-block;
margin-top: 5px;
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
letter-spacing: 0.06em;
color: #7a9a6a;
background: #eef4e8;
border: 1px solid #c0d8b0;
@@ -250,10 +254,11 @@
border: 1.5px solid #c9b08a;
background: #f5edd6;
color: #6b4228;
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
letter-spacing: 0.06em;
transition: all 0.15s;
user-select: none;
white-space: nowrap;
@@ -328,10 +333,11 @@
.cs-option {
display: block;
padding: 8px 14px;
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.05em;
color: #4a3420;
cursor: pointer;
transition: background 0.1s, color 0.1s;
@@ -369,8 +375,9 @@
gap: 6px;
padding: 5px 12px 5px 10px;
font-size: 12px;
font-family: JetBrains Mono, monospace;
letter-spacing: 0.08em;
font-family: Manrope, sans-serif;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
cursor: pointer;
border: 1.5px solid #c9b08a;
@@ -430,10 +437,11 @@
padding: 14px 18px;
background: #f0e4c4;
color: #7a5c2a;
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 11px;
font-weight: 700;
text-align: center;
letter-spacing: 0.12em;
letter-spacing: 0.07em;
text-transform: uppercase;
border: 1.5px solid #c9b08a;
margin-bottom: 18px;
@@ -450,10 +458,10 @@
}
.utci-legend-label {
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.18em;
letter-spacing: 0.08em;
color: #7a5c2a;
margin-bottom: 12px;
display: block;
@@ -468,10 +476,11 @@
.utci-legend-item {
padding: 6px 12px;
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
letter-spacing: 0.04em;
flex: 1;
min-width: 110px;
text-align: center;
@@ -536,10 +545,11 @@
margin-top: 36px;
padding-top: 20px;
border-top: 1px solid #d4c0a0;
font-family: JetBrains Mono, monospace;
font-family: Manrope, sans-serif;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
letter-spacing: 0.07em;
color: #9a7d5a;
line-height: 2.2;
}
+150 -60
View File
@@ -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 });
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;
}
// 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');
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
View File
@@ -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>`;
}
+6 -4
View File
@@ -133,10 +133,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
color: #fff;
border: none;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Manrope', sans-serif;
font-weight: 700;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.12em;
letter-spacing: 0.07em;
cursor: pointer;
}
button:hover { background: #a06e18; }
@@ -153,10 +154,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
.back {
margin-top: 1.5rem;
font-family: 'JetBrains Mono', monospace;
font-family: 'Manrope', sans-serif;
font-weight: 700;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
letter-spacing: 0.06em;
}
.back a { color: #b09870; text-decoration: none; }
.back a:hover { color: #c8922a; }