{
const W = 540, H = 540, cx = W/2, cy = H/2, R = 240;
const svg = d3.create("svg").attr("viewBox", [0,0,W,H])
.style("background","radial-gradient(circle at center, #001 0%, #000 70%)")
.style("border-radius","8px").style("max-width","100%").style("height","auto");
// Stereographic from nadir: r = R * tan((90° - alt)/2). Returns null when the
// point is below the horizon so callers can break their path there.
function project(alt, az) {
if (alt < -0.001) return null;
const r = R * Math.tan((Math.PI/2 - alt)/2);
// az measured from N clockwise; on screen, x = r sin(az), y = -r cos(az)
return [cx + r*Math.sin(az), cy - r*Math.cos(az)];
}
// Path generator that BREAKS at null entries instead of connecting across them.
// Also breaks when consecutive points are far apart on screen (azimuth wrap-around).
const lineGen = d3.line()
.defined(d => d != null)
.x(d => d[0]).y(d => d[1]);
// Horizon ring + altitude circles
for (const a of [30, 60]) {
const r = R * Math.tan((Math.PI/2 - a*DEG)/2);
svg.append("circle").attr("cx",cx).attr("cy",cy).attr("r",r)
.attr("fill","none").attr("stroke","#234").attr("stroke-dasharray","2,3");
}
svg.append("circle").attr("cx",cx).attr("cy",cy).attr("r",R)
.attr("fill","none").attr("stroke","#445");
// Cardinal labels
const card = [["N",0],["E",Math.PI/2],["S",Math.PI],["W",3*Math.PI/2]];
for (const [t,a] of card) {
svg.append("text").attr("x", cx + (R+12)*Math.sin(a))
.attr("y", cy - (R+12)*Math.cos(a) + 4)
.attr("text-anchor","middle").attr("fill","#88a").attr("font-size","12px").text(t);
}
// Celestial equator + ecliptic + body trails, sampled.
// We post-process: any pair of consecutive non-null points that are unrealistically
// far apart on screen (a wrap-around chord) gets a null inserted between them so
// .defined() breaks the path there.
function drawGreatCircle(samples, color) {
const broken = [];
for (let i = 0; i < samples.length; i++) {
const p = samples[i];
const prev = broken[broken.length - 1];
if (p && prev) {
const dx = p[0] - prev[0], dy = p[1] - prev[1];
if (dx*dx + dy*dy > (R*0.5)*(R*0.5)) broken.push(null);
}
broken.push(p);
}
svg.append("path").attr("d", lineGen(broken)).attr("fill","none")
.attr("stroke",color).attr("stroke-width",0.6).attr("opacity",0.55);
}
// Equator: δ = 0, vary α
const eqPts = [];
for (let a=0; a<2*Math.PI; a+=Math.PI/120) {
const H = lstFor(jdNow, loc.lon) - a;
const sinAlt = Math.cos(loc.lat*DEG)*Math.cos(H);
const alt = Math.asin(sinAlt);
const az = Math.atan2(-Math.sin(H), -Math.sin(loc.lat*DEG)*Math.cos(H));
eqPts.push(project(alt, (az+2*Math.PI)%(2*Math.PI)));
}
drawGreatCircle(eqPts, "#5a8");
// Ecliptic
const eps0 = obliquity(julianCenturies(jdNow));
const ecPts = [];
for (let l=0; l<2*Math.PI; l+=Math.PI/120) {
const dec = Math.asin(Math.sin(eps0)*Math.sin(l));
const ra = Math.atan2(Math.cos(eps0)*Math.sin(l), Math.cos(l));
const H = lstFor(jdNow, loc.lon) - ra;
const sinAlt = Math.sin(loc.lat*DEG)*Math.sin(dec)+Math.cos(loc.lat*DEG)*Math.cos(dec)*Math.cos(H);
const alt = Math.asin(Math.max(-1, Math.min(1, sinAlt)));
const y = -Math.sin(H);
const x = Math.tan(dec)*Math.cos(loc.lat*DEG) - Math.sin(loc.lat*DEG)*Math.cos(H);
let az = Math.atan2(y,x); if (az<0) az += 2*Math.PI;
ecPts.push(project(alt, az));
}
drawGreatCircle(ecPts, "#a85");
// 24-hour trails for Sun & Moon — keep nulls in-place so the path breaks
// when the body dips below the horizon.
function trail(bodyFn, color) {
const pts = [];
for (let dh=-24; dh<=0; dh+=0.25) {
const t = jdNow + dh/24;
const b = bodyFn(t);
const aa = altAz(loc.lat*DEG, b.delta, b.alpha, t, loc.lon);
pts.push(project(aa.alt, aa.az));
}
drawGreatCircle(pts, color);
}
trail(sunPosition, "#ffcc33");
trail(moonPosition, "#cce");
// Current Sun & Moon
const sunAA = altAz(loc.lat*DEG, sun.delta, sun.alpha, jdNow, loc.lon);
const moonAA = altAz(loc.lat*DEG, moon.delta, moon.alpha, jdNow, loc.lon);
const sp = project(sunAA.alt, sunAA.az);
const mp = project(moonAA.alt, moonAA.az);
if (sp) {
svg.append("circle").attr("cx",sp[0]).attr("cy",sp[1]).attr("r",7)
.attr("fill","#ffcc33").attr("filter","drop-shadow(0 0 6px #ffcc33)");
}
if (mp) {
const g = svg.append("g").attr("transform",`translate(${mp[0]},${mp[1]})`);
g.append("circle").attr("r",6).attr("fill","#eee");
// shade based on phase
const k = ill.illuminated;
g.append("path").attr("d", d3.arc()({innerRadius:0, outerRadius:6,
startAngle: 0, endAngle: Math.PI*(1-k)*2}))
.attr("fill","rgba(0,0,0,0.7)");
}
svg.append("text").attr("x", 8).attr("y", 16).attr("fill","#bbb").attr("font-size","11px")
.text(`zenith view · ${currentDateLabel} · ${picker.hour.toFixed(2)}h local`);
svg.append("text").attr("x", 8).attr("y", H-8).attr("fill","#5a8").attr("font-size","10px")
.text("— equator");
svg.append("text").attr("x", 90).attr("y", H-8).attr("fill","#a85").attr("font-size","10px")
.text("— ecliptic");
return svg.node();
}
{
const W = 540, H = 540;
const svg = d3.create("svg").attr("viewBox", [0,0,W,H])
.style("background","#001").style("border-radius","8px")
.style("max-width","100%").style("height","auto");
// Center the globe on the observer's longitude rotated by Earth's spin
const rot = [-loc.lon, -loc.lat*0.6, 0];
const proj = d3.geoOrthographic().scale(W*0.45).translate([W/2, H/2]).rotate(rot).clipAngle(90);
const path = d3.geoPath(proj);
// Globe sphere
svg.append("path").datum({type:"Sphere"}).attr("d", path)
.attr("fill","#001a33").attr("stroke","#345");
// Wireframe graticule (15° spacing)
const graticule = d3.geoGraticule().step([15,15]);
svg.append("path").datum(graticule).attr("d", path)
.attr("fill","none").attr("stroke","#356").attr("stroke-width",0.4);
// Equator highlighted
svg.append("path").datum(d3.geoGraticule().step([180,180]).extentMinor([[-180,0],[180,0]]))
.attr("d", path).attr("fill","none").attr("stroke","#588").attr("stroke-width",0.7);
// Sub-solar / sub-lunar
const subSun = subPoint(jdNow, sunPosition);
const subMoon = subPoint(jdNow, moonPosition);
// Night side: great circle 90° from sub-solar, hemisphere AWAY from sun is dark
const antiSun = [(subSun.lon*RAD + 180 + 540) % 360 - 180, -subSun.lat*RAD];
const night = d3.geoCircle().radius(90).center(antiSun)();
svg.append("path").datum(night).attr("d", path)
.attr("fill","rgba(0,0,30,0.55)").attr("stroke","#446").attr("stroke-width",0.5);
// Sub-solar point
svg.append("path").datum({type:"Point", coordinates:[subSun.lon*RAD, subSun.lat*RAD]})
.attr("d", path.pointRadius(6))
.attr("fill","#ffcc33").attr("stroke","#aa6");
// Sub-lunar point
svg.append("path").datum({type:"Point", coordinates:[subMoon.lon*RAD, subMoon.lat*RAD]})
.attr("d", path.pointRadius(5))
.attr("fill","#eee").attr("stroke","#888");
// Observer
svg.append("path").datum({type:"Point", coordinates:[loc.lon, loc.lat]})
.attr("d", path.pointRadius(3.5))
.attr("fill","#f44").attr("stroke","#fff").attr("stroke-width",0.5);
// Inset readout
const sunAlt = altAz(loc.lat*DEG, sun.delta, sun.alpha, jdNow, loc.lon).alt*RAD;
const moonAlt = altAz(loc.lat*DEG, moon.delta, moon.alpha, jdNow, loc.lon).alt*RAD;
svg.append("text").attr("x", 8).attr("y", 16).attr("fill","#bbb").attr("font-size","11px")
.text(`Sun alt ${sunAlt.toFixed(1)}°, Moon alt ${moonAlt.toFixed(1)}°`);
svg.append("text").attr("x", 8).attr("y", H-8).attr("fill","#888").attr("font-size","10px")
.text(`☉ sub-solar (${(subSun.lat*RAD).toFixed(1)}°, ${(subSun.lon*RAD).toFixed(1)}°)`);
return svg.node();
}