Public
Edited
Apr 14
1 fork
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
width
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
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
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
midThisYear = doty2dote(yd[0], nDaysInYear / 20)
Insert cell
midNextYear = doty2dote(yd[0] + 1, nDaysNextYear / 20)
Insert cell
daysUntilMidThisYear = dz[0] - midThisYear[0]
Insert cell
daysUntilMidNextYear = dz[0] - midNextYear[0]
Insert cell
selectedDote = unix2dote(unix, selectedZone)
Insert cell
selectedDeco = dote2deco(...selectedDote, "0", true)
Insert cell
unixDeco = doty2deco(3, 182.1, 0, "0", true)
Insert cell
unixDecoM = doty2deco(1, 182.1, 0, "0", true, true)
Insert cell
selectedDecoM = dote2deco(...selectedDote, "0", true, true)
Insert cell
micro = `${yd0[0]}+${Math.floor(yd0[1] * 1e6)}`
Insert cell
selectedZone = long2zone(location[0])
Insert cell
serialize = {
const xmlns = "http://www.w3.org/2000/xmlns/";
const xlinkns = "http://www.w3.org/1999/xlink";
const svgns = "http://www.w3.org/2000/svg";
return function serialize(svg) {
svg = svg.cloneNode(true);
const fragment = window.location.href + "#";
const walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT);
while (walker.nextNode()) {
for (const attr of walker.currentNode.attributes) {
if (attr.value.includes(fragment)) {
attr.value = attr.value.replace(fragment, "#");
}
}
}
svg.setAttributeNS(xmlns, "xmlns", svgns);
svg.setAttributeNS(xmlns, "xmlns:xlink", xlinkns);
const serializer = new window.XMLSerializer;
const string = serializer.serializeToString(svg);
return new Blob([string], {type: "image/svg+xml"});
};
}
Insert cell
<style>
div.narrow {
margin-bottom: -35.25px;
font-size: 1.2em;
}
.pentbarlegend-swatch {
font-size: 19px;
}
</style>
Insert cell
move = {
d3.select(pentBar)
.select("div")
.raise() // Places swatch below the plot
.style("float", "right"); // Floats the swatch on the right.
}
Insert cell
function interval(range = [], options = {}) {
const [min = 0, max = 1] = range;
const {
step = .001,
label = null,
value = [min, max],
format = ([start, end]) => `${start} … ${end}`,
color,
width,
theme,
} = options;

const __ns__ = DOM.uid('scope').id;
const css = `
#${__ns__} {
font: 13px/1.2 var(--sans-serif);
display: flex;
align-items: baseline;
flex-wrap: wrap;
max-width: 100%;
width: auto;
}
@media only screen and (min-width: 30em) {
#${__ns__} {
flex-wrap: nowrap;
width: 360px;
}
}
#${__ns__} .label {
width: 60px;
padding: 5px 0 4px 0;
margin-right: .5px;
flex-shrink: 0;
}
#${__ns__} .form {
display: flex;
width: 100%;
}
#${__ns__} .range {
flex-shrink: 1;
width: 100%;
}
#${__ns__} .range-slider {
width: 100%;
}
`;
const $range = rangeInput({min, max, value: [value[0], value[1]], step, color, width, theme});
const $output = html`<output>`;
const $view = html`<div id=${__ns__}>
${label == null ? '' : html`<div class="label">${label}`}
<div class=form>
<div class=range>
${$range}<div class=range-output style="display: inline-block;">${$output}</div>
</div>
</div>
${html`<style>${css}`}
`;

const update = () => {
const content = format([$range.value[0], $range.value[1]]);
if(typeof content === 'string') $output.value = content;
else {
while($output.lastChild) $output.lastChild.remove();
$output.appendChild(content);
}
};
$range.oninput = update;
update();
return Object.defineProperty($view, 'value', {
get: () => $range.value,
set: ([a, b]) => {
$range.value = [a, b];
update();
},
});
}
Insert cell
cssLength = v => v == null ? null : typeof v === 'number' ? `${v}px` : `${v}`
Insert cell
theme_Flat = `
/* Options */
:scope {
color: #3b99fc;
width: 240px;
}

:scope {
position: relative;
display: inline-block;
--thumb-size: 15px;
--thumb-radius: calc(var(--thumb-size) / 2);
padding: var(--thumb-radius) 0;
margin: 2px;
vertical-align: middle;
}
:scope .range-track {
box-sizing: border-box;
position: relative;
height: 7px;
background-color: hsl(0, 0%, 80%);
overflow: visible;
border-radius: 4px;
padding: 0 var(--thumb-radius);
}
:scope .range-track-zone {
box-sizing: border-box;
position: relative;
}
:scope .range-select {
box-sizing: border-box;
position: relative;
left: var(--range-min);
width: calc(var(--range-max) - var(--range-min));
cursor: ew-resize;
background: currentColor;
height: 7px;
border: inherit;
}
/* Expands the hotspot area. */
:scope .range-select:before {
content: "";
position: absolute;
width: 100%;
height: var(--thumb-size);
left: 0;
top: calc(2px - var(--thumb-radius));
}
:scope .range-select:focus,
:scope .thumb:focus {
outline: none;
}
:scope .thumb {
box-sizing: border-box;
position: absolute;
width: var(--thumb-size);
height: var(--thumb-size);

background: #fcfcfc;
top: -4px;
border-radius: 100%;
border: 1px solid hsl(0,0%,55%);
cursor: default;
margin: 0;
}
:scope .thumb:active {
box-shadow: inset 0 var(--thumb-size) #0002;
}
:scope .thumb-min {
left: calc(-1px - var(--thumb-radius));
}
:scope .thumb-max {
right: calc(-1px - var(--thumb-radius));
}
`
Insert cell
function randomScope(prefix = 'scope-') {
return prefix + (performance.now() + Math.random()).toString(32).replace('.', '-');
}
Insert cell
function rangeInput(options = {}) {
const {
min = 0,
max = 100,
step = 'any',
value: defaultValue = [min, max],
color,
width,
theme = theme_Flat,
} = options;
const controls = {};
const scope = randomScope();
const clamp = (a, b, v) => v < a ? a : v > b ? b : v;
const html = htl.html;

// Will be used to sanitize values while avoiding floating point issues.
const input = html`<input type=range ${{min, max, step}}>`;
const dom = html`<div class=${`${scope} range-slider`} style=${{
color,
width: cssLength(width),
}}>
${controls.track = html`<div class="range-track">
${controls.zone = html`<div class="range-track-zone">
${controls.range = html`<div class="range-select" tabindex=0>
${controls.min = html`<div class="thumb thumb-min" tabindex=0>`}
${controls.max = html`<div class="thumb thumb-max" tabindex=0>`}
`}
`}
`}
${html`<style>${theme.replace(/:scope\b/g, '.'+scope)}`}
</div>`;

let value = [], changed = false;
Object.defineProperty(dom, 'value', {
get: () => [...value],
set: ([a, b]) => {
value = sanitize(a, b);
updateRange();
},
});

const sanitize = (a, b) => {
a = isNaN(a) ? min : ((input.value = a), input.valueAsNumber);
b = isNaN(b) ? max : ((input.value = b), input.valueAsNumber);
return [Math.min(a, b), Math.max(a, b)];
}
const updateRange = () => {
const ratio = v => (v - min) / (max - min);
dom.style.setProperty('--range-min', `${ratio(value[0]) * 100}%`);
dom.style.setProperty('--range-max', `${ratio(value[1]) * 100}%`);
};

const dispatch = name => {
dom.dispatchEvent(new Event(name, {bubbles: true}));
};
const setValue = (vmin, vmax) => {
const [pmin, pmax] = value;
value = sanitize(vmin, vmax);
updateRange();
// Only dispatch if values have changed.
if(pmin === value[0] && pmax === value[1]) return;
dispatch('input');
changed = true;
};
setValue(...defaultValue);
// Mousemove handlers.
const handlers = new Map([
[controls.min, (dt, ov) => {
const v = clamp(min, ov[1], ov[0] + dt * (max - min));
setValue(v, ov[1]);
}],
[controls.max, (dt, ov) => {
const v = clamp(ov[0], max, ov[1] + dt * (max - min));
setValue(ov[0], v);
}],
[controls.range, (dt, ov) => {
const d = ov[1] - ov[0];
const v = clamp(min, max - d, ov[0] + dt * (max - min));
setValue(v, v + d);
}],
]);
// Returns client offset object.
const pointer = e => e.touches ? e.touches[0] : e;
// Note: Chrome defaults "passive" for touch events to true.
const on = (e, fn) => e.split(' ').map(e => document.addEventListener(e, fn, {passive: false}));
const off = (e, fn) => e.split(' ').map(e => document.removeEventListener(e, fn, {passive: false}));
let initialX, initialV, target, dragging = false;
function handleDrag(e) {
// Gracefully handle exit and reentry of the viewport.
if(!e.buttons && !e.touches) {
handleDragStop();
return;
}
dragging = true;
const w = controls.zone.getBoundingClientRect().width;
e.preventDefault();
handlers.get(target)((pointer(e).clientX - initialX) / w, initialV);
}
function handleDragStop(e) {
off('mousemove touchmove', handleDrag);
off('mouseup touchend', handleDragStop);
if(changed) dispatch('change');
}
invalidation.then(handleDragStop);
dom.ontouchstart = dom.onmousedown = e => {
dragging = false;
changed = false;
if(!handlers.has(e.target)) return;
on('mousemove touchmove', handleDrag);
on('mouseup touchend', handleDragStop);
e.preventDefault();
e.stopPropagation();
target = e.target;
initialX = pointer(e).clientX;
initialV = value.slice();
};
controls.track.onclick = e => {
if(dragging) return;
changed = false;
const r = controls.zone.getBoundingClientRect();
const t = clamp(0, 1, (pointer(e).clientX - r.left) / r.width);
const v = min + t * (max - min);
const [vmin, vmax] = value, d = vmax - vmin;
if(v < vmin) setValue(v, v + d);
else if(v > vmax) setValue(v - d, v);
if(changed) dispatch('change');
};
return dom;
}
Insert cell
function formatDecimal(number) {
return number == 1 ? number : (Math.round(number * 100) / 100).toString().slice(1)
}
Insert cell
nested = Array.from({length: intervals.length}, (_, i) => ([
{
label: `${i} or ${i+5}`,
duration: intervals[i][1] !== intervals[i][0] ? intervals[i][0] : 1,
group: "Rest"
},
{
label: `${i} or ${i+5}`,
duration: intervals[i][1]-intervals[i][0],
group: "Work"
},
{
label: `${i} or ${i+5}`,
duration: intervals[i][1] !== intervals[i][0] ? 1-intervals[i][1] : null,
group: "Rest"
}]))
Insert cell
schedule3 = [
{label: "0 or 5", duration: 0.3, group: "Rest"},
{label: "0 or 5", duration: 0.4, group: "Work"},
{label: "0 or 5", duration: 0.3, group: "Rest"},
{label: "1 or 6", duration: 0.3, group: "Rest"},
{label: "1 or 6", duration: 0.4, group: "Work"},
{label: "1 or 6", duration: 0.3, group: "Rest"},
{label: "2 or 7", duration: 0.3, group: "Rest"},
{label: "2 or 7", duration: 0.4, group: "Work"},
{label: "2 or 7", duration: 0.3, group: "Rest"},
{label: "3 or 8", duration: 0, group: "Rest"},
{label: "3 or 8", duration: 0, group: "Work"},
{label: "3 or 8", duration: 1, group: "Rest"},
{label: "4 or 9", duration: 0, group: "Rest"},
{label: "4 or 9", duration: 0, group: "Work"},
{label: "4 or 9", duration: 1, group: "Rest"},
]
Insert cell
schedule2 = [
{label: "0 or 5", duration: 0.2, group: "Rest"},
{label: "0 or 5", duration: 0.6, group: "Work"},
{label: "0 or 5", duration: 0.2, group: "Rest"},
{label: "1 or 6", duration: 0.2, group: "Rest"},
{label: "1 or 6", duration: 0.6, group: "Work"},
{label: "1 or 6", duration: 0.2, group: "Rest"},
{label: "2 or 7", duration: 0, group: "Rest"},
{label: "2 or 7", duration: 0, group: "Work"},
{label: "2 or 7", duration: 1, group: "Rest"},
{label: "3 or 8", duration: 0, group: "Rest"},
{label: "3 or 8", duration: 0, group: "Work"},
{label: "3 or 8", duration: 1, group: "Rest"},
{label: "4 or 9", duration: 0, group: "Rest"},
{label: "4 or 9", duration: 0, group: "Work"},
{label: "4 or 9", duration: 1, group: "Rest"},
]
Insert cell
schedules = [
[[.2, .8], [.2, .8], [0, 0], [0, 0], [0, 0]],
[[.3, .7], [.3, .7], [.3, .7], [0, 0], [0, 0]],
[[.35, .65], [.35, .65], [.35, .65], [.35, .65], [0, 0]],
[[.38, .62], [.38, .62], [.38, .62], [.38, .62], [.38, .62]],
]
Insert cell
set(viewof intervals, schedules[schedule-2])
Insert cell
durations = [].concat(...nested)
Insert cell
// https://observablehq.com/@jeremiak/download-data-button
button = (data, filename = 'data.csv') => {
if (!data) throw new Error('Array of data required as first argument');

let downloadData;
if (filename.includes('.csv')) {
downloadData = new Blob([d3.csvFormat(data)], { type: "text/csv" });
} else {
downloadData = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json"
});
}

