More realistic sunrise and sunset colours
This commit is contained in:
+99
-34
@@ -16,7 +16,7 @@
|
|||||||
import { h, Fragment } from '../vendor/preact.js';
|
import { h, Fragment } from '../vendor/preact.js';
|
||||||
import htm from '../vendor/htm.js';
|
import htm from '../vendor/htm.js';
|
||||||
import {
|
import {
|
||||||
skyFillForElev, grassFillForElev,
|
skyGradientForElev, skyFillForElev, grassFillForElev,
|
||||||
moonPhaseFraction,
|
moonPhaseFraction,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
|
||||||
@@ -42,20 +42,41 @@ const html = htm.bind(h);
|
|||||||
export function SkyScope({ elev, dt, size = 26 }) {
|
export function SkyScope({ elev, dt, size = 26 }) {
|
||||||
const r = size / 2;
|
const r = size / 2;
|
||||||
const innerR = r - 1.2;
|
const innerR = r - 1.2;
|
||||||
const skyFill = skyFillForElev(elev);
|
|
||||||
const isDay = elev > -3;
|
// Rising if local hour < 12 (dt.getUTCHours() == local hour after timezone fix)
|
||||||
const elevClamped = Math.max(-30, Math.min(90, elev));
|
const localHour = dt ? dt.getUTCHours() : 12;
|
||||||
|
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
|
||||||
|
const isNight = elev <= -8;
|
||||||
|
|
||||||
|
// Sun position — clamped so it never goes below the horizon line (y = r)
|
||||||
|
const elevClamped = Math.max(0, Math.min(90, elev));
|
||||||
const sunY = r - Math.sin((elevClamped * Math.PI) / 180) * (innerR - 2.5);
|
const sunY = r - Math.sin((elevClamped * Math.PI) / 180) * (innerR - 2.5);
|
||||||
const sunR = Math.max(2.2, innerR * 0.32);
|
const sunR = Math.max(2.2, innerR * 0.32);
|
||||||
|
|
||||||
|
// Near-horizon sun gets a warm glow halo
|
||||||
|
const sunGlowOpacity = elev < 10 ? Math.max(0, 1 - elev / 10) * 0.65 : 0.25;
|
||||||
|
const sunGlowR = sunR + (elev < 6 ? 3.5 : 1.8);
|
||||||
|
const sunGlowColor = isRising ? '#ff8c3a' : '#ff9a6a';
|
||||||
|
|
||||||
const phase = moonPhaseFraction(dt);
|
const phase = moonPhaseFraction(dt);
|
||||||
const moonR = innerR * 0.55;
|
const moonR = innerR * 0.55;
|
||||||
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR;
|
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonR;
|
||||||
const moonLitFromRight = phase < 0.5;
|
const moonLitFromRight = phase < 0.5;
|
||||||
const shadowCx = r + (moonLitFromRight ? -phaseOffset : phaseOffset);
|
const shadowCx = r + (moonLitFromRight ? -phaseOffset : phaseOffset);
|
||||||
const grass = grassFillForElev(elev);
|
const grass = grassFillForElev(elev);
|
||||||
const uid = `${size}-${Math.round(elev * 10)}-${Math.round(phase * 1000)}`;
|
|
||||||
const clipId = `scope-clip-${uid}`;
|
const uid = `ss-${size}-${Math.round(elev * 10)}-${Math.round(phase * 1000)}-${isRising ? 'r' : 's'}`;
|
||||||
const grassId = `scope-grass-${uid}`;
|
const clipId = `clip-${uid}`;
|
||||||
|
const skyId = `sky-${uid}`;
|
||||||
|
const grassId = `grass-${uid}`;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
|
<svg width=${size} height=${size} viewBox=${`0 0 ${size} ${size}`}
|
||||||
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle' }}>
|
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle' }}>
|
||||||
@@ -63,29 +84,54 @@ export function SkyScope({ elev, dt, size = 26 }) {
|
|||||||
<clipPath id=${clipId}>
|
<clipPath id=${clipId}>
|
||||||
<circle cx=${r} cy=${r} r=${innerR} />
|
<circle cx=${r} cy=${r} r=${innerR} />
|
||||||
</clipPath>
|
</clipPath>
|
||||||
|
<!-- Sky gradient — top of disk to bottom (zenith → horizon) -->
|
||||||
|
<linearGradient id=${skyId} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color=${skyGrad.top} />
|
||||||
|
<stop offset="100%" stop-color=${skyGrad.bot} />
|
||||||
|
</linearGradient>
|
||||||
|
<!-- Grass gradient — horizon to ground -->
|
||||||
<linearGradient id=${grassId} x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id=${grassId} x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stop-color=${grass.top} />
|
<stop offset="0%" stop-color=${grass.top} />
|
||||||
<stop offset="100%" stop-color=${grass.bot} />
|
<stop offset="100%" stop-color=${grass.bot} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<!-- Moon phase mask: lit area = white, shadow = black punched out -->
|
||||||
|
<mask id=${`moon-mask-${uid}`}>
|
||||||
|
<circle cx=${r} cy=${r} r=${moonR} fill="white" />
|
||||||
|
<circle cx=${shadowCx} cy=${r} r=${moonR} fill="black" />
|
||||||
|
</mask>
|
||||||
</defs>
|
</defs>
|
||||||
<circle cx=${r} cy=${r} r=${innerR} fill=${skyFill} />
|
|
||||||
|
<!-- Sky disk -->
|
||||||
|
<circle cx=${r} cy=${r} r=${innerR} fill=${`url(#${skyId})`} />
|
||||||
|
|
||||||
|
<!-- Ground half (lower semicircle) -->
|
||||||
<path d=${`M ${r - innerR} ${r} A ${innerR} ${innerR} 0 0 0 ${r + innerR} ${r} Z`}
|
<path d=${`M ${r - innerR} ${r} A ${innerR} ${innerR} 0 0 0 ${r + innerR} ${r} Z`}
|
||||||
fill=${`url(#${grassId})`}
|
fill=${`url(#${grassId})`}
|
||||||
clip-path=${`url(#${clipId})`} />
|
clip-path=${`url(#${clipId})`} />
|
||||||
${isDay
|
|
||||||
? html`
|
${isDay && html`
|
||||||
<g clip-path=${`url(#${clipId})`}>
|
<g clip-path=${`url(#${clipId})`}>
|
||||||
<circle cx=${r} cy=${sunY} r=${sunR + 1.4} fill="#ffe9a0" opacity="0.55" />
|
<!-- Warm glow halo — bigger and more vivid near the horizon -->
|
||||||
<circle cx=${r} cy=${sunY} r=${sunR} fill="#fff3a8" stroke="#e6a32a" stroke-width="0.4" />
|
<circle cx=${r} cy=${sunY} r=${sunGlowR} fill=${sunGlowColor} opacity=${sunGlowOpacity} />
|
||||||
</g>`
|
<!-- Sun disk -->
|
||||||
: html`
|
<circle cx=${r} cy=${sunY} r=${sunR} fill="#fff8c0" stroke="#e6a32a" stroke-width="0.5" />
|
||||||
<g clip-path=${`url(#${clipId})`}>
|
|
||||||
<circle cx=${r} cy=${r} r=${moonR} fill="#f5edd6" />
|
|
||||||
<circle cx=${shadowCx} cy=${r} r=${moonR} fill=${skyFill} />
|
|
||||||
<circle cx=${r} cy=${r} r=${moonR} fill="none" stroke="rgba(245,237,214,0.45)" stroke-width="0.4" />
|
|
||||||
</g>`}
|
</g>`}
|
||||||
|
|
||||||
|
${(isTwil || isNight) && html`
|
||||||
|
<g clip-path=${`url(#${clipId})`}>
|
||||||
|
<!-- Lit crescent via mask -->
|
||||||
|
<circle cx=${r} cy=${r} r=${moonR}
|
||||||
|
fill="#f5edd6" mask=${`url(#moon-mask-${uid})`} />
|
||||||
|
<!-- Dim unlit face so the full disk outline remains visible -->
|
||||||
|
<circle cx=${r} cy=${r} r=${moonR}
|
||||||
|
fill="rgba(180,170,210,0.18)" stroke="rgba(245,237,214,0.45)" stroke-width="0.4" />
|
||||||
|
</g>`}
|
||||||
|
|
||||||
|
<!-- Brass scope ring -->
|
||||||
<circle cx=${r} cy=${r} r=${innerR} fill="none" stroke="#c8922a" stroke-width="0.9" />
|
<circle cx=${r} cy=${r} r=${innerR} fill="none" stroke="#c8922a" stroke-width="0.9" />
|
||||||
<circle cx=${r} cy=${r} r=${innerR - 0.5} fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="0.4" />
|
<!-- Inner highlight glint -->
|
||||||
|
<circle cx=${r} cy=${r} r=${innerR - 0.5} fill="none"
|
||||||
|
stroke="rgba(255,255,255,0.22)" stroke-width="0.4" />
|
||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,16 +300,24 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() })
|
|||||||
// grass on the bottom, sun positioned by elevation (or moon at night
|
// grass on the bottom, sun positioned by elevation (or moon at night
|
||||||
// with its phase shadow). Mirrors the little hourly SkyScopes.
|
// with its phase shadow). Mirrors the little hourly SkyScopes.
|
||||||
const cx = 100, cy = 100, R = 86;
|
const cx = 100, cy = 100, R = 86;
|
||||||
const isDay = elev > -3;
|
const localHour = dt ? dt.getUTCHours() : 12;
|
||||||
const skyFill = skyFillForElev(elev);
|
const isRising = localHour < 12;
|
||||||
|
const isDay = elev > 0; // sun only above the actual horizon
|
||||||
|
const isTwil = elev > -8 && !isDay;
|
||||||
|
const skyGrad = skyGradientForElev(elev, isRising);
|
||||||
|
const skyHorizon = skyGrad.bot;
|
||||||
const grass = grassFillForElev(elev);
|
const grass = grassFillForElev(elev);
|
||||||
const phase = moonPhaseFraction(dt);
|
const phase = moonPhaseFraction(dt);
|
||||||
const lensR = 95; // full back of the dial
|
const lensR = 95;
|
||||||
const elevClamped = Math.max(-30, Math.min(90, elev));
|
const elevClamped = Math.max(0, Math.min(90, elev)); // clamp to above horizon
|
||||||
const sunY = cy - Math.sin((elevClamped * Math.PI) / 180) * (lensR - 18);
|
const sunY = cy - Math.sin((elevClamped * Math.PI) / 180) * (lensR - 18);
|
||||||
const sunDrawR = 14;
|
const sunDrawR = 14;
|
||||||
|
// Near-horizon glow for the big dial
|
||||||
|
const sunGlowR = sunDrawR + (elev < 10 ? 7 : 3);
|
||||||
|
const sunGlowOp = elev < 10 ? Math.max(0, 1 - elev / 10) * 0.55 : 0.2;
|
||||||
|
const sunGlowCol = isRising ? '#ff8c3a' : '#ff9a6a';
|
||||||
const moonDrawR = 15;
|
const moonDrawR = 15;
|
||||||
const moonY = cy - 45; // float in the upper sky, away from the readout
|
const moonY = cy - 45;
|
||||||
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonDrawR;
|
const phaseOffset = Math.cos(2 * Math.PI * phase) * moonDrawR;
|
||||||
const moonLitFromRight = phase < 0.5;
|
const moonLitFromRight = phase < 0.5;
|
||||||
const moonShadowCx = cx + (moonLitFromRight ? -phaseOffset : phaseOffset);
|
const moonShadowCx = cx + (moonLitFromRight ? -phaseOffset : phaseOffset);
|
||||||
@@ -327,27 +381,38 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date() })
|
|||||||
<clipPath id="scope-lens-clip">
|
<clipPath id="scope-lens-clip">
|
||||||
<circle cx=${cx} cy=${cy} r=${lensR} />
|
<circle cx=${cx} cy=${cy} r=${lensR} />
|
||||||
</clipPath>
|
</clipPath>
|
||||||
|
<linearGradient id="scope-lens-sky" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color=${skyGrad.top} />
|
||||||
|
<stop offset="100%" stop-color=${skyGrad.bot} />
|
||||||
|
</linearGradient>
|
||||||
<linearGradient id="scope-lens-grass" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="scope-lens-grass" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stop-color=${grass.top} />
|
<stop offset="0%" stop-color=${grass.top} />
|
||||||
<stop offset="100%" stop-color=${grass.bot} />
|
<stop offset="100%" stop-color=${grass.bot} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<!-- Moon phase mask: white = lit area, black = shadow punched out -->
|
||||||
|
<mask id="scope-moon-mask">
|
||||||
|
<circle cx=${cx} cy=${moonY} r=${moonDrawR} fill="white" />
|
||||||
|
<circle cx=${moonShadowCx} cy=${moonY} r=${moonDrawR} fill="black" />
|
||||||
|
</mask>
|
||||||
</defs>
|
</defs>
|
||||||
<circle cx=${cx} cy=${cy} r="96" fill="url(#scope-bg-glow)" />
|
<circle cx=${cx} cy=${cy} r="96" fill="url(#scope-bg-glow)" />
|
||||||
|
|
||||||
<!-- Full sky + grass + sun/moon scene behind the readout -->
|
<!-- Full sky + grass + sun/moon scene behind the readout -->
|
||||||
<g clip-path="url(#scope-lens-clip)">
|
<g clip-path="url(#scope-lens-clip)">
|
||||||
<circle cx=${cx} cy=${cy} r=${lensR} fill=${skyFill} />
|
<circle cx=${cx} cy=${cy} r=${lensR} fill="url(#scope-lens-sky)" />
|
||||||
<path d=${`M ${cx - lensR} ${cy} A ${lensR} ${lensR} 0 0 0 ${cx + lensR} ${cy} Z`}
|
<path d=${`M ${cx - lensR} ${cy} A ${lensR} ${lensR} 0 0 0 ${cx + lensR} ${cy} Z`}
|
||||||
fill="url(#scope-lens-grass)" />
|
fill="url(#scope-lens-grass)" />
|
||||||
${isDay
|
${isDay && html`<${Fragment}>
|
||||||
? html`<${Fragment}>
|
<circle cx=${cx} cy=${sunY} r=${sunGlowR} fill=${sunGlowCol} opacity=${sunGlowOp} />
|
||||||
<circle cx=${cx} cy=${sunY} r=${sunDrawR + 4} fill="#ffe9a0" opacity="0.55" />
|
<circle cx=${cx} cy=${sunY} r=${sunDrawR} fill="#fff8c0" stroke="#e6a32a" stroke-width="0.8" />
|
||||||
<circle cx=${cx} cy=${sunY} r=${sunDrawR} fill="#fff3a8" stroke="#e6a32a" stroke-width="0.8" />
|
</>`}
|
||||||
</>`
|
${(isTwil || (!isDay && !isTwil)) && html`<${Fragment}>
|
||||||
: html`<${Fragment}>
|
<!-- Lit crescent — full moon disk clipped by the phase mask -->
|
||||||
<circle cx=${cx} cy=${moonY} r=${moonDrawR} fill="#f5edd6" />
|
<circle cx=${cx} cy=${moonY} r=${moonDrawR}
|
||||||
<circle cx=${moonShadowCx} cy=${moonY} r=${moonDrawR} fill=${skyFill} />
|
fill="#f5edd6" mask="url(#scope-moon-mask)" />
|
||||||
<circle cx=${cx} cy=${moonY} r=${moonDrawR} fill="none" stroke="rgba(245,237,214,0.55)" stroke-width="0.7" />
|
<!-- Dim unlit face so the full disk outline is still visible -->
|
||||||
|
<circle cx=${cx} cy=${moonY} r=${moonDrawR}
|
||||||
|
fill="rgba(180,170,210,0.18)" stroke="rgba(245,237,214,0.45)" stroke-width="0.7" />
|
||||||
</>`}
|
</>`}
|
||||||
</g>
|
</g>
|
||||||
${stressBands.map((b, i) => html`
|
${stressBands.map((b, i) => html`
|
||||||
|
|||||||
+44
-19
@@ -13,7 +13,7 @@
|
|||||||
// confidenceBand(i) day-tab gradient + label
|
// confidenceBand(i) day-tab gradient + label
|
||||||
// moonPhaseFraction(date) 0..1 synodic phase
|
// moonPhaseFraction(date) 0..1 synodic phase
|
||||||
// moonGlyph(p) phase fraction → emoji
|
// moonGlyph(p) phase fraction → emoji
|
||||||
// skyFillForElev(e) solar elevation → sky hex colour
|
// skyGradientForElev(e, isRising) solar elevation + direction → {top,bot} sky gradient
|
||||||
// grassFillForElev(e) solar elevation → {top,bot} hex
|
// grassFillForElev(e) solar elevation → {top,bot} hex
|
||||||
// ════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@@ -212,26 +212,51 @@ export function moonGlyph(p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
// SKY FILL — colour inside the little circle, based on sun elevation.
|
// SKY GRADIENT — top/bottom colour pair for the SkyScope disk gradient.
|
||||||
// ───────────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────────
|
||||||
// > 30° high noon (bright blue)
|
// isRising: true when sun is climbing (local hour < 12).
|
||||||
// > 10° mid-sky (pale blue)
|
//
|
||||||
// > 3° morning/afternoon (warm gold)
|
// Sunrise palette → reds, oranges, yellows at the horizon.
|
||||||
// > -3° golden hour / sunset (deep amber)
|
// Sunset palette → pinks, purples, mauves at the horizon.
|
||||||
// > -8° civil twilight (dusky lilac)
|
// Both share the same deep blue zenith at high elevations.
|
||||||
// > -14° nautical twilight (deep blue-violet)
|
|
||||||
// else night (indigo)
|
|
||||||
// To shift "when sunset starts" visually, tweak the elevation
|
|
||||||
// thresholds. To recolour: replace the hex codes.
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
export function skyFillForElev(e) {
|
export function skyGradientForElev(e, isRising) {
|
||||||
if (e > 30) return '#9fd3ef';
|
// Full daytime — blue sky, paler toward horizon
|
||||||
if (e > 10) return '#c8e3ee';
|
if (e > 30) return { top: '#5bb8e8', bot: '#9fd3ef' };
|
||||||
if (e > 3) return '#ffd596';
|
if (e > 10) return { top: '#7cc5ec', bot: '#c8e3ee' };
|
||||||
if (e > -3) return '#f59b6e';
|
|
||||||
if (e > -8) return '#9279b0';
|
// Low sun — golden hour / near-horizon
|
||||||
if (e > -14) return '#3a2f60';
|
if (e > 3) {
|
||||||
return '#1a1538';
|
return isRising
|
||||||
|
? { top: '#6aaddb', bot: '#ffb347' } // sunrise: blue → warm amber/orange
|
||||||
|
: { top: '#7b8fc4', bot: '#ffb877' }; // sunset: blue-violet → golden
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sun at/just below horizon — the dramatic band
|
||||||
|
if (e > -3) {
|
||||||
|
return isRising
|
||||||
|
? { top: '#4a7aaa', bot: '#e8622a' } // sunrise: deep blue → vivid red-orange
|
||||||
|
: { top: '#7a5090', bot: '#e8826a' }; // sunset: violet → coral/pink
|
||||||
|
}
|
||||||
|
|
||||||
|
// Civil twilight
|
||||||
|
if (e > -8) {
|
||||||
|
return isRising
|
||||||
|
? { top: '#2a3a6a', bot: '#a04828' } // pre-dawn: indigo → burnt sienna
|
||||||
|
: { top: '#5a3572', bot: '#c07080' }; // dusk: purple → dusty rose/mauve
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nautical twilight
|
||||||
|
if (e > -14) return { top: '#1e1a50', bot: '#3a2f60' };
|
||||||
|
|
||||||
|
// Night
|
||||||
|
return { top: '#0e0c28', bot: '#1a1538' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience flat colour — returns the bot (horizon) colour for contexts
|
||||||
|
// that still need a single fill (e.g. moon shadow fill).
|
||||||
|
export function skyFillForElev(e, isRising = false) {
|
||||||
|
return skyGradientForElev(e, isRising).bot;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grass palette — mirrors skyFillForElev but for the ground.
|
// Grass palette — mirrors skyFillForElev but for the ground.
|
||||||
|
|||||||
Reference in New Issue
Block a user