Public
Edited
Jul 22, 2024
Paused
3 forks
34 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
chart = {
const svg = d3.create("svg")
.attr("viewBox", [0, 0, svg_size, svg_size]);
var chart = { node: svg.node() };

var defs = svg.append('defs')
defs.append(hand_marker)
svg.append("circle")
.attr("r", svg_size / 2 - .5)
.attr("cx", svg_size / 2)
.attr("cy", svg_size / 2)
.attr("stroke", "#aaa")
.attr("fill", "#eee");

svg.append("circle")
.attr("r", radius_t)
.attr("cx", svg_size / 2)
.attr("cy", svg_size / 2)
.attr("stroke", "#aaa")
.attr("fill", "#ddd");

svg.append("circle")
.attr("r", radius)
.attr("cx", svg_size / 2)
.attr("cy", svg_size / 2)
.attr("fill", "#8af")
.attr("stroke", "none");
var clip_face = defs
.append('clipPath')
.attr('id', 'clip-face');
clip_face.append("circle")
.attr("r", radius)
.attr("cx", svg_size / 2)
.attr("cy", svg_size / 2);
var gClipped = svg.append("g")
.attr('clip-path', 'url(#clip-face)');
var gSky = gClipped.append("g");
chart.skyGrid = gSky.append("path")
.attr("fill", "none")
.attr("stroke", "white")
.attr("opacity", .3)
.attr("stroke-width", 1);
// celestial equator
chart.skyEq = gSky.append("path")
.attr("fill", "none")
.attr("stroke", "white")
.attr("opacity", 1)
.attr("stroke-width", 1);

// zenith
chart.z_char = put_char(gSky, "Z").attr("fill", "#fff");
var gEarth = gClipped.append("g");
chart.gEarth = gEarth;

chart.horizons = gEarth.append("g");

chart.earth_grid = gEarth.append("path")
.attr("fill", "none")
.attr("opacity", .3)
.attr("stroke", "white")
.attr("stroke-width", .5);
chart.nadir_char = put_char(gEarth, "👤")
.attr("font-size", 14);
chart.s_char = put_char(gEarth, "S")
.attr("font-size", 14).attr("fill", "#ec9");
chart.w_char = put_char(gEarth, "W")
.attr("font-size", 14).attr("fill", "#ec9");
chart.n_char = put_char(gEarth, "N")
.attr("font-size", 14).attr("fill", "#ec9");
chart.e_char = put_char(gEarth, "E")
.attr("font-size", 14).attr("fill", "#ec9");

var gSun = gClipped.append("g");

// sun track for today (appx.)
chart.sun_track = gSun.append("path")
.attr("fill", "none")
.attr("stroke", "#fe9")
.attr("opacity", 1)
.attr("stroke-width", 1)
.attr("stroke-dasharray", [3, 10]);

// moon track for today (appx.)
chart.moon_track = gSun.append("path")
.attr("fill", "none")
.attr("stroke", "#777")
.attr("opacity", 1)
.attr("stroke-width", 1)
.attr("stroke-dasharray", [3, 10]);
// sidereal time hands
// define hand length (for star) in image space:
chart.sidereal_1 = gSun.append("path")
.attr("fill", "none")
.attr("stroke", "#444")
.attr("opacity", 1)
.attr("stroke-width", 1);
chart.sidereal_2 = gSun.append("path")
.attr("fill", "none")
.attr("stroke", "#444")
.attr("opacity", 1)
.attr("stroke-width", 1);
chart.star = gSun.append(star_shape);
// ecliptic
chart.ecliptic = gSun.append("g");

// horoscope
chart.horoscope = gSun.append("g");

// dot in middle of hands
svg.append("circle")
.attr("r", 4)
.attr("cx", svg_size / 2)
.attr("cy", svg_size / 2)
.attr("fill", "black");
// border of clock face (has to be above the earth and sky fills)
svg.append("circle")
.attr("r", radius)
.attr("cx", svg_size / 2)
.attr("cy", svg_size / 2)
.attr("fill", "none")
.attr("stroke", "black");

// mean solar time scale
self.solar_time = new TimeScale(svg, radius, "#333", "#eee", d => roman_num(d), svg_size / 2);
self.solar_time.g_labels.style('letter-spacing', '-0.0625625em')
self.solar_time.g.append("g").attr("transform", `translate(-9, ${-radius - 23}) scale(${9/16})`).append(sun_shape);
// local time scale
chart.local_time = new TimeScale(svg, radius_t, "#777", "#fff", d => d, svg_size / 2);
var gHands = svg.append("g");
// sun hand
chart.sun_line = gHands.append("path")
.attr("fill", "none")
.attr("stroke", "#000")
.attr("opacity", 1)
.attr("stroke-width", 2)
.attr("marker-end", "url(#hand-end)");
chart.sun = gHands.append("g").append(sun_shape);
// moon hand
// mean lunar time (without equation)
chart.moon_line = gHands.append("path")
.attr("fill", "none")
.attr("stroke", "#000")
.attr("opacity", .7)
.attr("stroke-width", 1.5);
chart.moon = [gHands.append("circle"), gHands.append("path")]
return chart;
}
Insert cell
update_chart = {

var svg_earth_rot = `rotate(${flip_pole(-rot_clock_dial) * 360} ${svg_size/2} ${svg_size/2})`;
chart.gEarth.attr("transform", svg_earth_rot);
chart.skyGrid.datum(graticule_sky)
.attr("d", sky_display == "Earth grid" ? path_earth : path_celestial)
.attr("transform", sky_display == "Earth grid" ? svg_earth_rot : "");
chart.skyEq.datum(celestial_eq).attr("d", path_celestial);
update_char(chart.z_char, projection_earth([0, 90]));
update_char(chart.nadir_char, projection_earth([0, -90]));
update_char(chart.s_char, projection_earth([0, -3]));
update_char(chart.w_char, projection_earth([90, -3]));
update_char(chart.n_char, projection_earth([180, -3]));
update_char(chart.e_char, projection_earth([270, -3]));
var sun_angle = sun_rot * 360;
var moon_angle = moon_rot * 360;
var sun = projection_celestial(sun_pos);
var moon = projection_celestial(moon_pos);

// sidereal time hands
// define hand length (for star) in image space:
var ecliptic_q1 = projection_celestial([180, 0]);
var ecliptic_q2 = projection_celestial([ 90, axis_tilt]);
var ecliptic_q4 = projection_celestial([-90, -axis_tilt]);
var sid_line = hand_with_length([0, -pole_lookat], [0, 0], projection_celestial, radius - 15)
var star = sid_line[1];
// mean solar time (without equation)
// define length in image space
var sun_line = hand_with_length([sun_angle, -pole_lookat], [sun_angle, 0], projection_celestial, radius - 19)
chart.sidereal_1.datum(mk_path([ecliptic_q1, sid_line[1]])).attr("d", path_identity)
chart.sidereal_2.datum(mk_path([ecliptic_q2, ecliptic_q4])).attr("d", path_identity)
chart.sun_line.datum(mk_path(sun_line)).attr("d", path_identity)
chart.moon_line.datum(mk_path([[0, -pole_lookat], [moon_angle, flip_pole(axis_tilt)]]))
.attr("d", path_celestial)

chart.sun.attr("transform", `translate(${sun[0] - 14}, ${sun[1] - 14}) scale(${14/16})`);
var star_rot = 180 - 360 * flip_pole(sky_rot + rot_clock_dial);
chart.star.attr("transform", `translate(${star[0] - 10} ${star[1] - 10}) scale(0.625) rotate(${star_rot} 16 16) `);
update_moon_shape(chart.moon[0], chart.moon[1], moon, 12, 2, moon_phase)
var mk_horizon = d3.geoCircle().radius(90).precision(10).center([0, -90]);
var horizons_data = [
{a:0, fill:"#906850"},
{a:6, fill:"#786050"},
{a:12, fill:"#635850"},
{a:18, fill:"#4b4b50"}
]
chart.horizons
.selectAll("path")
.data(horizons_data)
.join("path")
.attr("d", d => path_earth(mk_horizon.radius(90 - d.a)()))
.attr("fill", d => d.fill)
.attr("stroke", "black")
.attr("opacity", 1)
.attr("stroke-width", d => d.a == 0 ? 1 : .3);
chart.earth_grid.datum(graticule).attr("d", path_earth)

// it is important for performance that we avoid circles with a centre
// near the pole *opposite of* the celestial pole we’re looking at.
var mk_track = d3.geoCircle().radius(90).precision(10).center([0, pole_lookat]);
chart.sun_track.datum(mk_track.radius(90 - flip_pole(sun_pos[1])))
.attr("d", path_celestial)
chart.moon_track.datum(mk_track.radius(90 - flip_pole(moon_pos[1])))
.attr("d", path_celestial)

chart.ecliptic
.selectAll("path")
.data(ecliptic)
.join("path")
.attr("d", path_celestial)
.attr("fill", "none")
.attr("stroke", "#fe9")
.attr("opacity", 1)
.attr("stroke-width", 1.5);
chart.horoscope.selectAll("text")
.data(horoscope)
.join("text")
.datum(d => d)
.text(a => a.text)
.attr("dy", ".4em")
.attr("text-anchor", "middle")
.attr("style", "fill: #fe9; stroke: black; stroke-opacity:30%; stroke-width: 3px; stroke-linejoin: round; paint-order: stroke fill")
.attr("font-size", 18)
.attr("x", a => a.p[0])
.attr("y", a => a.p[1]);

var rot_clock_mirrored = -flip_pole(rot_clock_dial);
self.solar_time.update(rot_clock_mirrored)

var local_rot = rot_clock_mirrored + flip_pole((observer[0] / 360) - current_time.utc_offset / 24);
chart.local_time.update(local_rot)
}
Insert cell
Insert cell
prague = [14.420917, 50.087008];
Insert cell
Insert cell
md`#### Default time`
Insert cell
param = {
var params = new URLSearchParams(html`<a href>`.search)
return function(key, def)
{
var v = params.get(key);
if (v == null) return def;
return v;
}
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
axis_tilt = 23.4
Insert cell
// Moment the mean solar time passes through the first point of Aries
// (this is when the sidereal time at 0°E is the same as the mean solar time).
// Due to equation of time this is about two days later than the equinox.
equinox = Date.UTC(2020, 2, 22, 0)
Insert cell
// sun_rot value at perihelion (this is a point where the effect of
// orbit eccentricity on equation of time is zero)
sun_rot_perihelion = .784
Insert cell
// Time stamp of a new moon (according to mean lunation)
// To get close to the average you should pick a new moon in
// the longest or shortest lunar cycle.
new_moon = Date.UTC(2023, 8, 15, 1, 39)
Insert cell
Insert cell
// sun rotation relative to sky. 0 at vernal equinox
sun_rot = (current_time.date.getTime() - equinox) / milliseconds_per_year
Insert cell
// sky rotation relative to earth
sky_rot = (-sun_rot_epoch - current_time.date.getTime() / milliseconds_per_sidereal_day - observer[0] / 360) % 1;
Insert cell
// Sky rotation at Unix epoch
// at lon=0°E at 0 local time, sky_rot == -sun_rot so the sun
// is exactly north
sun_rot_epoch = -equinox / milliseconds_per_year;
Insert cell
moon_rot = (sun_rot + (current_time.date.getTime() - new_moon) / milliseconds_per_lunar_month) % 1;
Insert cell
// declination of the point on the ecliptic with given longitude (λ)
declination_on_ecliptic = λ => Math.asin(sin_axis_tilt * Math.sin(λ)) * 180 / Math.PI
Insert cell
// Calculates the ecliptic longitude (λ) of the point on the ecliptic
// with a given right ascension (α)
// α is derived (for the sun) from mean solar time.
function rot_to_λ(rot)
{
var α = rot * Math.PI * 2;
return Math.atan2(Math.sin(α), Math.cos(α) * cos_axis_tilt);
}
Insert cell
// convert ecliptic coordinates to equatorial: (λ, β) to (α, δ)
ecliptic_rotation = d3.geoRotation([0, 0, axis_tilt]);
Insert cell
ecliptic_rotation([0, 0])
Insert cell
sun_pos = {
var angle = sun_rot * 360;
if (sun_moon_mode == "clock")
return [angle, declination_on_ecliptic(rot_to_λ(sun_rot))];
else if (sun_moon_mode == "ecliptic")
return ecliptic_rotation([angle, 0]);
else // "eqn"
// apply approximation from Wikipedia, ahem, the Astronomical Almanac
// this has zero crossings at aphelion (↗) and perihelion (↘)
var g = (sun_rot - sun_rot_perihelion) * 2 * Math.PI;
angle += 1.915 * Math.sin(g) + 0.020 * Math.sin(2*g);
return ecliptic_rotation([angle, 0]);
}
Insert cell
// The moon orbit is not that regular, so just settle for
// mean lunar time
moon_pos = [moon_rot * 360, declination_on_ecliptic(rot_to_λ(moon_rot))]
Insert cell
moon_phase = {
var d = moon_rot - sun_rot;
return d - Math.floor(d);
}
Insert cell
sin_axis_tilt = Math.sin(Math.PI / 180 * axis_tilt)
Insert cell
cos_axis_tilt = Math.cos(Math.PI / 180 * axis_tilt)
Insert cell
rot_clock_dial = (anchor == "Observer" ? 0 : anchor == "Mean solar time" ? -sky_rot - sun_rot - .50: -sky_rot + flip_pole(.25)) % 1
Insert cell
Insert cell
date_format = Intl.DateTimeFormat(undefined, {
timeStyle: "short",
dateStyle: "medium",
timeZone: "UTC"
})
Insert cell
sidereal_time_format = Intl.DateTimeFormat(undefined, {
timeStyle: "short", timeZone: "UTC", hourCycle: 'h24'})
Insert cell
pad = n => ("00" + n).slice(-2)
Insert cell
function roman_num(d)
{
if (d == 0) return "XII";
if (d == 12) return ""
if (d > 12) d -= 12;
var s = "";
while (d >= 9)
{
s = s + "X";
d -= 10;
}
if (d >= 5) // clocks usually have IIII for 4
{
s = s + "V";
d -= 5;
}
while (d > 0)
{
s = s + "I";
d -= 1;
}
while (d < 0)
{
s = s.substr(0, s.length - 1) + "I" + s.substr(s.length - 1, 1);
d += 1;
}
return s;
}
Insert cell
Insert cell
radius = .44 * svg_size
Insert cell
radius_t = .47 * svg_size
Insert cell
svg_size = 800
Insert cell
svg_max_width = +param("width", 1024)
Insert cell
Insert cell
is_north_up = observer[1] <= 0 ? 1 : 0
Insert cell
function flip_pole(n)
{
return is_north_up ? -n : n;
}
Insert cell
pole_lookat = flip_pole(90)
Insert cell
projection_celestial = {
var rot = [(sky_rot + rot_clock_dial) * 360 + (1-is_north_up) * 180,
pole_lookat];
return d3.geoStereographic()
.reflectX(true)
.scale(svg_size / 4)
.translate([svg_size / 2, svg_size / 2])
.rotate(rot)
.clipAngle(180 - 1e-4)
.clipExtent([[0, 0], [svg_size, svg_size]])
.precision(0.2);
}
Insert cell
projection_earth = d3.geoStereographic()
.scale(svg_size / 4)
.translate([svg_size / 2, svg_size / 2])
.rotate([is_north_up * 180, Math.abs(observer[1])])
.clipAngle(180 - 1e-4)
.clipExtent([[0, 0], [svg_size, svg_size]])
.precision(0.2)
Insert cell
path_celestial = d3.geoPath(projection_celestial);
Insert cell
path_identity = d3.geoPath()
Insert cell
path_earth = d3.geoPath(projection_earth);
Insert cell
function mk_path(coords)
{
return {
type: "LineString",
coordinates: coords
};
}
Insert cell
Insert cell
graticule = d3.geoGraticule().step([90, 90]).extent([[-180, -90], [179.99999, 0]])();
Insert cell
graticule_sky = {
if (sky_display == "None") { return []; }
if (sky_display == "Constellations")
{
var ofrohn_url = "https://ofrohn.github.io/data/constellations.lines.json";
return d3.json(ofrohn_url);
}
return d3.geoGraticule().step([15, 15]).extent([[-180, -90], [179.99999, 90]])();
}
Insert cell
ecliptic = {
var c = d3.geoCircle().precision(10).center([-90 + 180 * is_north_up, -flip_pole(90 + axis_tilt)]);
var l = [c.radius(89.5)(), c.radius(90.5)(), c.radius(75)()];
for (var i = 0; i < 12; ++i)
{
var a = 30 * i;
var rad = a * Math.PI / 180;
l.push(mk_path([
ecliptic_rotation([a, -flip_pole(.5)]),
ecliptic_rotation([a, - flip_pole(15)])
]))
}
return l;
}
Insert cell
function put_char(g, char)
{
return g.append("text")
.attr("dy", ".4em")
.attr("text-anchor", "middle")
.text(char)}
Insert cell
function update_char(ch_el, pos)
{
ch_el.attr("x", pos[0])
.attr("y", pos[1]);
}
Insert cell
horoscope = {
var chars = ["♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓"]
var h = [];
for (var i = 0; i < 12; ++i)
{
var a = 15 + 30 * i;
var rad = a * Math.PI / 180;
h.push({
p: projection_celestial(ecliptic_rotation([a, -flip_pole(7.5)])),
text: chars[i] + "\uFE0E" /* modifier: emoji black/white variant */
})
}
return h;
}
Insert cell
celestial_eq = d3.geoCircle().radius(90).precision(10).center([0, pole_lookat])();
Insert cell
TimeScale = {
function _TimeScale(parent, radius, color, shadow_color, mk_text, canvas_offset)
{
this.color = color;
this.shadow_color = shadow_color;
this.mk_text = mk_text;
this.canvas_offset = canvas_offset;
this.radius = radius;
this.g = parent.append("g");
this.g.attr("transform", `translate(${this.canvas_offset}, ${this.canvas_offset})`);
this.g_labels = this.g.append("g")
.attr("text-anchor", "middle")
.attr("font-size", 16)
.attr("fill", this.color)
this.g_labels
.selectAll("text")
.data(d3.range(24))
.join("text")
.datum(d => ({a: 2 * Math.PI / 24 * d, deg: 15*d, text: this.mk_text(d)}) )
.text(a => a.text)
.attr("transform", a => `rotate(${180 + a.deg} 0 0)`)
.attr("x", 0)
.attr("y", -(this.radius + 8));
this.g.append("g")
.attr("stroke", color)
.selectAll("line")
.data(d3.range(96))
.join("line")
.datum(d => ({a:2 * Math.PI / 96 * d, l:(d & 3) == 0 ? 5 : 2.5}))
.attr("x1", x => this.radius * Math.cos(x.a))
.attr("x2", x => (this.radius + x.l) * Math.cos(x.a))
.attr("y1", x => this.radius * Math.sin(x.a))
.attr("y2", x => (this.radius + x.l) * Math.sin(x.a));
}
// separate cell so the chart itself doesn't become dependent on this
_TimeScale.prototype.update = function(rot)
{
this.g.attr("transform", `translate(${this.canvas_offset}, ${this.canvas_offset}) rotate(${rot * 360})`);
var shadow_color = this.shadow_color;
var ra = (rot * 2 * Math.PI);
function do_shadow(x)
{
var a = Math.PI + flip_pole(x.a) + 2 * Math.PI * rot;
return `${Math.sin(a)*1.4}px ${Math.cos(a)*1.4}px 0.5px ${shadow_color}`;
}
this.g_labels
.selectAll("text")
.attr("transform", x => `rotate(${180 + flip_pole(x.deg)} 0 0)`)
.style("text-shadow", do_shadow);
}
return _TimeScale;
}
Insert cell
function hand_with_length(p1, p2, projection, length)
{
var p1 = projection(p1);
var p2 = projection(p2);
var dx = p2[0] - p1[0];
var dy = p2[1] - p1[1];
var scale = length / Math.hypot(dx, dy);
return [p1, [p1[0] + scale * dx, p1[1] + scale * dy]];
}
Insert cell
function hand_marker()
{
return svg`
<marker id="hand-end" viewBox="0 0 30 4"
refX="1" refY="2"
markerUnits="strokeWidth"
markerWidth="30" markerHeight="3"
orient="auto">
<path d="M0 1.733333 C3 1.833333 5 1 10 0.5 C15 1.5 15 1.5 29.5 2 C15 2.5 15 2.5 10 3.5 C5 3 3 2.16666 0 2.26666"
stroke="black" fill="#eee" stroke-width=.8 stroke-linecap="butt"
stroke-linejoin="round"/>
</marker>`;
}
Insert cell
Insert cell
function update_moon_shape(circle, path, pos, size, border, phase)
{
circle
.attr("r", size - .5*border)
.attr("cx", pos[0])
.attr("cy", pos[1])
.attr("stroke", "black")
.attr("stroke-width", 1)
.attr("fill", "#222");
var size_1 = size - border;
var sin_th = Math.cos(Math.PI * 2 * phase);
var x1 = size_1 * (phase <= .5 ? -1 : sin_th);
var x2 = size_1 * (phase >= .5 ? 1 : -sin_th);
path
.attr("d",
`M${pos[0]} ${pos[1] - size_1}` +
` a ${Math.abs(x1)} ${size_1} 0 0 ${x1 <= 0 ? 0 : 1} 0 ${size_1*2}` +
` a ${Math.abs(x2)} ${size_1} 0 0 ${x2 > 0 ? 0 : 1} 0 ${-size_1*2}`)
.attr("stroke", "none")
.attr("fill", "#eeeacc");
}
Insert cell
Insert cell
Insert cell
function sun_shape() {
var rayP = [
// inner diameter
"L",
[10, 2.2],
"C", // inside ray curve
[13.3, 1.9],
[13.3, 1.2],
[15.5, 1.5],
"C", // outside ray curve
[13.5, -1.2],
[12.0, -1.3],
[10.0, -1.7]
];
var ray_path = [];
const N = 14;
for (var i = 0; i < N; ++i) {
var th = i/N * 2 * Math.PI;
var cs = Math.cos(th);
var sn = Math.sin(th);
rayP.forEach(p => {
if (typeof p === 'string') {
ray_path.push(p);
}
else {
var x = p[0] * cs + p[1] * sn + 16;
var y = p[0] * -sn + p[1] * cs + 16;
ray_path.push(x.toFixed(2) + "," + y.toFixed(2));
}
});
}
ray_path[0] = "M";
console.log(ray_path.join("\n"))
return svg`
<g>
<path
style="fill:#ffeecc;stroke:black;stroke-width:1;stroke-linejoin:round"
d="${ray_path.join(" ")}Z" />
<circle
style="fill:#ffd600;stroke:#ddb200;stroke-width:0.7;"
cx="16" cy="16" r="9" />
</g>`
}
Insert cell
Insert cell
import { range, dayOfYear, timeSliders, location, timeButtons, currentLocationButton} from "@roelandschoukens/inputs"
Insert cell
d3 = require("d3@6")
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more