const size = (downloadData.size).toFixed(0);
const button = DOM.download(
downloadData,
filename,
`Download ${filename} (~${size} bytes)`
);
return button;
}
Insert cell
yearsUnix = 1969 + 306 / 365
Insert cell
diffYearsUnix = fracYear0 - yearsUnix
Insert cell
flooredDiffYearsUnix = Math.floor(diffYearsUnix)
Insert cell
moduloDiffYearsUnix = diffYearsUnix % 1
Insert cell
diffDoty = moduloDiffYearsUnix * nDaysInYear0 / 10
Insert cell
function year2dote(year = 0) {
const cote = Math.floor((year >= 0 ? year : year - 399) / 400),
yotc = year - cote * 400;
return cote * 146097 + yotc * 365 + Math.floor(yotc / 4) - Math.floor(yotc / 100)
}
Insert cell
lazyDote = unix2dote(Date.now())
Insert cell
numbers = Array.from({length: 366}, (_, i) => i)
Insert cell
lazyYear = dote2date(...lazyDote)[0]
Insert cell
lazyNdays = (365 + year2leap(lazyYear + 1))
Insert cell
set(viewof inputDoty, scrubberDoty)
Insert cell
// https://observablehq.com/@observablehq/synchronized-inputs
function set(input, value) {
input.value = value;
input.dispatchEvent(new Event("input", {bubbles: true}));
}
Insert cell
function doty2dotm(doty = 0) {
const m = Math.floor((5 * doty + 2) / 153);
return doty - Math.floor((153 * m + 2) / 5) + 1;
}
Insert cell
function month2doty(month = 1) {
return Math.floor(
(153 * (month > 2 ? month - 3 : month + 9) + 2) / 5
)}
Insert cell
new Date().getUTCFullYear() - (new Date().getMonth() < 3) + 1
Insert cell
function doty2month(doty = 0) {
const m = Math.floor((5 * doty + 2) / 153);
return Math.floor(m < 10 ? m + 3 : m - 9);
}
Insert cell
// https://observablehq.com/@juang1744/transform-input/1
transformInput = function(target, {bind: source, transform = identity, involutory = false, invert = involutory ? transform : inverse(transform)} = {}){
if (source === undefined) {
source = target;
target = html`<div>${source}</div>`;
}
function sourceInputHandler() {
target.removeEventListener("input", targetInputHandler);
setTransform(target).to(transform(source.value)).andDispatchEvent();
target.addEventListener("input", targetInputHandler);
}
function targetInputHandler() {
source.removeEventListener("input", sourceInputHandler);
setTransform(source).to(invert(target.value)).andDispatchEvent();
source.addEventListener("input", sourceInputHandler);
}
source.addEventListener("input", sourceInputHandler);
target.addEventListener("input", targetInputHandler);
invalidation.then(() => {
source.removeEventListener("input", sourceInputHandler);
target.removeEventListener("input", targetInputHandler);
});

sourceInputHandler();
return target;
}
Insert cell
setTransform = (input) => ({to: (value) => (input.value = value, {andDispatchEvent: (event = new Event("input")) => input.dispatchEvent(event)})});
Insert cell
function inverse(f) {
switch (f) {
case identity: return identity;
case Math.sqrt: return square;
case Math.log: return Math.exp;
case Math.exp: return Math.log;
default: return (x => solve(f, x, x));
}
function solve(f, y, x = 0) {
const dx = 1e-6;
let steps = 100, deltax, fx, dfx;
do {
fx = f(x)
dfx = (f(x + dx) - fx) || dx;
deltax = dx * (fx - y)/dfx
x -= deltax;
} while (Math.abs(deltax) > dx && --steps > 0);
return steps === 0 ? NaN : x;
}

function square(x) {
return x * x;
}
}
Insert cell
function identity(x) {
return x;
}
Insert cell
unix = {
while(true) {
yield Date.now();
}
}
Insert cell
function unix2dote1(unix, zone, offset = 719468) {
return [
(unix ?? Date.now()) / 86400000
+ (zone = zone ?? (10 - Math.round(
(new Date).getTimezoneOffset() / 144)) % 10
) / 10 + offset, zone]
}
Insert cell
function unix2dote(unix, zone, offset = 719468) {
return [(unix ?? Date.now()) / 86400000 + (
zone = zone ?? -Math.round(
(new Date).getTimezoneOffset() / 144)
) / 10 + offset, zone]
}
Insert cell
function dote2deco(dote = 719468, zone = 0, lead = "0", emoji = false, minus = false) {
let [year, doty] = dote2date(dote);
if (minus) { doty = Math.abs(doty - (365 + Number(year2leap(year + 1)))); }
return `${
(year + minus).toString().padStart(4, lead)}${minus ? "-" : "+"}${
Math.floor(doty).toString().padStart(3, lead)}${emoji ? "🗓️" : ""}${(doty % 1 * 10).toFixed(4)}${
zone != null ? (zone < 0 ? "+" : "-") + String(Math.abs(zone)) : ""}${emoji ? "🕰️" : ""}`
}
Insert cell
function doty2dote0(year = 1969, doty = 306) {
return doty + Math.floor(year * 365 + Math.floor(year / 4) - Math.floor(year / 100) + Math.floor(year / 400));
}
Insert cell
function doty2deco(year = 1969, doty = 306, zone = 0, lead = "0", emoji = false, minus = false) {
return dote2deco(doty2dote(year, doty)[0], zone, lead, emoji, minus);
}
Insert cell
function dote2date0(days = 719468, zone = 0) {
const cote = Math.floor((days >= 0 ? days : days - 146096) / 146097), dotc = days - cote * 146097,
yotc = Math.floor((dotc - Math.floor(dotc / 1460) + Math.floor(dotc / 36524) - Math.floor(dotc / 146096)) / 365);
return [yotc + cote * 400, dotc + Math.floor(yotc / 100) - yotc * 365 - Math.floor(yotc / 4), zone];
}
Insert cell
function dote2date(dote, zone = 0) {
const cote = Math.floor((
dote >= 0 ? dote
: dote - 146096
) / 146097),
dotc = dote - cote * 146097,
yotc = Math.floor((dotc
- Math.floor(dotc / 1460)
+ Math.floor(dotc / 36524)
- Math.floor(dotc / 146096)
) / 365);
return [
yotc + cote * 400,
dotc - (yotc * 365
+ Math.floor(yotc / 4)
- Math.floor(yotc / 100)
), zone]}
Insert cell
function dote2date1(dote = 719468, zone = 0) {
const cote = Math.floor((dote >= 0 ? dote : dote - 146096) / 146097),
dotc = dote - cote * 146097,
yotc = Math.floor((dotc - Math.floor(dotc / 1460) + Math.floor(dotc / 36524)
- Math.floor(dotc / 146096)) / 365);
return [yotc + cote * 400,
dotc - (yotc * 365 + Math.floor(yotc / 4) - Math.floor(yotc / 100)), zone];
}
Insert cell
function year2leap(year = 1970) {
return year % 4 == 0 && year % 100 != 0 || year % 400 == 0;
}
Insert cell
function doty2dote(year = 1969, doty = 306, zone = 0) {
const cote = Math.floor((year >= 0 ? year : year - 399) / 400),
yote = year - cote * 400;
return [cote * 146097 + yote * 365 + Math.floor(yote / 4) - Math.floor(yote / 100) + doty, zone]
}
Insert cell
solarDoty = solarTime(unix, location[0])
Insert cell
solarDeco = doty2deco(...solarDoty.slice(0, 2), null, "0", true)
Insert cell
solarDeco1 = doty2deco(...solarDoty.slice(0, 2), null, "0", true, true)
Insert cell
// I derived the function below from https://github.com/hypnos3/suncalc3#getting-solar-time
// https://github.com/Hypnos3/suncalc3/blob/master/suncalc.js#L780
// Looks like I ditched the original version of the function after this version:
// https://observablehq.com/@dec/alc@21363
// I created a new version of the function below in this version of the alc notebook:
// https://observablehq.com/@dec/alc@21383
// I updated the way longitude is included in this version of the alc notebook:
// https://observablehq.com/@dec/alc@27187
// I am not sure how well (if at all) I tested the function below
// 360 / 365 = 9.863, which matches a value in the wikipedia equation
// https://en.wikipedia.org/wiki/Equation_of_time
// (doty + 345) % 365 adjusts to dayOfYear - 81 in original function
// Math.PI / 180 converts to radians
// The function below is similar to this pysolar function:
// https://github.com/pingswept/pysolar/blob/master/pysolar/solar.py#L46
solarTime = function(unix, long) {
const dote = unix2dote(unix, 0)[0], [year, doty] = dote2date(dote),
b = 360 / 365 * ((Math.floor(doty) + 345) % 365) * Math.PI / 180;
return dote2date(dote + (
9.87 * Math.sin(2 * b)
- 7.53 * Math.cos(b)
- 1.5 * Math.sin(b)
) / 1440 + long2turn(long, 0));
};
Insert cell
// These are previous versions, in something approximately chronological order, of the solarTime function for recordkeeping purposes
// getSolarTime = function (dateValue, lng) {
// const date = new Date(dateValue);
// const start = new Date(date.getFullYear(), 0, 0);
// const diff = date.getTime() - start.getTime() + (start.getTimezoneOffset() - date.getTimezoneOffset()) * 60000;
// const dayOfYear = Math.floor(diff / 86400000);

