sister-day.html
· 20 KiB · HTML
Raw
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sister Day Calculator</title>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,500;0,700;1,300;1,500&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--spring: #2d8a56;
--fall: #b85c2a;
--cold: #3a7ab5;
--sol: #7c5cbf;
--hot: #c9960a;
--bg: #faf9f7;
--surface: #ffffff;
--surface2: #f2f0ed;
--text: #1a1a1a;
--muted: #6b6b6b;
--border: rgba(0,0,0,0.08);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg); color: var(--text);
font-family: 'DM Sans', sans-serif; font-weight: 400;
min-height: 100vh; overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
.container {
max-width: 760px; margin: 0 auto; padding: 3.5rem 1.5rem;
}
h1 {
font-family: 'Cormorant Garamond', serif;
font-weight: 500; font-size: clamp(3rem, 8vw, 4.8rem);
line-height: 1.05; letter-spacing: -0.02em; margin-bottom: 0.25em;
background: linear-gradient(135deg, var(--spring), var(--fall));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.subtitle {
font-family: 'Cormorant Garamond', serif;
font-weight: 300; font-style: italic;
font-size: 1.35rem; color: var(--muted);
margin-bottom: 2.5rem; line-height: 1.5;
}
.definition {
border-left: 3px solid var(--cold);
padding: 1.1rem 1.4rem; margin-bottom: 2.5rem;
background: rgba(58,122,181,0.04); border-radius: 0 10px 10px 0;
font-size: 1.05rem; line-height: 1.75; color: var(--muted);
}
.definition strong { color: var(--text); font-weight: 500; }
.controls {
display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; margin-bottom: 2rem;
}
label { display: flex; flex-direction: column; gap: 0.45rem; }
label span {
font-size: 0.85rem; font-weight: 500; text-transform: uppercase;
letter-spacing: 0.07em; color: var(--muted);
}
input, select {
background: var(--surface); border: 1.5px solid var(--border);
color: var(--text); border-radius: 10px; padding: 0.75rem 1rem;
font-family: 'DM Sans', sans-serif; font-size: 1.05rem; font-weight: 400;
transition: border-color 0.2s; appearance: none; -webkit-appearance: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236b6b6b' fill='none' stroke-width='1.5'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 1rem center;
padding-right: 2.5rem;
}
input:focus, select:focus { outline: none; border-color: rgba(0,0,0,0.2); }
.coldest-note {
font-size: 0.85rem; color: var(--cold); margin-top: 0.15rem; min-height: 1.2em; font-weight: 500;
}
.result-card {
background: var(--surface); border-radius: 18px; padding: 2.25rem;
border: 1.5px solid var(--border); margin-bottom: 1.5rem;
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
animation: fadeUp 0.5s ease both;
}
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
.pair {
display: flex; align-items: center; justify-content: center; gap: 1.5rem;
margin-bottom: 1.5rem; flex-wrap: wrap;
}
.day-badge {
display: flex; flex-direction: column; align-items: center;
padding: 1.1rem 1.75rem; border-radius: 14px; min-width: 150px;
}
.day-badge.spring { background: rgba(45,138,86,0.08); border: 1.5px solid rgba(45,138,86,0.2); }
.day-badge.fall { background: rgba(184,92,42,0.08); border: 1.5px solid rgba(184,92,42,0.2); }
.day-badge .season-label {
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.1em; margin-bottom: 0.3rem;
}
.day-badge.spring .season-label { color: var(--spring); }
.day-badge.fall .season-label { color: var(--fall); }
.day-badge .date {
font-family: 'Cormorant Garamond', serif; font-size: 1.85rem; font-weight: 600;
}
.mirror-symbol { font-size: 1.5rem; color: var(--muted); opacity: 0.4; }
.meta { text-align: center; font-size: 1rem; color: var(--muted); line-height: 1.6; }
.meta em { font-style: normal; color: var(--cold); font-weight: 500; }
.orbital { position: relative; width: 100%; max-width: 420px; margin: 1.75rem auto 0; aspect-ratio: 1; }
.orbital svg { width: 100%; height: 100%; }
.legend {
display: flex; flex-wrap: wrap; justify-content: center; gap: 0.75rem 1.5rem;
margin-top: 1.25rem; font-size: 0.82rem; color: var(--muted); font-weight: 400;
}
.legend-item { display: flex; align-items: center; gap: 0.4rem; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.lag-card {
background: var(--surface); border-radius: 18px; padding: 1.75rem 2.25rem;
border: 1.5px solid var(--border); margin-bottom: 2rem;
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
animation: fadeUp 0.5s ease both; animation-delay: 0.1s;
}
.lag-card h3 {
font-family: 'Cormorant Garamond', serif;
font-weight: 600; font-size: 1.35rem; margin-bottom: 0.65rem;
color: var(--sol);
}
.lag-card p {
font-size: 1rem; line-height: 1.7; color: var(--muted);
}
.lag-card p strong { color: var(--text); font-weight: 500; }
.lag-card .lag-stats {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.85rem; margin-top: 1.15rem;
}
.lag-stat {
background: var(--surface2); border-radius: 12px; padding: 0.85rem 1.1rem; text-align: center;
}
.lag-stat .val {
font-family: 'Cormorant Garamond', serif; font-size: 1.7rem; font-weight: 600;
}
.lag-stat .lbl {
font-size: 0.78rem; font-weight: 500; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--muted); margin-top: 0.2rem;
}
.lag-stat.sol .val { color: var(--sol); }
.lag-stat.cold .val { color: var(--cold); }
footer {
text-align: center; font-size: 0.85rem; color: var(--muted); padding: 2rem 0 1rem;
border-top: 1.5px solid var(--border); margin-top: 1.5rem;
}
@media (max-width: 480px) {
.controls { grid-template-columns: 1fr; }
.pair { gap: 0.75rem; }
.day-badge { min-width: 125px; padding: 0.85rem 1.25rem; }
.lag-card .lag-stats { grid-template-columns: 1fr; }
.container { padding: 2rem 1.25rem; }
}
</style>
</head>
<body>
<div class="container">
<h1>Sister Day</h1>
<p class="subtitle">The mirror date across winter's coldest point</p>
<div class="definition">
<strong>Sister Day</strong> — For a given location, let <em>C</em> denote the calendar date with the lowest long-term average daily temperature. The <em>sister day</em> of a date <em>D</em> is the unique date <em>D′</em> equidistant from <em>C</em> on the opposite side of the annual temperature cycle — if <em>D</em> falls <em>n</em> days after <em>C</em>, then <em>D′</em> falls <em>n</em> days before <em>C</em>, and vice versa.
</div>
<div class="controls">
<label>
<span>Your date</span>
<input type="date" id="dateInput">
</label>
<label>
<span>Location</span>
<select id="locationSelect"></select>
<div class="coldest-note" id="coldestNote"></div>
</label>
</div>
<div class="result-card" id="resultCard" style="display:none;">
<div class="pair">
<div class="day-badge spring" id="badgeA">
<div class="season-label" id="labelA">warming</div>
<div class="date" id="dateA"></div>
</div>
<div class="mirror-symbol">⟷</div>
<div class="day-badge fall" id="badgeB">
<div class="season-label" id="labelB">cooling</div>
<div class="date" id="dateB"></div>
</div>
</div>
<div class="meta" id="metaLine"></div>
<div class="orbital">
<svg viewBox="0 0 440 440" id="orbitalSvg"></svg>
</div>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:var(--spring)"></div>Warming side</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--fall)"></div>Cooling side</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--cold)"></div>Coldest day</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--hot)"></div>Warmest day</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--sol)"></div>Winter solstice</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--hot);opacity:0.45"></div>Summer solstice</div>
</div>
</div>
<div class="lag-card" id="lagCard" style="display:none;">
<h3>Why not the solstice?</h3>
<p>
The winter solstice marks the day the sun sits lowest in the sky — the most oblique angle of incidence and the least solar energy per square metre of ground. Yet the coldest day arrives weeks later. This is <strong>thermal lag</strong>: the earth and atmosphere keep radiating stored heat even after the sun begins its climb back. It's the same reason late afternoon is hotter than solar noon, scaled to an entire hemisphere.
</p>
<div class="lag-stats">
<div class="lag-stat sol">
<div class="val" id="solDate"></div>
<div class="lbl">Winter solstice (min sun angle)</div>
</div>
<div class="lag-stat cold">
<div class="val" id="coldDate"></div>
<div class="lbl">Coldest day (min temperature)</div>
</div>
</div>
<p style="margin-top:0.95rem;">
For <strong id="lagCity"></strong>, this thermal lag is <strong id="lagDays"></strong> — the atmosphere needs that long to exhaust its stored warmth after the solstice turns the corner.
</p>
</div>
<footer>A concept by Erik Kalviainen · Built with Claude</footer>
</div>
<script>
const $ = id => document.getElementById(id);
const locations = [
{ name: "Waterloo, ON", cold: [1,29], hot: [7,21], h: 'N' },
{ name: "Toronto, ON", cold: [1,28], hot: [7,20], h: 'N' },
{ name: "Ottawa, ON", cold: [1,15], hot: [7,18], h: 'N' },
{ name: "Montreal, QC", cold: [1,14], hot: [7,18], h: 'N' },
{ name: "Vancouver, BC", cold: [12,28], hot: [8,2], h: 'N' },
{ name: "Calgary, AB", cold: [1,10], hot: [7,17], h: 'N' },
{ name: "Edmonton, AB", cold: [1,8], hot: [7,15], h: 'N' },
{ name: "Winnipeg, MB", cold: [1,10], hot: [7,19], h: 'N' },
{ name: "Halifax, NS", cold: [2,2], hot: [7,28], h: 'N' },
{ name: "Sudbury, ON", cold: [1,18], hot: [7,18], h: 'N' },
{ name: "New York, NY", cold: [1,29], hot: [7,22], h: 'N' },
{ name: "Chicago, IL", cold: [1,21], hot: [7,20], h: 'N' },
{ name: "Boston, MA", cold: [1,25], hot: [7,22], h: 'N' },
{ name: "Minneapolis, MN", cold: [1,13], hot: [7,19], h: 'N' },
{ name: "Denver, CO", cold: [12,25], hot: [7,14], h: 'N' },
{ name: "Detroit, MI", cold: [1,27], hot: [7,20], h: 'N' },
{ name: "Philadelphia, PA", cold: [1,28], hot: [7,22], h: 'N' },
{ name: "Seattle, WA", cold: [12,28], hot: [8,3], h: 'N' },
{ name: "Portland, OR", cold: [12,26], hot: [8,1], h: 'N' },
{ name: "San Francisco, CA", cold: [1,1], hot: [9,15], h: 'N' },
{ name: "Los Angeles, CA", cold: [12,22], hot: [8,15], h: 'N' },
{ name: "Miami, FL", cold: [1,15], hot: [7,25], h: 'N' },
{ name: "Washington, DC", cold: [1,27], hot: [7,21], h: 'N' },
{ name: "London, UK", cold: [2,8], hot: [7,25], h: 'N' },
{ name: "Paris, France", cold: [1,17], hot: [7,23], h: 'N' },
{ name: "Berlin, Germany", cold: [1,18], hot: [7,22], h: 'N' },
{ name: "Stockholm, Sweden", cold: [2,2], hot: [7,22], h: 'N' },
{ name: "Helsinki, Finland", cold: [2,5], hot: [7,20], h: 'N' },
{ name: "Moscow, Russia", cold: [1,22], hot: [7,20], h: 'N' },
{ name: "Amsterdam, NL", cold: [2,3], hot: [7,25], h: 'N' },
{ name: "Zurich, Switzerland", cold: [1,12], hot: [7,20], h: 'N' },
{ name: "Tokyo, Japan", cold: [1,25], hot: [8,5], h: 'N' },
{ name: "Seoul, S. Korea", cold: [1,15], hot: [8,1], h: 'N' },
{ name: "Beijing, China", cold: [1,14], hot: [7,18], h: 'N' },
{ name: "Sydney, Australia", cold: [7,17], hot: [1,22], h: 'S' },
{ name: "Melbourne, Australia", cold: [7,20], hot: [1,28], h: 'S' },
{ name: "Auckland, NZ", cold: [7,22], hot: [2,2], h: 'S' },
{ name: "Buenos Aires, Argentina", cold: [7,15], hot: [1,18], h: 'S' },
{ name: "São Paulo, Brazil", cold: [7,18], hot: [2,5], h: 'S' },
{ name: "Cape Town, S. Africa", cold: [7,20], hot: [2,8], h: 'S' },
];
const sel = $('locationSelect');
locations.forEach((loc, i) => {
const o = document.createElement('option');
o.value = i; o.textContent = loc.name; sel.appendChild(o);
});
sel.value = 0;
$('dateInput').value = new Date().toISOString().slice(0, 10);
function shortMonth(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }
function dayOfYear(d) {
const s = new Date(d.getFullYear(), 0, 0);
return Math.round((d - s) / 86400000);
}
function winterSolsticeDoy(h) { return h === 'N' ? 355 : 172; }
function summerSolsticeDoy(h) { return h === 'N' ? 172 : 355; }
function polar(deg, cx, cy, r) {
const rad = deg * Math.PI / 180;
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
}
function doyToAng(doy) { return (doy / 365) * 360 - 90; }
function svgArc(cx, cy, r, a1, a2) {
let sweep = a2 - a1;
if (sweep < 0) sweep += 360;
if (sweep <= 0.1) return '';
const large = sweep > 180 ? 1 : 0;
const [x1,y1] = polar(a1,cx,cy,r);
const [x2,y2] = polar(a2,cx,cy,r);
return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`;
}
function thermalLag(solDoy, coldDoy) {
let d = coldDoy - solDoy;
if (d < 0) d += 365;
if (d > 182) d -= 365;
return Math.abs(d);
}
function compute() {
const dVal = $('dateInput').value;
if (!dVal) { $('resultCard').style.display='none'; $('lagCard').style.display='none'; return; }
const loc = locations[parseInt(sel.value)];
const D = new Date(dVal + 'T12:00:00');
const yr = D.getFullYear();
const C = new Date(yr, loc.cold[0]-1, loc.cold[1], 12);
const H = new Date(yr, loc.hot[0]-1, loc.hot[1], 12);
const cDoy = dayOfYear(C), dDoy = dayOfYear(D), hDoy = dayOfYear(H);
$('coldestNote').textContent = `Coldest avg day: ${shortMonth(C)}`;
let n = dDoy - cDoy;
if (n > 182) n -= 365;
if (n < -182) n += 365;
let sDoy = cDoy - n;
if (sDoy < 1) sDoy += 365;
if (sDoy > 365) sDoy -= 365;
const sister = new Date(yr, 0, sDoy, 12);
const absN = Math.abs(n);
let warmD, coolD;
if (n >= 0) { warmD = D; coolD = sister; } else { warmD = sister; coolD = D; }
$('resultCard').style.display = '';
$('dateA').textContent = shortMonth(warmD);
$('dateB').textContent = shortMonth(coolD);
$('metaLine').innerHTML = `Both <em>${absN} days</em> from the coldest day (${shortMonth(C)})`;
const wSolDoy = winterSolsticeDoy(loc.h);
const sSolDoy = summerSolsticeDoy(loc.h);
const wSolDate = new Date(yr, 0, wSolDoy, 12);
const lag = thermalLag(wSolDoy, cDoy);
$('lagCard').style.display = '';
$('solDate').textContent = shortMonth(wSolDate);
$('coldDate').textContent = shortMonth(C);
$('lagCity').textContent = loc.name;
$('lagDays').textContent = lag + ' days';
// --- Orbital ---
const CX = 220, CY = 220, R = 155;
const cA = doyToAng(cDoy);
const wA = doyToAng(dayOfYear(warmD));
const fA = doyToAng(dayOfYear(coolD));
const hA = doyToAng(hDoy);
const wSolA = doyToAng(wSolDoy);
const sSolA = doyToAng(sSolDoy);
const arcW = svgArc(CX,CY,R, cA, wA);
const arcF = svgArc(CX,CY,R, fA, cA);
const lagArc = svgArc(CX,CY,R+14, wSolA, cA);
// Lag label midpoint
let lagSweep = cA - wSolA; if (lagSweep < 0) lagSweep += 360;
let lagMidA = lagSweep > 180 ? wSolA + (lagSweep - 360) / 2 : wSolA + lagSweep / 2;
const [llx,lly] = polar(lagMidA, CX, CY, R + 28);
// Month ticks
const mNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
let ticks = '';
for (let m = 0; m < 12; m++) {
const md = new Date(yr, m, 15);
const a = doyToAng(dayOfYear(md));
const [t1x,t1y] = polar(a,CX,CY,R-6);
const [t2x,t2y] = polar(a,CX,CY,R+6);
const [tlx,tly] = polar(a,CX,CY,R-26);
ticks += `<line x1="${t1x}" y1="${t1y}" x2="${t2x}" y2="${t2y}" stroke="rgba(0,0,0,0.08)" stroke-width="1"/>`;
ticks += `<text x="${tlx}" y="${tly+4}" text-anchor="middle" font-size="11" fill="rgba(0,0,0,0.25)" font-family="DM Sans,sans-serif" font-weight="400">${mNames[m]}</text>`;
}
// Sun rays at summer solstice
const [ssx,ssy] = polar(sSolA,CX,CY,R);
let rays = '';
for (let i = -2; i <= 2; i++) {
const rA = sSolA + i * 4;
const [rx1,ry1] = polar(rA,CX,CY,R+5);
const [rx2,ry2] = polar(rA,CX,CY,R+16+(i===0?5:0));
rays += `<line x1="${rx1}" y1="${ry1}" x2="${rx2}" y2="${ry2}" stroke="${'var(--hot)'}" stroke-width="${i===0?2:1}" opacity="${i===0?0.5:0.3}" stroke-linecap="round"/>`;
}
// All key points
const pts = {
c: { xy: polar(cA,CX,CY,R), lbl: polar(cA,CX,CY,R+30) },
h: { xy: polar(hA,CX,CY,R), lbl: polar(hA,CX,CY,R+30) },
w: { xy: polar(wA,CX,CY,R), lbl: polar(wA,CX,CY,R+30) },
f: { xy: polar(fA,CX,CY,R), lbl: polar(fA,CX,CY,R+30) },
ws: { xy: polar(wSolA,CX,CY,R), lbl: polar(wSolA,CX,CY,R+30) },
ss: { xy: polar(sSolA,CX,CY,R), lbl: polar(sSolA,CX,CY,R+30) },
};
// Solstice axis
const [sa1x,sa1y] = polar(wSolA,CX,CY,R-10);
const [sa2x,sa2y] = polar(sSolA,CX,CY,R-10);
$('orbitalSvg').innerHTML = `
${ticks}
<circle cx="${CX}" cy="${CY}" r="${R}" fill="none" stroke="rgba(0,0,0,0.07)" stroke-width="1.5"/>
<!-- Solstice axis -->
<line x1="${sa1x}" y1="${sa1y}" x2="${sa2x}" y2="${sa2y}" stroke="rgba(124,92,191,0.1)" stroke-width="1" stroke-dasharray="4 8"/>
<!-- Cold–center axis -->
<line x1="${pts.c.xy[0]}" y1="${pts.c.xy[1]}" x2="${CX}" y2="${CY}" stroke="rgba(58,122,181,0.1)" stroke-width="1" stroke-dasharray="4 6"/>
<!-- Hot–center axis -->
<line x1="${pts.h.xy[0]}" y1="${pts.h.xy[1]}" x2="${CX}" y2="${CY}" stroke="rgba(201,150,10,0.1)" stroke-width="1" stroke-dasharray="4 6"/>
<!-- Thermal lag arc -->
<path d="${lagArc}" fill="none" stroke="rgba(124,92,191,0.35)" stroke-width="1.5" stroke-dasharray="4 4" stroke-linecap="round"/>
<text x="${llx}" y="${lly+4}" text-anchor="middle" font-size="11" fill="rgba(124,92,191,0.7)" font-family="DM Sans,sans-serif" font-weight="500">${lag}d lag</text>
<!-- Sister day arcs -->
<path d="${arcW}" fill="none" stroke="var(--spring)" stroke-width="3.5" stroke-linecap="round" opacity="0.85"/>
<path d="${arcF}" fill="none" stroke="var(--fall)" stroke-width="3.5" stroke-linecap="round" opacity="0.85"/>
<!-- Sun rays -->
${rays}
<!-- Summer solstice -->
<circle cx="${ssx}" cy="${ssy}" r="5.5" fill="var(--hot)" opacity="0.45"/>
<text x="${pts.ss.lbl[0]}" y="${pts.ss.lbl[1]+5}" text-anchor="middle" font-size="11" fill="var(--hot)" font-family="DM Sans,sans-serif" opacity="0.6" font-weight="500">${loc.h==='N'?'Jun':'Dec'} solstice</text>
<!-- Winter solstice -->
<circle cx="${pts.ws.xy[0]}" cy="${pts.ws.xy[1]}" r="5.5" fill="var(--sol)" opacity="0.7"/>
<text x="${pts.ws.lbl[0]}" y="${pts.ws.lbl[1]+5}" text-anchor="middle" font-size="11" fill="var(--sol)" font-family="DM Sans,sans-serif" font-weight="500" opacity="0.8">${loc.h==='N'?'Dec':'Jun'} solstice</text>
<!-- Warmest day -->
<circle cx="${pts.h.xy[0]}" cy="${pts.h.xy[1]}" r="6" fill="var(--hot)" opacity="0.75"/>
<text x="${pts.h.lbl[0]}" y="${pts.h.lbl[1]+5}" text-anchor="middle" font-size="12" fill="var(--hot)" font-family="DM Sans,sans-serif" font-weight="600">${shortMonth(H)}</text>
<!-- Coldest day -->
<circle cx="${pts.c.xy[0]}" cy="${pts.c.xy[1]}" r="6.5" fill="var(--cold)"/>
<text x="${pts.c.lbl[0]}" y="${pts.c.lbl[1]+5}" text-anchor="middle" font-size="12" fill="var(--cold)" font-family="DM Sans,sans-serif" font-weight="600">${shortMonth(C)}</text>
<!-- Warming dot -->
<circle cx="${pts.w.xy[0]}" cy="${pts.w.xy[1]}" r="7" fill="var(--spring)"/>
<text x="${pts.w.lbl[0]}" y="${pts.w.lbl[1]+5}" text-anchor="middle" font-size="12.5" fill="var(--spring)" font-family="DM Sans,sans-serif" font-weight="600">${shortMonth(warmD)}</text>
<!-- Cooling dot -->
<circle cx="${pts.f.xy[0]}" cy="${pts.f.xy[1]}" r="7" fill="var(--fall)"/>
<text x="${pts.f.lbl[0]}" y="${pts.f.lbl[1]+5}" text-anchor="middle" font-size="12.5" fill="var(--fall)" font-family="DM Sans,sans-serif" font-weight="600">${shortMonth(coolD)}</text>
`;
}
$('dateInput').addEventListener('change', compute);
sel.addEventListener('change', compute);
compute();
</script>
</body>
</html>
| 1 | <!DOCTYPE html> |
| 2 | <html lang="en"> |
| 3 | <head> |
| 4 | <meta charset="UTF-8"> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 6 | <title>Sister Day Calculator</title> |
| 7 | <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,500;0,700;1,300;1,500&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet"> |
| 8 | <style> |
| 9 | :root { |
| 10 | --spring: #2d8a56; |
| 11 | --fall: #b85c2a; |
| 12 | --cold: #3a7ab5; |
| 13 | --sol: #7c5cbf; |
| 14 | --hot: #c9960a; |
| 15 | --bg: #faf9f7; |
| 16 | --surface: #ffffff; |
| 17 | --surface2: #f2f0ed; |
| 18 | --text: #1a1a1a; |
| 19 | --muted: #6b6b6b; |
| 20 | --border: rgba(0,0,0,0.08); |
| 21 | } |
| 22 | * { margin: 0; padding: 0; box-sizing: border-box; } |
| 23 | body { |
| 24 | background: var(--bg); color: var(--text); |
| 25 | font-family: 'DM Sans', sans-serif; font-weight: 400; |
| 26 | min-height: 100vh; overflow-x: hidden; |
| 27 | -webkit-font-smoothing: antialiased; |
| 28 | } |
| 29 | .container { |
| 30 | max-width: 760px; margin: 0 auto; padding: 3.5rem 1.5rem; |
| 31 | } |
| 32 | h1 { |
| 33 | font-family: 'Cormorant Garamond', serif; |
| 34 | font-weight: 500; font-size: clamp(3rem, 8vw, 4.8rem); |
| 35 | line-height: 1.05; letter-spacing: -0.02em; margin-bottom: 0.25em; |
| 36 | background: linear-gradient(135deg, var(--spring), var(--fall)); |
| 37 | -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; |
| 38 | } |
| 39 | .subtitle { |
| 40 | font-family: 'Cormorant Garamond', serif; |
| 41 | font-weight: 300; font-style: italic; |
| 42 | font-size: 1.35rem; color: var(--muted); |
| 43 | margin-bottom: 2.5rem; line-height: 1.5; |
| 44 | } |
| 45 | .definition { |
| 46 | border-left: 3px solid var(--cold); |
| 47 | padding: 1.1rem 1.4rem; margin-bottom: 2.5rem; |
| 48 | background: rgba(58,122,181,0.04); border-radius: 0 10px 10px 0; |
| 49 | font-size: 1.05rem; line-height: 1.75; color: var(--muted); |
| 50 | } |
| 51 | .definition strong { color: var(--text); font-weight: 500; } |
| 52 | .controls { |
| 53 | display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; margin-bottom: 2rem; |
| 54 | } |
| 55 | label { display: flex; flex-direction: column; gap: 0.45rem; } |
| 56 | label span { |
| 57 | font-size: 0.85rem; font-weight: 500; text-transform: uppercase; |
| 58 | letter-spacing: 0.07em; color: var(--muted); |
| 59 | } |
| 60 | input, select { |
| 61 | background: var(--surface); border: 1.5px solid var(--border); |
| 62 | color: var(--text); border-radius: 10px; padding: 0.75rem 1rem; |
| 63 | font-family: 'DM Sans', sans-serif; font-size: 1.05rem; font-weight: 400; |
| 64 | transition: border-color 0.2s; appearance: none; -webkit-appearance: none; |
| 65 | box-shadow: 0 1px 3px rgba(0,0,0,0.04); |
| 66 | } |
| 67 | select { |
| 68 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236b6b6b' fill='none' stroke-width='1.5'/%3E%3C/svg%3E"); |
| 69 | background-repeat: no-repeat; background-position: right 1rem center; |
| 70 | padding-right: 2.5rem; |
| 71 | } |
| 72 | input:focus, select:focus { outline: none; border-color: rgba(0,0,0,0.2); } |
| 73 | .coldest-note { |
| 74 | font-size: 0.85rem; color: var(--cold); margin-top: 0.15rem; min-height: 1.2em; font-weight: 500; |
| 75 | } |
| 76 | .result-card { |
| 77 | background: var(--surface); border-radius: 18px; padding: 2.25rem; |
| 78 | border: 1.5px solid var(--border); margin-bottom: 1.5rem; |
| 79 | box-shadow: 0 2px 12px rgba(0,0,0,0.04); |
| 80 | animation: fadeUp 0.5s ease both; |
| 81 | } |
| 82 | @keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } } |
| 83 | .pair { |
| 84 | display: flex; align-items: center; justify-content: center; gap: 1.5rem; |
| 85 | margin-bottom: 1.5rem; flex-wrap: wrap; |
| 86 | } |
| 87 | .day-badge { |
| 88 | display: flex; flex-direction: column; align-items: center; |
| 89 | padding: 1.1rem 1.75rem; border-radius: 14px; min-width: 150px; |
| 90 | } |
| 91 | .day-badge.spring { background: rgba(45,138,86,0.08); border: 1.5px solid rgba(45,138,86,0.2); } |
| 92 | .day-badge.fall { background: rgba(184,92,42,0.08); border: 1.5px solid rgba(184,92,42,0.2); } |
| 93 | .day-badge .season-label { |
| 94 | font-size: 0.75rem; font-weight: 600; text-transform: uppercase; |
| 95 | letter-spacing: 0.1em; margin-bottom: 0.3rem; |
| 96 | } |
| 97 | .day-badge.spring .season-label { color: var(--spring); } |
| 98 | .day-badge.fall .season-label { color: var(--fall); } |
| 99 | .day-badge .date { |
| 100 | font-family: 'Cormorant Garamond', serif; font-size: 1.85rem; font-weight: 600; |
| 101 | } |
| 102 | .mirror-symbol { font-size: 1.5rem; color: var(--muted); opacity: 0.4; } |
| 103 | .meta { text-align: center; font-size: 1rem; color: var(--muted); line-height: 1.6; } |
| 104 | .meta em { font-style: normal; color: var(--cold); font-weight: 500; } |
| 105 | .orbital { position: relative; width: 100%; max-width: 420px; margin: 1.75rem auto 0; aspect-ratio: 1; } |
| 106 | .orbital svg { width: 100%; height: 100%; } |
| 107 | |
| 108 | .legend { |
| 109 | display: flex; flex-wrap: wrap; justify-content: center; gap: 0.75rem 1.5rem; |
| 110 | margin-top: 1.25rem; font-size: 0.82rem; color: var(--muted); font-weight: 400; |
| 111 | } |
| 112 | .legend-item { display: flex; align-items: center; gap: 0.4rem; } |
| 113 | .legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } |
| 114 | |
| 115 | .lag-card { |
| 116 | background: var(--surface); border-radius: 18px; padding: 1.75rem 2.25rem; |
| 117 | border: 1.5px solid var(--border); margin-bottom: 2rem; |
| 118 | box-shadow: 0 2px 12px rgba(0,0,0,0.04); |
| 119 | animation: fadeUp 0.5s ease both; animation-delay: 0.1s; |
| 120 | } |
| 121 | .lag-card h3 { |
| 122 | font-family: 'Cormorant Garamond', serif; |
| 123 | font-weight: 600; font-size: 1.35rem; margin-bottom: 0.65rem; |
| 124 | color: var(--sol); |
| 125 | } |
| 126 | .lag-card p { |
| 127 | font-size: 1rem; line-height: 1.7; color: var(--muted); |
| 128 | } |
| 129 | .lag-card p strong { color: var(--text); font-weight: 500; } |
| 130 | .lag-card .lag-stats { |
| 131 | display: grid; grid-template-columns: 1fr 1fr; gap: 0.85rem; margin-top: 1.15rem; |
| 132 | } |
| 133 | .lag-stat { |
| 134 | background: var(--surface2); border-radius: 12px; padding: 0.85rem 1.1rem; text-align: center; |
| 135 | } |
| 136 | .lag-stat .val { |
| 137 | font-family: 'Cormorant Garamond', serif; font-size: 1.7rem; font-weight: 600; |
| 138 | } |
| 139 | .lag-stat .lbl { |
| 140 | font-size: 0.78rem; font-weight: 500; text-transform: uppercase; |
| 141 | letter-spacing: 0.05em; color: var(--muted); margin-top: 0.2rem; |
| 142 | } |
| 143 | .lag-stat.sol .val { color: var(--sol); } |
| 144 | .lag-stat.cold .val { color: var(--cold); } |
| 145 | |
| 146 | footer { |
| 147 | text-align: center; font-size: 0.85rem; color: var(--muted); padding: 2rem 0 1rem; |
| 148 | border-top: 1.5px solid var(--border); margin-top: 1.5rem; |
| 149 | } |
| 150 | @media (max-width: 480px) { |
| 151 | .controls { grid-template-columns: 1fr; } |
| 152 | .pair { gap: 0.75rem; } |
| 153 | .day-badge { min-width: 125px; padding: 0.85rem 1.25rem; } |
| 154 | .lag-card .lag-stats { grid-template-columns: 1fr; } |
| 155 | .container { padding: 2rem 1.25rem; } |
| 156 | } |
| 157 | </style> |
| 158 | </head> |
| 159 | <body> |
| 160 | <div class="container"> |
| 161 | <h1>Sister Day</h1> |
| 162 | <p class="subtitle">The mirror date across winter's coldest point</p> |
| 163 | |
| 164 | <div class="definition"> |
| 165 | <strong>Sister Day</strong> — For a given location, let <em>C</em> denote the calendar date with the lowest long-term average daily temperature. The <em>sister day</em> of a date <em>D</em> is the unique date <em>D′</em> equidistant from <em>C</em> on the opposite side of the annual temperature cycle — if <em>D</em> falls <em>n</em> days after <em>C</em>, then <em>D′</em> falls <em>n</em> days before <em>C</em>, and vice versa. |
| 166 | </div> |
| 167 | |
| 168 | <div class="controls"> |
| 169 | <label> |
| 170 | <span>Your date</span> |
| 171 | <input type="date" id="dateInput"> |
| 172 | </label> |
| 173 | <label> |
| 174 | <span>Location</span> |
| 175 | <select id="locationSelect"></select> |
| 176 | <div class="coldest-note" id="coldestNote"></div> |
| 177 | </label> |
| 178 | </div> |
| 179 | |
| 180 | <div class="result-card" id="resultCard" style="display:none;"> |
| 181 | <div class="pair"> |
| 182 | <div class="day-badge spring" id="badgeA"> |
| 183 | <div class="season-label" id="labelA">warming</div> |
| 184 | <div class="date" id="dateA"></div> |
| 185 | </div> |
| 186 | <div class="mirror-symbol">⟷</div> |
| 187 | <div class="day-badge fall" id="badgeB"> |
| 188 | <div class="season-label" id="labelB">cooling</div> |
| 189 | <div class="date" id="dateB"></div> |
| 190 | </div> |
| 191 | </div> |
| 192 | <div class="meta" id="metaLine"></div> |
| 193 | <div class="orbital"> |
| 194 | <svg viewBox="0 0 440 440" id="orbitalSvg"></svg> |
| 195 | </div> |
| 196 | <div class="legend"> |
| 197 | <div class="legend-item"><div class="legend-dot" style="background:var(--spring)"></div>Warming side</div> |
| 198 | <div class="legend-item"><div class="legend-dot" style="background:var(--fall)"></div>Cooling side</div> |
| 199 | <div class="legend-item"><div class="legend-dot" style="background:var(--cold)"></div>Coldest day</div> |
| 200 | <div class="legend-item"><div class="legend-dot" style="background:var(--hot)"></div>Warmest day</div> |
| 201 | <div class="legend-item"><div class="legend-dot" style="background:var(--sol)"></div>Winter solstice</div> |
| 202 | <div class="legend-item"><div class="legend-dot" style="background:var(--hot);opacity:0.45"></div>Summer solstice</div> |
| 203 | </div> |
| 204 | </div> |
| 205 | |
| 206 | <div class="lag-card" id="lagCard" style="display:none;"> |
| 207 | <h3>Why not the solstice?</h3> |
| 208 | <p> |
| 209 | The winter solstice marks the day the sun sits lowest in the sky — the most oblique angle of incidence and the least solar energy per square metre of ground. Yet the coldest day arrives weeks later. This is <strong>thermal lag</strong>: the earth and atmosphere keep radiating stored heat even after the sun begins its climb back. It's the same reason late afternoon is hotter than solar noon, scaled to an entire hemisphere. |
| 210 | </p> |
| 211 | <div class="lag-stats"> |
| 212 | <div class="lag-stat sol"> |
| 213 | <div class="val" id="solDate"></div> |
| 214 | <div class="lbl">Winter solstice (min sun angle)</div> |
| 215 | </div> |
| 216 | <div class="lag-stat cold"> |
| 217 | <div class="val" id="coldDate"></div> |
| 218 | <div class="lbl">Coldest day (min temperature)</div> |
| 219 | </div> |
| 220 | </div> |
| 221 | <p style="margin-top:0.95rem;"> |
| 222 | For <strong id="lagCity"></strong>, this thermal lag is <strong id="lagDays"></strong> — the atmosphere needs that long to exhaust its stored warmth after the solstice turns the corner. |
| 223 | </p> |
| 224 | </div> |
| 225 | |
| 226 | <footer>A concept by Erik Kalviainen · Built with Claude</footer> |
| 227 | </div> |
| 228 | |
| 229 | <script> |
| 230 | const $ = id => document.getElementById(id); |
| 231 | |
| 232 | const locations = [ |
| 233 | { name: "Waterloo, ON", cold: [1,29], hot: [7,21], h: 'N' }, |
| 234 | { name: "Toronto, ON", cold: [1,28], hot: [7,20], h: 'N' }, |
| 235 | { name: "Ottawa, ON", cold: [1,15], hot: [7,18], h: 'N' }, |
| 236 | { name: "Montreal, QC", cold: [1,14], hot: [7,18], h: 'N' }, |
| 237 | { name: "Vancouver, BC", cold: [12,28], hot: [8,2], h: 'N' }, |
| 238 | { name: "Calgary, AB", cold: [1,10], hot: [7,17], h: 'N' }, |
| 239 | { name: "Edmonton, AB", cold: [1,8], hot: [7,15], h: 'N' }, |
| 240 | { name: "Winnipeg, MB", cold: [1,10], hot: [7,19], h: 'N' }, |
| 241 | { name: "Halifax, NS", cold: [2,2], hot: [7,28], h: 'N' }, |
| 242 | { name: "Sudbury, ON", cold: [1,18], hot: [7,18], h: 'N' }, |
| 243 | { name: "New York, NY", cold: [1,29], hot: [7,22], h: 'N' }, |
| 244 | { name: "Chicago, IL", cold: [1,21], hot: [7,20], h: 'N' }, |
| 245 | { name: "Boston, MA", cold: [1,25], hot: [7,22], h: 'N' }, |
| 246 | { name: "Minneapolis, MN", cold: [1,13], hot: [7,19], h: 'N' }, |
| 247 | { name: "Denver, CO", cold: [12,25], hot: [7,14], h: 'N' }, |
| 248 | { name: "Detroit, MI", cold: [1,27], hot: [7,20], h: 'N' }, |
| 249 | { name: "Philadelphia, PA", cold: [1,28], hot: [7,22], h: 'N' }, |
| 250 | { name: "Seattle, WA", cold: [12,28], hot: [8,3], h: 'N' }, |
| 251 | { name: "Portland, OR", cold: [12,26], hot: [8,1], h: 'N' }, |
| 252 | { name: "San Francisco, CA", cold: [1,1], hot: [9,15], h: 'N' }, |
| 253 | { name: "Los Angeles, CA", cold: [12,22], hot: [8,15], h: 'N' }, |
| 254 | { name: "Miami, FL", cold: [1,15], hot: [7,25], h: 'N' }, |
| 255 | { name: "Washington, DC", cold: [1,27], hot: [7,21], h: 'N' }, |
| 256 | { name: "London, UK", cold: [2,8], hot: [7,25], h: 'N' }, |
| 257 | { name: "Paris, France", cold: [1,17], hot: [7,23], h: 'N' }, |
| 258 | { name: "Berlin, Germany", cold: [1,18], hot: [7,22], h: 'N' }, |
| 259 | { name: "Stockholm, Sweden", cold: [2,2], hot: [7,22], h: 'N' }, |
| 260 | { name: "Helsinki, Finland", cold: [2,5], hot: [7,20], h: 'N' }, |
| 261 | { name: "Moscow, Russia", cold: [1,22], hot: [7,20], h: 'N' }, |
| 262 | { name: "Amsterdam, NL", cold: [2,3], hot: [7,25], h: 'N' }, |
| 263 | { name: "Zurich, Switzerland", cold: [1,12], hot: [7,20], h: 'N' }, |
| 264 | { name: "Tokyo, Japan", cold: [1,25], hot: [8,5], h: 'N' }, |
| 265 | { name: "Seoul, S. Korea", cold: [1,15], hot: [8,1], h: 'N' }, |
| 266 | { name: "Beijing, China", cold: [1,14], hot: [7,18], h: 'N' }, |
| 267 | { name: "Sydney, Australia", cold: [7,17], hot: [1,22], h: 'S' }, |
| 268 | { name: "Melbourne, Australia", cold: [7,20], hot: [1,28], h: 'S' }, |
| 269 | { name: "Auckland, NZ", cold: [7,22], hot: [2,2], h: 'S' }, |
| 270 | { name: "Buenos Aires, Argentina", cold: [7,15], hot: [1,18], h: 'S' }, |
| 271 | { name: "São Paulo, Brazil", cold: [7,18], hot: [2,5], h: 'S' }, |
| 272 | { name: "Cape Town, S. Africa", cold: [7,20], hot: [2,8], h: 'S' }, |
| 273 | ]; |
| 274 | |
| 275 | const sel = $('locationSelect'); |
| 276 | locations.forEach((loc, i) => { |
| 277 | const o = document.createElement('option'); |
| 278 | o.value = i; o.textContent = loc.name; sel.appendChild(o); |
| 279 | }); |
| 280 | sel.value = 0; |
| 281 | $('dateInput').value = new Date().toISOString().slice(0, 10); |
| 282 | |
| 283 | function shortMonth(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } |
| 284 | function dayOfYear(d) { |
| 285 | const s = new Date(d.getFullYear(), 0, 0); |
| 286 | return Math.round((d - s) / 86400000); |
| 287 | } |
| 288 | function winterSolsticeDoy(h) { return h === 'N' ? 355 : 172; } |
| 289 | function summerSolsticeDoy(h) { return h === 'N' ? 172 : 355; } |
| 290 | |
| 291 | function polar(deg, cx, cy, r) { |
| 292 | const rad = deg * Math.PI / 180; |
| 293 | return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)]; |
| 294 | } |
| 295 | function doyToAng(doy) { return (doy / 365) * 360 - 90; } |
| 296 | |
| 297 | function svgArc(cx, cy, r, a1, a2) { |
| 298 | let sweep = a2 - a1; |
| 299 | if (sweep < 0) sweep += 360; |
| 300 | if (sweep <= 0.1) return ''; |
| 301 | const large = sweep > 180 ? 1 : 0; |
| 302 | const [x1,y1] = polar(a1,cx,cy,r); |
| 303 | const [x2,y2] = polar(a2,cx,cy,r); |
| 304 | return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`; |
| 305 | } |
| 306 | |
| 307 | function thermalLag(solDoy, coldDoy) { |
| 308 | let d = coldDoy - solDoy; |
| 309 | if (d < 0) d += 365; |
| 310 | if (d > 182) d -= 365; |
| 311 | return Math.abs(d); |
| 312 | } |
| 313 | |
| 314 | function compute() { |
| 315 | const dVal = $('dateInput').value; |
| 316 | if (!dVal) { $('resultCard').style.display='none'; $('lagCard').style.display='none'; return; } |
| 317 | |
| 318 | const loc = locations[parseInt(sel.value)]; |
| 319 | const D = new Date(dVal + 'T12:00:00'); |
| 320 | const yr = D.getFullYear(); |
| 321 | const C = new Date(yr, loc.cold[0]-1, loc.cold[1], 12); |
| 322 | const H = new Date(yr, loc.hot[0]-1, loc.hot[1], 12); |
| 323 | const cDoy = dayOfYear(C), dDoy = dayOfYear(D), hDoy = dayOfYear(H); |
| 324 | |
| 325 | $('coldestNote').textContent = `Coldest avg day: ${shortMonth(C)}`; |
| 326 | |
| 327 | let n = dDoy - cDoy; |
| 328 | if (n > 182) n -= 365; |
| 329 | if (n < -182) n += 365; |
| 330 | let sDoy = cDoy - n; |
| 331 | if (sDoy < 1) sDoy += 365; |
| 332 | if (sDoy > 365) sDoy -= 365; |
| 333 | const sister = new Date(yr, 0, sDoy, 12); |
| 334 | const absN = Math.abs(n); |
| 335 | |
| 336 | let warmD, coolD; |
| 337 | if (n >= 0) { warmD = D; coolD = sister; } else { warmD = sister; coolD = D; } |
| 338 | |
| 339 | $('resultCard').style.display = ''; |
| 340 | $('dateA').textContent = shortMonth(warmD); |
| 341 | $('dateB').textContent = shortMonth(coolD); |
| 342 | $('metaLine').innerHTML = `Both <em>${absN} days</em> from the coldest day (${shortMonth(C)})`; |
| 343 | |
| 344 | const wSolDoy = winterSolsticeDoy(loc.h); |
| 345 | const sSolDoy = summerSolsticeDoy(loc.h); |
| 346 | const wSolDate = new Date(yr, 0, wSolDoy, 12); |
| 347 | const lag = thermalLag(wSolDoy, cDoy); |
| 348 | |
| 349 | $('lagCard').style.display = ''; |
| 350 | $('solDate').textContent = shortMonth(wSolDate); |
| 351 | $('coldDate').textContent = shortMonth(C); |
| 352 | $('lagCity').textContent = loc.name; |
| 353 | $('lagDays').textContent = lag + ' days'; |
| 354 | |
| 355 | // --- Orbital --- |
| 356 | const CX = 220, CY = 220, R = 155; |
| 357 | const cA = doyToAng(cDoy); |
| 358 | const wA = doyToAng(dayOfYear(warmD)); |
| 359 | const fA = doyToAng(dayOfYear(coolD)); |
| 360 | const hA = doyToAng(hDoy); |
| 361 | const wSolA = doyToAng(wSolDoy); |
| 362 | const sSolA = doyToAng(sSolDoy); |
| 363 | |
| 364 | const arcW = svgArc(CX,CY,R, cA, wA); |
| 365 | const arcF = svgArc(CX,CY,R, fA, cA); |
| 366 | const lagArc = svgArc(CX,CY,R+14, wSolA, cA); |
| 367 | |
| 368 | // Lag label midpoint |
| 369 | let lagSweep = cA - wSolA; if (lagSweep < 0) lagSweep += 360; |
| 370 | let lagMidA = lagSweep > 180 ? wSolA + (lagSweep - 360) / 2 : wSolA + lagSweep / 2; |
| 371 | const [llx,lly] = polar(lagMidA, CX, CY, R + 28); |
| 372 | |
| 373 | // Month ticks |
| 374 | const mNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; |
| 375 | let ticks = ''; |
| 376 | for (let m = 0; m < 12; m++) { |
| 377 | const md = new Date(yr, m, 15); |
| 378 | const a = doyToAng(dayOfYear(md)); |
| 379 | const [t1x,t1y] = polar(a,CX,CY,R-6); |
| 380 | const [t2x,t2y] = polar(a,CX,CY,R+6); |
| 381 | const [tlx,tly] = polar(a,CX,CY,R-26); |
| 382 | ticks += `<line x1="${t1x}" y1="${t1y}" x2="${t2x}" y2="${t2y}" stroke="rgba(0,0,0,0.08)" stroke-width="1"/>`; |
| 383 | ticks += `<text x="${tlx}" y="${tly+4}" text-anchor="middle" font-size="11" fill="rgba(0,0,0,0.25)" font-family="DM Sans,sans-serif" font-weight="400">${mNames[m]}</text>`; |
| 384 | } |
| 385 | |
| 386 | // Sun rays at summer solstice |
| 387 | const [ssx,ssy] = polar(sSolA,CX,CY,R); |
| 388 | let rays = ''; |
| 389 | for (let i = -2; i <= 2; i++) { |
| 390 | const rA = sSolA + i * 4; |
| 391 | const [rx1,ry1] = polar(rA,CX,CY,R+5); |
| 392 | const [rx2,ry2] = polar(rA,CX,CY,R+16+(i===0?5:0)); |
| 393 | rays += `<line x1="${rx1}" y1="${ry1}" x2="${rx2}" y2="${ry2}" stroke="${'var(--hot)'}" stroke-width="${i===0?2:1}" opacity="${i===0?0.5:0.3}" stroke-linecap="round"/>`; |
| 394 | } |
| 395 | |
| 396 | // All key points |
| 397 | const pts = { |
| 398 | c: { xy: polar(cA,CX,CY,R), lbl: polar(cA,CX,CY,R+30) }, |
| 399 | h: { xy: polar(hA,CX,CY,R), lbl: polar(hA,CX,CY,R+30) }, |
| 400 | w: { xy: polar(wA,CX,CY,R), lbl: polar(wA,CX,CY,R+30) }, |
| 401 | f: { xy: polar(fA,CX,CY,R), lbl: polar(fA,CX,CY,R+30) }, |
| 402 | ws: { xy: polar(wSolA,CX,CY,R), lbl: polar(wSolA,CX,CY,R+30) }, |
| 403 | ss: { xy: polar(sSolA,CX,CY,R), lbl: polar(sSolA,CX,CY,R+30) }, |
| 404 | }; |
| 405 | |
| 406 | // Solstice axis |
| 407 | const [sa1x,sa1y] = polar(wSolA,CX,CY,R-10); |
| 408 | const [sa2x,sa2y] = polar(sSolA,CX,CY,R-10); |
| 409 | |
| 410 | $('orbitalSvg').innerHTML = ` |
| 411 | ${ticks} |
| 412 | <circle cx="${CX}" cy="${CY}" r="${R}" fill="none" stroke="rgba(0,0,0,0.07)" stroke-width="1.5"/> |
| 413 | |
| 414 | <!-- Solstice axis --> |
| 415 | <line x1="${sa1x}" y1="${sa1y}" x2="${sa2x}" y2="${sa2y}" stroke="rgba(124,92,191,0.1)" stroke-width="1" stroke-dasharray="4 8"/> |
| 416 | |
| 417 | <!-- Cold–center axis --> |
| 418 | <line x1="${pts.c.xy[0]}" y1="${pts.c.xy[1]}" x2="${CX}" y2="${CY}" stroke="rgba(58,122,181,0.1)" stroke-width="1" stroke-dasharray="4 6"/> |
| 419 | <!-- Hot–center axis --> |
| 420 | <line x1="${pts.h.xy[0]}" y1="${pts.h.xy[1]}" x2="${CX}" y2="${CY}" stroke="rgba(201,150,10,0.1)" stroke-width="1" stroke-dasharray="4 6"/> |
| 421 | |
| 422 | <!-- Thermal lag arc --> |
| 423 | <path d="${lagArc}" fill="none" stroke="rgba(124,92,191,0.35)" stroke-width="1.5" stroke-dasharray="4 4" stroke-linecap="round"/> |
| 424 | <text x="${llx}" y="${lly+4}" text-anchor="middle" font-size="11" fill="rgba(124,92,191,0.7)" font-family="DM Sans,sans-serif" font-weight="500">${lag}d lag</text> |
| 425 | |
| 426 | <!-- Sister day arcs --> |
| 427 | <path d="${arcW}" fill="none" stroke="var(--spring)" stroke-width="3.5" stroke-linecap="round" opacity="0.85"/> |
| 428 | <path d="${arcF}" fill="none" stroke="var(--fall)" stroke-width="3.5" stroke-linecap="round" opacity="0.85"/> |
| 429 | |
| 430 | <!-- Sun rays --> |
| 431 | ${rays} |
| 432 | |
| 433 | <!-- Summer solstice --> |
| 434 | <circle cx="${ssx}" cy="${ssy}" r="5.5" fill="var(--hot)" opacity="0.45"/> |
| 435 | <text x="${pts.ss.lbl[0]}" y="${pts.ss.lbl[1]+5}" text-anchor="middle" font-size="11" fill="var(--hot)" font-family="DM Sans,sans-serif" opacity="0.6" font-weight="500">${loc.h==='N'?'Jun':'Dec'} solstice</text> |
| 436 | |
| 437 | <!-- Winter solstice --> |
| 438 | <circle cx="${pts.ws.xy[0]}" cy="${pts.ws.xy[1]}" r="5.5" fill="var(--sol)" opacity="0.7"/> |
| 439 | <text x="${pts.ws.lbl[0]}" y="${pts.ws.lbl[1]+5}" text-anchor="middle" font-size="11" fill="var(--sol)" font-family="DM Sans,sans-serif" font-weight="500" opacity="0.8">${loc.h==='N'?'Dec':'Jun'} solstice</text> |
| 440 | |
| 441 | <!-- Warmest day --> |
| 442 | <circle cx="${pts.h.xy[0]}" cy="${pts.h.xy[1]}" r="6" fill="var(--hot)" opacity="0.75"/> |
| 443 | <text x="${pts.h.lbl[0]}" y="${pts.h.lbl[1]+5}" text-anchor="middle" font-size="12" fill="var(--hot)" font-family="DM Sans,sans-serif" font-weight="600">${shortMonth(H)}</text> |
| 444 | |
| 445 | <!-- Coldest day --> |
| 446 | <circle cx="${pts.c.xy[0]}" cy="${pts.c.xy[1]}" r="6.5" fill="var(--cold)"/> |
| 447 | <text x="${pts.c.lbl[0]}" y="${pts.c.lbl[1]+5}" text-anchor="middle" font-size="12" fill="var(--cold)" font-family="DM Sans,sans-serif" font-weight="600">${shortMonth(C)}</text> |
| 448 | |
| 449 | <!-- Warming dot --> |
| 450 | <circle cx="${pts.w.xy[0]}" cy="${pts.w.xy[1]}" r="7" fill="var(--spring)"/> |
| 451 | <text x="${pts.w.lbl[0]}" y="${pts.w.lbl[1]+5}" text-anchor="middle" font-size="12.5" fill="var(--spring)" font-family="DM Sans,sans-serif" font-weight="600">${shortMonth(warmD)}</text> |
| 452 | |
| 453 | <!-- Cooling dot --> |
| 454 | <circle cx="${pts.f.xy[0]}" cy="${pts.f.xy[1]}" r="7" fill="var(--fall)"/> |
| 455 | <text x="${pts.f.lbl[0]}" y="${pts.f.lbl[1]+5}" text-anchor="middle" font-size="12.5" fill="var(--fall)" font-family="DM Sans,sans-serif" font-weight="600">${shortMonth(coolD)}</text> |
| 456 | `; |
| 457 | } |
| 458 | |
| 459 | $('dateInput').addEventListener('change', compute); |
| 460 | sel.addEventListener('change', compute); |
| 461 | compute(); |
| 462 | </script> |
| 463 | </body> |
| 464 | </html> |
| 465 |