Public
Edited
Jul 24, 2024
1 star
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
save_animation = {
if (!save_animation_btn) return "";

let v = (viewof time_now);
var t = new Date(v.value.date);
t.setSeconds(t.getSeconds() + 30);
v.value = t;
v.dispatchEvent(new CustomEvent("input", {bubbles:true}));
let downloadLink = document.createElement('a');
var iStr = ("000"+frame_counter).slice(-3);
var name = 'sun-strike-frame'+iStr+'.png';
downloadLink.setAttribute('download', name);
let canvas = chart.context.canvas;
let dataURL = canvas.toDataURL('image/png');
let url = dataURL.replace(/^data:image\/png/,'data:application/octet-stream');
downloadLink.setAttribute('href', url);
downloadLink.click();
return name;
}
Insert cell
Insert cell
chart = Chart();
Insert cell
async function Chart()
{
const div = d3.create("div");
div.style("overflow", "hidden");
div.style("background", "#888");

var svg_el = div.append(() => svg`<svg width="${2 * sky_overlay_size + 10}" height="${2 * sky_overlay_size + 10}"
viewbox="0 0 ${2 * sky_overlay_size + 10} ${2 * sky_overlay_size + 10}"
style="position: absolute; top: 28px; z-index: 1;">
<defs>
</defs>`);

const context = DOM.context2d(chart_size[0], chart_size[1]);
const canvas = context.canvas;
canvas.style.transformOrigin = "top left";
div.append(() => canvas);

var k = 1;
var transform_timeout = undefined;
const ourObj = {
node:div.node(), context:context};

// callback for zoom and pan
ourObj.set_transform_callback = function(f)
{
ourObj.transform_callback = f;
if (f)
{
f(d3.zoomTransform(ourObj.svg.node()), ourObj);
invalidation.then(() => clearTimeout(transform_timeout));
}
}
ourObj.zoomTransform = function()
{
return d3.zoomTransform(ourObj.svg.node());
}
function zoomed({transform}) {
mutable map_transform = transform;
ourObj.map_rect = [
transform.x + transform.k * chart_content_rect[0][0],
transform.y + transform.k * chart_content_rect[0][1],
transform.k * chart_content_rect[1][0],
transform.k * chart_content_rect[1][1]];
}

function zoomed_end({transform})
{
// update list of definitions after a delay
// (updating patterns is potentially slow)
if (ourObj.transform_callback)
{
clearTimeout(transform_timeout);
transform_timeout = setTimeout(function() {
ourObj.transform_callback(transform, ourObj)
transform_timeout = undefined;
}, 1000);
}
}
// zoom behaviour
var zoom = d3.zoom()
.extent([[0, 0], chart_size])
// ideally we would exclude the areas outside the clipping box but
// that doesn’t work well.
.translateExtent([[0, 0], chart_size])
.scaleExtent([1, max_zoom * map_size[0] / width])
.on("zoom", zoomed)
.on("end", zoomed_end);
div.call(zoom);
zoomed({transform: {k:1, x:0, y:0}});
return ourObj;
}
Insert cell
// this cell refreshes when the map is zoomed or panned
mutable map_transform = {return {k:1, x:0, y:0}}
Insert cell
bg_canvas_ctx =
{
const ctx = DOM.context2d(chart_size[0], chart_size[1]);
var path = mkpath(ctx, chart.map_rect);

prepare_canvas(ctx);

// roads
ctx.lineWidth = 0.5 + 0.5 * map_transform.k;
for (var f of road_collection.features)
{
ctx.beginPath();
ctx.strokeStyle ='black';
path(f);
ctx.stroke();
}

ctx.restore();
ctx.restore();
return ctx;
}
Insert cell
chart_update = {
// basemap
var t_shade = 2 * sun_vec[2];

var ctx = chart.context;
var canvas = ctx.canvas;
var path = mkpath(ctx, chart.map_rect);
prepare_canvas(ctx);
var land_color = land_shade(t_shade);
ctx.fillStyle = land_color;
ctx.fill();

ctx.beginPath();
path(water_collection);
ctx.lineWidth = 1;
ctx.fillStyle = water_shade(t_shade);
ctx.strokeStyle = water_edge_shade(t_shade);
ctx.fill();
ctx.stroke();
// glare
const glare_colors = ['#7cf', '#ffa'];
for (var mode = wet_mode ? 0 : 1; mode < 2; ++mode) {
ctx.strokeStyle = d3.interpolate(land_color, glare_colors[mode])(.6);

for (var f of road_collection.features)
{
var misery = sun_strike_misery(f, mode == 0);
if (misery > .5)
{
ctx.beginPath();
ctx.lineWidth = 5 * (map_transform.k + 1) * (misery - .2);
path(f);
ctx.stroke();
}
}
}

// roads base layer (undo the scaling we got from DOM.context2d)
ctx.save();
ctx.resetTransform();
ctx.drawImage(bg_canvas_ctx.canvas, 0, 0, canvas.width, canvas.height);
ctx.restore();

// roads
ctx.lineWidth = 1 + 0.5 * map_transform.k;
for (var f of road_collection.features)
{
var misery = sun_strike_misery(f, wet_mode);
if (misery > 0)
{
ctx.beginPath();
ctx.strokeStyle = sun_strike_shade(misery);
path(f);
ctx.stroke();
}
}

ctx.restore(); // this removes the clip path
// text label
ctx.font = "18px 'Source Serif Pro', serif";
ctx.textBaseline = "top";
var mt = ctx.measureText(now_text_weekday);
ctx.fillStyle = "#0007";
ctx.fillRect(0, 0, mt.width + 10, 8 + 18);
ctx.fillStyle = "#fff";
ctx.fillText(now_text_weekday, 5, 4);

// sky circle overlay
const r = 40;
ctx.transform(1, 0, 0, 1, 5 + r, 30 + r);

ctx.beginPath();
ctx.arc(0, 0, r, 2 * Math.PI, false);
ctx.fillStyle = water_shade(t_shade * 2 + 0.5)
ctx.strokeStyle = "#000";
ctx.fill();
ctx.stroke();

if (sun_position.altitude > 0)
{
var r_sun = (1 - sun_position.altitude * 2 / Math.PI) * (r - 5);
var x = -Math.sin(sun_position.azimuth) * r_sun;
var y = Math.cos(sun_position.azimuth) * r_sun;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(x, y);
ctx.strokeStyle = "#ffa"
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, 5, 2 * Math.PI, false);
ctx.fillStyle = "#fc4"
ctx.strokeStyle = "#a50";
ctx.fill();
ctx.stroke();
}
ctx.font = "14px 'Source Serif Pro', serif";
var deg_txt = (sun_position.altitude * 180 / Math.PI).toFixed(1);
var mt = ctx.measureText(deg_txt);
ctx.fillStyle = "#000";
ctx.fillText(deg_txt + '°', -mt.width / 2, lon_lat[1] > 0 ? -r+4 : r-15);