// const b = 360 / 365 * (dayOfYear - 81) * Math.PI / 180;
// const equationOfTime = 9.87 * Math.sin(2 * b) - 7.53 * Math.cos(b) - 1.5 * Math.sin(b);
// const timeCorrection = equationOfTime + 4 * lng;
// const localSolarTime = date.getHours() + timeCorrection / 60 + date.getMinutes() / 60;

// const solarDate = new Date(0, 0);
// solarDate.setMinutes(+localSolarTime * 60);
// return solarDate.getUTCHours() / 2.4 + solarDate.getUTCMinutes() / 144 + solarDate.getUTCSeconds() / 8640 + solarDate.getUTCMilliseconds() / 8640000;
// };
// getTC = function (unix, long) {
// const [year, doty] = dote2doty(...unix2dote(unix, 4))
// const b = 360 / 365 * ((doty + 61 + greg2bool(year + 1)) % 365 - 81) * Math.PI / 180;
// return (9.87 * Math.sin(2 * b) - 7.53 * Math.cos(b) - 1.5 * Math.sin(b) + 4 * long) / 1440;
// };

// getSolarTime = function(unix, long) {
// const dote = unix2dote(unix, 4)[0], [year, doty] = dote2doty(dote),
// b = 360 / 365 * ((doty + 61 + greg2bool(year + 1)) % 365 - 81) * Math.PI / 180;
// return dote2doty(dote + (9.87 * Math.sin(2 * b) - 7.53 * Math.cos(b) - 1.5 * Math.sin(b) + 4 * long) / 1440);
// };

// getMySolarTime = function(unix, long) {
// const dote = unix2dote(unix, 4)[0], [year, doty] = dote2doty(dote),
// b = 360 / 365 * ((Math.floor(doty) + 345) % 365) * Math.PI / 180;
// return dote2doty(dote + (9.87 * Math.sin(2 * b) - 7.53 * Math.cos(b) - 1.5 * Math.sin(b) + 4 * long) / 1440);
// };

// solarTime = function(unix, long) {
// const dote = unix2dote(unix, 0)[0], [year, doty] = dote2doty(dote),
// b = 360 / 365 * ((Math.floor(doty) + 345) % 365) * Math.PI / 180;
// return dote2doty(dote + (
// 9.87 * Math.sin(2 * b) - 7.53 * Math.cos(b) - 1.5 * Math.sin(b) + 4 * long
// ) / 1440);
// };
//
// solarTime = function(unix, long) {
// const dote = unix2dote(unix, 0)[0], [year, doty] = dote2doty(dote),
// b = 360 / 365 * ((Math.floor(doty) + 345) % 365) * Math.PI / 180;
// return dote2doty(dote + (
// 9.87 * Math.sin(2 * b)
// - 7.53 * Math.cos(b)
// - 1.5 * Math.sin(b)
// + 4 * (((long %= 360) < 0 ? long + 360 : long) + 18)
// ) / 1440);
// };
Insert cell
// mysolar = function(doty) {
// const b = 360 / 365 * ((Math.floor(doty) + 345) % 365) * Math.PI / 180;
// return 9.87 * Math.sin(2 * b) - 7.53 * Math.cos(b) - 1.5 * Math.sin(b)
// };
Insert cell
// pysolar = function(day) {
// const b = 2 * Math.PI / 364.0 * (day - 81);
// return 9.87 * Math.sin(2 * b) - 7.53 * Math.cos(b) - 1.5 * Math.sin(b)
// }
Insert cell
dz = unix2dote(unix)
Insert cell
dz0 = unix2dote(unix, 0)
Insert cell
yd = dote2date(...dz)
Insert cell
yd0 = dote2date(...dz0)
Insert cell
fracYear = yd[0] + yd[1] * 10 / nDaysInYear
Insert cell
fracYear0 = yd0[0] + yd0[1] * 10 / nDaysInYear0
Insert cell
zone = dz[1]
Insert cell
function removeEmoji(arg) {
return arg.replace(/[^\p{N}\p{P}\p{Sm}\n]/gu, '')
}
Insert cell
decoP = removeEmoji(dote2deco(...dz))
Insert cell
decoM = removeEmoji(dote2deco(...dz, "0", false, true))
Insert cell
decoYearP = decoP.slice(0, 4)
Insert cell
decoYearM = decoM.slice(0, 4)
Insert cell
decoDateP = decoP.slice(5, 8)
Insert cell
decoDateM = decoM.slice(5, 8)
Insert cell
decoTimeP = removeEmoji(decoP.slice(8, 14))
Insert cell
decoTimeM = removeEmoji(decoM.slice(8, 14))
Insert cell
decoDateTimeP = removeEmoji(decoP.slice(5, 14))
Insert cell
decoDateTimeP1 = yd[1].toFixed(5)
Insert cell
decoDateTimeM1 = (nDaysInYear / 10 - yd[1]).toFixed(5)
Insert cell
nDaysInYear = (365 + year2leap(decoYearP + 1)) * 10
Insert cell
nDaysNextYear = (365 + year2leap(decoYearP + 2)) * 10
Insert cell
nDaysInYear0 = (365 + year2leap(yd0[0] + 1)) * 10
Insert cell
function long2zone(degrees = -180) {
return Math.floor(long2turn(degrees, 1));
}
Insert cell
function removeLeadingZeros(arg) {
return arg.split(/\+|(?=\d\.)/)
}
Insert cell
function getYotcSH(dote = 492633) {
const cote = Math.floor(dote / 12053);
const dotc = dote - cote * 12053;
return Math.floor((dotc - Math.floor(dotc / 1461)) / 365)
}
Insert cell
function getDotcSH(yotc = 0) {
return yotc * 365 + Math.trunc((yotc - 1) / 4)
}
Insert cell
Insert cell
graticule.coordinates = graticule.coordinates.map(
i => i.map(j => j.map((k, index, arr) => i.length === 3 && index === 0 ? k - 18 : k))
)
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
function long2zone1(degrees = -180) {
return ~~long2turn(degrees, 1);
}
Insert cell
function long2zone2(degrees = -180) {
return Math.round(long2long1(degrees, 1));
}
Insert cell
function long2turn(degrees = -180, e = 3) {
// turns: e=0, deciturns: e=1, etc.
return (((degrees %= 360) < 0 ? degrees + 360 : degrees) + 18) / (360 / 10**e) % 10**e;
}
Insert cell
function lati2turn(degrees = -180, e = 3) {
// turns: e=0, deciturns: e=1, etc.
return ((degrees %= 360) < 0 ? degrees + 360 : degrees) / (360 / 10**e) % 10**e;
}
Insert cell
function lati2turn1(degrees = -180, e = 3) {
// turns: e=0, deciturns: e=1, etc.
return (degrees %= 360) / (360 / 10**e) % 10**e;
}
Insert cell
function turn2long(turns = 0) {
return (((turns %= 1000) < 0 ? turns + 1000 : turns)) * .360 - 162;
}
Insert cell
function long2long1(degrees = -180, e = 0) {
return ((degrees %= 360) < 0 ? degrees + 360 : degrees) / (360 / 10**e) % 10**e;
}
Insert cell
// degrees to zone
// multiples of 9 degrees yield terminating decimal numbers
// [-180, -90, 0, 9, 18, 36, 45, 90, 162].map(long2zone1)
Insert cell
// degrees to zone
// multiples of 9 degrees yield terminating decimal numbers
// [-180, -90, 0, 9, 18, 36, 45, 90, 162].map(long2zone2)
Insert cell
Insert cell
function greg2doty(month = 1, day = 1) {
return Math.floor(
(153 * (month > 2 ? month - 3 : month + 9) + 2) / 5 + day - 1
)}
Insert cell
function date2doty(date) {
return greg2doty(date.getMonth() + 1, date.getDate())
}
Insert cell
function date2doty1(date) {
return greg2doty(date.getMonth() + 1, date.getDate())
}
Insert cell
// https://observablehq.com/@dbridges/visualizing-seasonal-daylight
globe = (root, { vizwidth, location, date, hour }) => {
const solarAngle = getSolarAngle(date);
const solarAngleDeg = toDegrees(solarAngle);
const hourSpin = 360 * ((hour + 12) / 24);
const spin = (180 + -location[0] + solarAngleDeg + hourSpin);
const tilt = -15;
const projection = d3.geoOrthographic()
.fitWidth(vizwidth, graticule)
.rotate([spin, tilt, 23.5]);
const path = d3.geoPath(projection);
const unClippedProjection = d3.geoOrthographic()
.clipAngle(null)
.fitWidth(vizwidth, graticule)
.rotate([spin, tilt, 23.5]);
const unClippedPath = d3.geoPath(unClippedProjection);
const staticProjection = d3.geoOrthographic()
.fitWidth(vizwidth, graticule)
.rotate([solarAngleDeg - 90, tilt]);
const staticPath = d3.geoPath(staticProjection);
const background = root.append("g");
const earth = root.append("g").style("opacity", 0.75);
const foreground = root.append("g");
earth.append("path")
.attr("d", path({type: "Sphere"}))
.attr("fill", colors.ocean)
.attr("stroke", "#9ecbda");
earth.append("path")
.attr("d", path(land))
.attr("fill", colors.land);
earth.append("path")
.attr("d", path(countries))
.attr("stroke-width", "1")
.attr("fill", "none")
.attr("stroke", "#000");
// root
// .append("text")
// .text(`${dote2deco(date.setUTCHours(0, 0, 0, 0) / 86400000 + 719468 + hour / 24 - 1, null, "0", true).slice(4)}`)
// .text(`${width}, ${vizwidth}`)
// .attr("x", vizwidth / 2)
// .attr("y", -6 + (width < 950) * 4)
// .attr("text-anchor", "middle")
// .attr("font-size", fontSize * (width < 950 ? 1.8 : 1.5))
// .attr("font-family", "monospace")
// .attr("fill", "black");
// root
// .append("text")
// .text(`${dote2deco(date.setUTCHours(0, 0, 0, 0) / 86400000 + 719468 + hour / 24, null, "0", true, true)}`)
// .attr("x", width / 2)
// .attr("y", -4 + (width < 400) * 1)
// .attr("text-anchor", "middle")
// .attr("font-size", fontSize * (width < 600 ? 1 : 1.2))
// .attr("font-family", "monospace")
// .attr("fill", "black");
background.append("path")
.attr("d", unClippedPath({type: "Point", coordinates: location}))
.attr("fill", "red");
const latitudeCoords = (latitude, start, end) => {
const longitudes = d3.range(start, end, 2).concat(end);
return longitudes.map(d => [d, latitude]);
}
const correctSpin = d3.geoRotation([-hourSpin, 0]);
const correctTilt = d3.geoRotation([6, 0, 0]);
/* total angular extent of day/night */
const dayExtent = 360 * dayLength(date, location[1]) / 24;
const nightExtent = 360 - dayExtent;
const dayLine = {
type: "LineString",
coordinates: latitudeCoords(location[1],
location[0] - dayExtent / 2,
location[0] + dayExtent / 2).map(d => correctSpin(d))
};
const nightLine = {
type: "LineString",
coordinates: latitudeCoords(location[1],
location[0] - dayExtent / 2 - nightExtent,
location[0] - dayExtent / 2).map(d => correctSpin(d))
};
background.append("path")
.attr("d", unClippedPath(dayLine))
.attr("fill", "none")
.attr("stroke", colors.day)
.attr("stroke-width", 3);
background.append("path")
.attr("d", unClippedPath(nightLine))
.attr("fill", "none")
.attr("stroke", colors.night)
.attr("stroke-width", 3);
foreground.append("path")
.attr("d", path(dayLine))
.attr("fill", "none")
.attr("stroke", colors.day)
.attr("stroke-width", 3);
foreground.append("path")
.attr("d", path(nightLine))
.attr("fill", "none")
.attr("stroke", colors.night)
.attr("stroke-width", 3);
foreground.append("path")
.attr("d", path({type: "Point", coordinates: location}))
.attr("stroke-width", .5)
.attr("stroke", "black")
.attr("fill", "red");
const shadowPolygon = [[0, -90], [0, 0], [0, 90], [180, 0], [0, -90]].map(d => correctTilt(d));
foreground.append("path")
.attr("d", staticPath({type: "Polygon", coordinates: [shadowPolygon]}))
.attr("fill", "rgba(0, 0, 0, 0.25)");
}
Insert cell
dayOfYear = (date) => {
const yearStart = new Date(date.getFullYear(), 0, 1+60);
return Math.floor((date.getTime() - yearStart.getTime())/86400000) + 1
}
Insert cell
// https://observablehq.com/@dbridges/visualizing-seasonal-daylight
daylightPlot = (
root,
{ vizwidth, height, year, latitude, defaultDate, defaultHour }
) => {
const margin = { top: 32, bottom: 32, left: 32, right: 0 };
const chartWidth = vizwidth - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;

const yTickValues =
width > 380 ? [3, 6, 9, 12, 15, 18, 21] : width > 90 ? [6, 12, 18] : [12];

const yScale = d3
.scaleLinear()
.domain([0, 24])
.range([margin.left, margin.left + chartWidth])
.clamp(true);

// y-axis scale
const xScale = d3
.scaleTime()
.domain([new Date(year, 0, 61), new Date(year, 11, 91)])
.range([margin.top, margin.top + chartHeight])
.clamp(true);

// y-axis labels
const xAxis = d3
.axisBottom(xScale)
.tickValues(d3.timeMonth.range(new Date(year, 0, 60), new Date(year, 12, 57)))
.tickSize(chartWidth)
.tickFormat(date2doty1);

const yAxis = d3
.axisLeft(yScale)
.tickValues(yTickValues)
.tickSize(chartHeight)
.tickFormat((d) => { return `${d / .024}` });


let date = defaultDate || new Date();
let hour = defaultHour != null ? defaultHour : date.getHours();

const handleMouseMove = (e) => {};

root
.append("rect")
.attr("y", margin.left)
.attr("x", margin.top)
.attr("height", chartWidth)
.attr("width", chartHeight)
.attr("ry", 0.05 * vizwidth)
.attr("fill", colors.night);

root
.append("g")
.attr("transform", `translate(0, ${margin.top})`)
.call(xAxis)
.call((g) => g.select(".domain").remove())
.call((g) => g.selectAll(".tick").attr("color", colors.grid))
.call((g) => g.selectAll(".tick text").attr("font-size", (width < 950 ? 1 : 1.1) * fontSize))
.call((g) => g.selectAll(".tick text").attr("color", "black"))
.call((g) => g.selectAll(".tick line").attr("stroke-dasharray", "5 3"));

root
.append("g")
.attr("transform", `translate(${margin.left + chartHeight}, 0)`)
.call(yAxis)
.call((g) => g.select(".domain").remove())
.call((g) => g.selectAll(".tick").attr("color", colors.grid))
.call((g) => g.selectAll(".tick text").attr("font-size", (width < 950 ? 1.2 : 1.1) * fontSize))
.call((g) => g.selectAll(".tick text").attr("color", "black"))
.call((g) => g.selectAll(".tick line").attr("stroke-dasharray", "5 3"));

root
.append("text")
.text("Time of day")
.attr("x", margin.left - chartWidth + (width < 950 ? 16 : 103))
.attr("y", margin.top - (width < 950 ? 22 : 42))//chartHeight / 2 + 5 + margin.bottom + (width < 600 ? 5 : width < 950 ? 8 : 20))
.attr("text-anchor", "middle")
.attr("font-size", fontSize * 1.2)
.attr("font-family", "sans-serif")
.attr("transform", "rotate(-90)")
.attr("fill", "black");

root
.append("text")
.text("Day of the year")
.attr("x", margin.left + (width < 400 ? chartWidth / 1.05 : width < 800 ? chartWidth / 1.05 : chartWidth))
.attr("y", margin.top + chartHeight / 2 + margin.bottom + (width < 400 ? 11 : width < 800 ? -20 : 20))
.attr("text-anchor", "middle")
.attr("font-size", fontSize * 1.2)
.attr("font-family", "sans-serif")
.attr("fill", "black");

const data = yearDates(year)
.map((d) => [d, dayLength(d, latitude)])
.filter(([_, d]) => d > 0);

/* Render separate polygons for each continuous sequence of
* days with more than 0 hours of day light
*/
const polys = [];
let currentPoly = [];

for (let i = 0; i < data.length; i++) {
const currentDate = data[i][0];
const prevDate = (data[i - 1] || [])[0];

if (
i === 0 ||
currentDate.getTime() - prevDate.getTime() < 3600 * 24 * 1000 * 1.5
) {
currentPoly.push(data[i]);
} else {
polys.push(currentPoly);
currentPoly = [data[i]];
}
}

polys.push(currentPoly);

polys.forEach((p) => {
const points = [
...p.map(([d, l]) => `${xScale(d)},${yScale(12 - l / 2)}`),
...p.reverse().map(([d, l]) => `${xScale(d)},${yScale(12 + l / 2)}`)
].join(" ");

root.append("polygon").attr("points", points).attr("fill", colors.day);
});

/* Legend */
const legend = root
.append("g")
.attr("transform", `translate(${margin.left + chartWidth / 2 - 64})`);

legend
.append("rect")
.attr("x", width < 950 ? 50 : -100)
.attr("y", width < 950 ? 17 : 4)
.attr("rx", 5)
.attr("width", 1.4 * fontSize)
.attr("height", 1.4 * fontSize)
.attr("fill", colors.day);

legend
.append("text")
.attr("x", width < 950 ? 62 : -72)
.attr("y", width < 950 ? 26.5 : 26)
.attr("font-size", 1.4 * fontSize)
.attr("font-family", "sans-serif")
.text("Day");

legend
.append("rect")
.attr("x", width < 950 ? 10 : -25)
.attr("y", width < 950 ? 17 : 4)
.attr("rx", 5)
.attr("width", 1.4 * fontSize)
.attr("height", 1.4 * fontSize)
.attr("fill", colors.night);

legend
.append("text")
.attr("x", width < 950 ? 22 : 3)
.attr("y", width < 950 ? 26.5 : 26)
.attr("font-size", 1.4 * fontSize)
.attr("font-family", "sans-serif")
.text("Night");

/* Time and date controls */

const dateLine = root.append("g");

const updateControlPositions = () => {
dateLine
.select("line")
.attr("y1", yScale(0))
.attr("x1", xScale(date))
.attr("y2", yScale(24))
.attr("x2", xScale(date));

dateLine
.select("rect")
.attr("y", yScale(0))
.attr("x", xScale(date) - 4);

root
.select("#time-control")
.attr("cy", yScale(hour))
.attr("cx", xScale(date));
};

const dispatchDateHourChange = () => {
const detail = { date, hour };
const changeEvent = new CustomEvent(EventType.DateHourChange, {
detail,
bubbles: true
});
root.node().dispatchEvent(changeEvent);
};

const handleDateLineDrag = ({ x }) => {
date = xScale.invert(x);
updateControlPositions();
dispatchDateHourChange();
};

const handleTimeCircleDrag = ({ y }) => {
hour = yScale.invert(y);
updateControlPositions();
dispatchDateHourChange();
};

dateLine.append("line").attr("stroke-width", 4).attr("stroke", "red");

dateLine
.append("rect")
.attr("height", chartWidth)
.attr("width", 8)
.attr("fill", "rgba(0, 0, 0, 0)")
.style("cursor", "row-resize")
.call(d3.drag().on("drag", handleDateLineDrag));

root
.append("circle")
.attr("id", "time-control")
.attr("r", 12)
.attr("fill", "red")
.attr("stroke-width", .6)
.attr("stroke", "black")
.style("cursor", "pointer")
.call(d3.drag().on("drag", handleTimeCircleDrag));

updateControlPositions();
}
Insert cell
fontSize = width < 600 ? 8 : width < 950 ? 14 : 18;
Insert cell
getSolarAngle = (date) => (dayOfYear(date) + 10) / 365 * Math.PI * 2 - Math.PI / 2;
Insert cell
/*
* Formulas uses the CBM model as reviewed here:
* https://www.ikhebeenvraag.be/mediastorage/FSDocument/171/Forsythe+-+A+model+comparison+for+daylength+as+a+function+of+latitude+and+day+of+year+-+1995.pdf
*/
dayLength = (date, latitude) => {
const yearStart = new Date(date.getFullYear(), 0, 1);
const dayOfYear = Math.floor((date.getTime() - yearStart.getTime())/86400000) + 1;
const revAngle = 0.2163108 + 2 * Math.atan(0.9671396 * Math.tan(0.00860 * (dayOfYear - 186)));
const decAngle = Math.asin(0.39795 * Math.cos(revAngle));
/* daylight coefficient selected for apparent sunrise/sunset */
const p = 0.8333

const intResult =
(Math.sin((p * Math.PI) / 180) +
Math.sin((latitude * Math.PI) / 180) * Math.sin(decAngle)) /
(Math.cos((latitude * Math.PI) / 180) * Math.cos(decAngle));
if (intResult >= 1) return 24;
if (intResult <= -1) return 0;
return 24 - 24 * Math.acos(intResult) / Math.PI;
}
Insert cell
yearDates = (year) => {
const startDate = new Date(year, 0, 1+60);
const endDate = new Date(year + 1, 0, 1+60);
return d3.timeDay.range(startDate, endDate);
}
Insert cell
height = (width < 600 ? .74 : width < 950 ? .64 : 0.54) * width;
Insert cell
EventType = ({
LocationChange: "LOCATION_CHANGE",
DateHourChange: "DATE_HOUR_CHANGE"
})
Insert cell
colors = ({
night: "#719fb6",
day: "#ffe438",
grid: "#4b6a79",
ocean: "#adeeff",
land: "#f5f1dc",
sun: "#ffe438"
})
Insert cell
toRadians = (val) => val * Math.PI / 180
Insert cell
toDegrees = (val) => val * 180 / Math.PI;
Insert cell
land = topojson.feature(world, world.objects.land);
Insert cell
d3 = require("d3@7", "d3-geo-projection@3")
Insert cell
function input(config) {
let {
form,
type = "text",
attributes = {},
action,
getValue,
title,
description,
format,
display,
submit,
options
} = config;
const wrapper = html`<div></div>`;
if (!form)
form = html`<form>
<input name=input type=${type} />
</form>`;
Object.keys(attributes).forEach(key => {
const val = attributes[key];
if (val != null) form.input.setAttribute(key, val);
});
if (submit)
form.append(
html`<input name=submit type=submit style="margin: 0 0.75em" value="${
typeof submit == "string" ? submit : "Submit"
}" />`
);
form.append(
html`<output name=output style="font: 14px Menlo, Consolas, monospace; margin-left: 0.1em; text-align:center;"></output>`
);
if (title)
form.prepend(
html`<div style="font: 700 0.9rem sans-serif; margin-bottom: 3px;">${title}</div>`
);
if (description)
form.append(
html`<div style="font-size: 0.85rem; font-style: italic; margin-top: 3px;">${description}</div>`
);
if (format)
format = typeof format === "function" ? format : d3format.format(format);
if (action) {
action(form);
} else {
const verb = submit
? "onsubmit"
: type == "button"
? "onclick"
: type == "checkbox" || type == "radio"
? "onchange"
: "oninput";
form[verb] = e => {
e && e.preventDefault();
const value = getValue ? getValue(form.input) : form.input.value;
if (form.output) {
const out = display ? display(value) : format ? format(value) : value;
if (out instanceof window.Element) {
while (form.output.hasChildNodes()) {
form.output.removeChild(form.output.lastChild);
}
form.output.append(out);
} else {
form.output.value = out;
}
}
form.value = value;
if (verb !== "oninput")
form.dispatchEvent(new CustomEvent("input", { bubbles: true }));
};
if (verb !== "oninput")
wrapper.oninput = e => e && e.stopPropagation() && e.preventDefault();
if (verb !== "onsubmit") form.onsubmit = e => e && e.preventDefault();
form[verb]();
}
while (form.childNodes.length) {
wrapper.appendChild(form.childNodes[0]);
}
form.append(wrapper);
return form;
}
Insert cell
d3format = require("d3-format@1")
Insert cell
// https://observablehq.com/@enjalot/draggable-world-map-coordinates-input
function worldMapCoordinates(config = {}, dimensions) {
const {
value = [], title, description, width = dimensions[0]
} = Array.isArray(config) ? {value: config} : config;
const height = dimensions[1];
let [lon, lat] = value;
lon = lon != null ? lon : null;
lat = lat != null ? lat : null;
const formEl = html`<form style="width: ${width}px;"></form>`;
const context = DOM.context2d(width, height-width/11.5);
const canvas = context.canvas;
canvas.style.margin = `-21px 0 ${width < 400 ? -48 : width < 800 ? -86 : -90}px`;
const projection = d3
.geoEquirectangular()
.precision(0.1)
.fitSize([width, height], { type: "Sphere" }).rotate([-153, 0]);
const path = d3.geoPath(projection, context).pointRadius(2.5);
formEl.append(canvas);

function draw() {
context.fillStyle = "#fff";
context.fillRect(0, 0, width, height);
context.beginPath(); path({type: "Sphere"});
context.fillStyle = colors.ocean; context.fill();
context.beginPath();
path(graticule);
context.lineWidth = 0.95;
context.strokeStyle = `#aaa`;
context.stroke();
context.beginPath();
path(land);
context.fillStyle = colors.land;
context.fill();
context.beginPath();
path(countries);
context.lineWidth = .95;
context.strokeStyle = `#000`;
context.stroke();
context.fillStyle = `#000`;
context.font = width < 760 ? "12px serif" : width < 990 ? "11.6px serif" : "18px serif";
d3.range(-1.5, 342 + 1, 36).map(x => context.fillText(long2zone(x), ...projection([x, 84.5 - (width < 400) * 3.6])));
d3.range(-1.5, 342 + 1, 36).map(x => context.fillText(long2zone(x), ...projection([x, -62])));
context.beginPath(), path(night), context.fillStyle = "rgba(0,0,255,0.1)", context.fill();
context.beginPath(); path.pointRadius(17); path({type: "Point", coordinates: sun}); context.strokeStyle = "#0008"; context.fillStyle = "#ff08"; context.lineWidth = 1; context.stroke(); context.fill();
if (lon != null && lat != null) {
path.pointRadius(17); context.strokeStyle = "black";
context.beginPath(); path({type: "Point", coordinates: [lon, lat]}); context.lineWidth = 1; context.stroke();
context.lineWidth = 6;
path.pointRadius(14); context.strokeStyle = "red";
context.beginPath(); path({type: "Point", coordinates: [lon, lat]}); context.stroke();
}
}

let drag = d3.drag()
.on("drag", (event) => {
let coords = projection.invert([event.x, event.y]);
lon = +coords[0].toFixed(2);
lat = +coords[1].toFixed(2);
draw();
canvas.dispatchEvent(new CustomEvent("input", { bubbles: true }));
})

d3.select(canvas).call(drag)

canvas.onclick = function(ev) {
const { offsetX, offsetY } = ev;
let coords = projection.invert([offsetX, offsetY]);
lon = +coords[0].toFixed(2);
lat = +coords[1].toFixed(2);
draw();
canvas.dispatchEvent(new CustomEvent("input", { bubbles: true }));
};

draw();

const form = input({
type: "worldMapCoordinates",
title,
description,
display: v => (width > 400) ? html`<div style="width: ${width}px; white-space: nowrap; color: #444; text-align: center; font: 18px monospace; position: relative; top: 4.8em; margin-bottom: 43px;">
<span style="color: #000;">Zone:</span> ${lon != null ? long2zone(lon) : ""}
&nbsp; &nbsp;
<span style="color: #000;">Longitude:</span> ${lon != null ? (long2turn(lon)).toFixed(0) : ""}
&nbsp; &nbsp;
<span style="color: #000;">Latitude:</span> ${lat != null ? ((lati2turn1(lat))).toFixed(0) : ""}
</div>` : '',
getValue: () => [lon != null ? lon : null, lat != null ? lat : null],
form: formEl
});
return form;
}
Insert cell
sun = {
const now = new Date;
const day = new Date(+now).setUTCHours(0, 0, 0, 0);
const t = solar.century(now);
const longitude = (day - now) / 864e5 * 360 - 180;
return [longitude - solar.equationOfTime(t) / 4, solar.declination(t)];
}
Insert cell
night = d3.geoCircle()
.radius(90)
.center(antipode(sun))
()
Insert cell
antipode = ([longitude, latitude]) => [longitude + 180, -latitude]
Insert cell
solar = require("solar-calculator@0.3/dist/solar-calculator.min.js")
Insert cell
// https://observablehq.com/@mbostock/scrubber
function Scrubber(values, {
format = value => value,
initial = 0,
direction = 1,
delay = null,
autoplay = true,
loop = true,
loopDelay = null,
alternate = false,
inputStyle = ""
} = {}) {
values = Array.from(values);
const form = html`<form style="font: 18px var(--monospace); font-variant-numeric: tabular-nums; display: flex; height: 33px; align-items: center;">
<button name=b type=button style="margin-right: 0.4em; width: 5em;"></button>
<label style="display: flex; align-items: center;">
<input name=i type=range min=0 max=${values.length - 1} value=${initial} step=1 style=${inputStyle}>
<output name=o style="margin-left: 0.4em;"></output>
</label>
</form>`;
let frame = null;
let timer = null;
let interval = null;
function start() {
form.b.textContent = "Pause";
if (delay === null) frame = requestAnimationFrame(tick);
else interval = setInterval(tick, delay);
}
function stop() {
form.b.textContent = "Play";
if (frame !== null) cancelAnimationFrame(frame), frame = null;
if (timer !== null) clearTimeout(timer), timer = null;
if (interval !== null) clearInterval(interval), interval = null;
}
function running() {
return frame !== null || timer !== null || interval !== null;
}
function tick() {
if (form.i.valueAsNumber === (direction > 0 ? values.length - 1 : direction < 0 ? 0 : NaN)) {
if (!loop) return stop();
if (alternate) direction = -direction;
if (loopDelay !== null) {
if (frame !== null) cancelAnimationFrame(frame), frame = null;
if (interval !== null) clearInterval(interval), interval = null;
timer = setTimeout(() => (step(), start()), loopDelay);
return;
}
}
if (delay === null) frame = requestAnimationFrame(tick);
step();
}
function step() {
form.i.valueAsNumber = (form.i.valueAsNumber + direction + values.length) % values.length;
form.i.dispatchEvent(new CustomEvent("input", {bubbles: true}));
}
form.i.oninput = event => {
if (event && event.isTrusted && running()) stop();
form.value = values[form.i.valueAsNumber];
form.o.value = format(form.value, form.i.valueAsNumber, values);
};
form.b.onclick = () => {
if (running()) return stop();
direction = alternate && form.i.valueAsNumber === values.length - 1 ? -1 : 1;
form.i.valueAsNumber = (form.i.valueAsNumber + direction) % values.length;
form.i.dispatchEvent(new CustomEvent("input", {bubbles: true}));
start();
};
form.i.oninput();
if (autoplay) start();
else stop();
Inputs.disposal(form).then(stop);
return form;
}
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