ctx.restore();
}
Insert cell
// common stuff to set up a canvas context
// this saves the state _twice_.
function prepare_canvas(ctx)
{
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";

ctx.save();
ctx.beginPath();
var cr = chart.map_rect;
ctx.rect(cr[0], cr[1], cr[2], cr[3]);
ctx.clip();
}
Insert cell
chart_size = {
var h = Math.round(width * map_size[1] / map_size[0]);
if (h < chart_max_h) return [width, h];
return [width, chart_max_h];
}
Insert cell
chart_content_rect = {
if (chart_size[1] < chart_max_h) return [[0, 0], chart_size];
var w = Math.round(chart_max_h * map_size[0] / map_size[1]);
return [[(chart_size[0] - w) / 2, 0], [w, chart_size[1]]];
}
Insert cell
chart_max_h = 650
Insert cell
// pixels / metre
max_zoom = 0.2
Insert cell
Insert cell
land_shade = x => d3.interpolate("#234", "#aaa")(Math.min(1, Math.max(0, x)))
Insert cell
water_shade = x => d3.interpolate("#384960", "#bdf")(Math.min(1, Math.max(0, x)))
Insert cell
water_edge_shade = x => d3.interpolate("#012", "#468")(Math.min(1, Math.max(0, x)))
Insert cell
Insert cell
sky_overlay_size = Math.min(60, Math.floor(width / 8));
Insert cell
Insert cell
sun_strike_shade = d3.interpolateInferno
Insert cell
sun_strike_misery = {

// we can approximately check the distance between unit vectors against
// a reference
const h_wet = 1 / distance_unit_vectors(15);
const v_wet = 1 / distance_unit_vectors(40);
const h_dry = 1 / distance_unit_vectors(10);
const v_dry = 1 / distance_unit_vectors(5);
function distance_unit_vectors(angle_deg) {
// distance between two unit vectors is 2 sin(angle / 2).
return 2 * Math.sin(angle_deg * Math.PI/360);
}
function sun_strike_misery_1(vec, wet_mode)
{
var t = 0;
// approximate check if the sun is under the current hill (2° tolerance)
if (sun_vec[2] > vec[2] - 0.0349)
{
// 0 to 1 misery value
const h = wet_mode ? h_wet : h_dry;
const v = wet_mode ? v_wet : v_dry;
const dist = Math.hypot(
h * (vec[0] - sun_vec[0]),
h * (vec[1] - sun_vec[1]),
v * (vec[2] - sun_vec[2]));
t = Math.max(0, 1 - dist);
}
return t;
}
return function (f, wet_mode)
{
// check horizon
if (sun_vec[2] < 0) return 0;
// for flat roads we will have to check both directions
// reject very short segments
if (f.properties.length < 20) { return 0; }
const vec = f.properties.vec;
if (!vec) return 0;
var t = Math.max(
sun_strike_misery_1(vec, wet_mode),
sun_strike_misery_1([-vec[0], -vec[1], -vec[2]], wet_mode));
if (!wet_mode) {
// For dry mode, square value since straight ahead is really annoying
// Wet conditions create a much larger range where sun strike is terrible.
t = t * t;
}
// fade to zero 0.5° from sunset
t *= Math.min(1.0, 114.7 * sun_vec[2])
return t;
}
}
Insert cell
Insert cell
function calc_time(now, key, offset)
{
var t = SunCalc.getTimes(now, lon_lat[1], lon_lat[0])[key];
// the returned value has low precision. Go up to at least 0.4°.
while (SunCalc.getPosition(t, lon_lat[1], lon_lat[0]).altitude < 0.00698) {
t = new Date(t.getTime() + offset);
}
return t;
}
Insert cell
function set_time_delta(offset)
{
var now_utc = (viewof time_now).value.utc;
(viewof time_now).value = new Date(now_utc + offset);
(viewof time_now).dispatchEvent(new CustomEvent("input", {bubbles:true}));
}
Insert cell
utc_date_format = Intl.DateTimeFormat(undefined, {
timeStyle: "short",
dateStyle: "medium",
timeZone: "UTC"
})
Insert cell
utc_date_format_week = Intl.DateTimeFormat(undefined, {
weekday: "short",
timeZone: "UTC"
})
Insert cell
now_text = utc_date_format.format(time_now.local)
Insert cell
now_text_weekday = utc_date_format_week.format(time_now.local) + " " + now_text
Insert cell
Insert cell
lon_lat = [174.7, -36.8]
Insert cell
sun_position = SunCalc.getPosition(time_now.date, lon_lat[1], lon_lat[0]);
Insert cell
sun_vec = {
var vz = Math.sin(sun_position.altitude);
var r = Math.cos(sun_position.altitude);
var vx = -Math.sin(sun_position.azimuth) * r;
var vy = -Math.cos(sun_position.azimuth) * r;
return [vx, vy, vz];
}
Insert cell
Insert cell
map_size = [39037, 44237]
Insert cell
map_bounds = {
var x1 = 1735405;
var y1 = 5894629;
var x2 = x1 + map_size[0];
var y2 = y1 + map_size[1];
return[[x1, y1], [x2, y2]];
}
Insert cell
map_bounds_path = ({
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
map_bounds[0],
[map_bounds[0][0],map_bounds[1][1]],
map_bounds[1],
[map_bounds[1][0], map_bounds[0][1]],
map_bounds[0]
]
]
},
"properties": {}
});
Insert cell
road_collection = {
var c = await d3.json(await FileAttachment("roads-elevation@2.geojson").url());
// for every road segment, calculate the 3D direction vector
for (var f of c.features) {
// we’re assuming here that northing actually points north. The segments are short enough
// that we’ll assume they are straight.
var sl = f.properties.slope || 0;
var cc = f.geometry.coordinates;
var p1 = cc[0];
var p2 = cc[cc.length - 1];
var dx = p2[0] - p1[0];
var dy = p2[1] - p1[1];
var dz = sl * Math.hypot(dx, dy);
var scale = 1 / Math.hypot(dx, dy, dz);
f.properties.vec = [dx * scale, dy * scale, dz * scale];
}
return c;
}
Insert cell
water_collection = d3.json(await FileAttachment("water@2.geojson").url())
Insert cell
function mkpath(context, rect)
{
var ext = [
[rect[0], rect[1]],
[rect[0] + rect[2], rect[1] + rect[3]]
];
return d3.geoPath(projection.fitExtent(ext, map_bounds_path), context);
}
Insert cell
// the image is projected exactly to the SVG area
projection = d3.geoIdentity().reflectY(true).fitSize(chart_size, map_bounds_path);
Insert cell
Insert cell
SunCalc = require('suncalc')
Insert cell
import {timeSliders, timeButtons} from "@roelandschoukens/inputs"
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