html`<style>
.tickLabel, .tickLabel1, .tickLabel2, .timeLabel {
fill: #000;
font-family: sans-serif;
font-size: 20px;
text-anchor: middle;
}
.timeLabel {
text-anchor: start;
}
.timeBar, .timeBarFull {
x: 1px;
height: 25px;
rx: 5px;
stroke: #aaa;
}
.timeBar {
fill: #e8e8e8;
}
.timeBarFull {
fill: #ccffff;
}
.background {
fill: white;
}
.tickDek, .tickDotd, .tickDotd1, .tickC, .tickC1, .tickM, .tickM1, .tickB {
stroke: none;
fill: #666;
width: 1px;
}
</style>`Observable
tool
observable
quarto
TL;DR
The goal of this blog post, the fourth in my Tools blog post series, is to set up the Visual Studio Code (VSCode) source-code editor to run in the remote development environments provided by the Codespaces and GitPod computing platforms. To accomplish this goal, we will use VSCode to edit a setup script and configuration files in a Git repository (repo) named dotfiles.
Introduction
If you do not have such a repo, you can create one by following along with the previous post in my tools blog post series, which is shown in Figure 1.
Day-of-the-year
Decalendar1 is a decimal calendar🗓️system and Declock2 is a decimal timekeeping⏳system. Instead of months, weeks, hours, minutes, and seconds, Decalendar and Declock use a number called the day-of-the-year (doty)3. Figure 2 displays the current doty () as a bar chart. The integer part of the doty is the Decalendar date4 () and the fractional part is the Declock time5 ().
The top two bars in Figure 2 (旬 and 日) show the two components of the Decalendar date (): the dek6 () and the day-of-the-dek7 (). A dek is a group of 10 days that fulfills the role of months and weeks in Decalendar. Therefore, the dek and the day-of-the-dek (dotd) in Decalendar dates are analogous to the month and day-of-the-month (dotm) in calendar dates (mm-dd) and the week and day-of-the-week (dotw) in week dates (Www-d).
The bottom three bars in Figure 2 (%, ‰, and ♫) show components of the Declock time (): cents8 (), mils9 (), and beats10 (). A cent is 1% of the day, which is a little less than a quarter hour. A mil (‰) is a tenth of a cent, which is close to a minute and a half. A beat is 1% of a mil, which is almost as long as a second, and can be thought of as a heart❤️beat or musical🎵beat with a constant rate or tempo of 69.4̅ beats per minute. In addition to displaying time on clocks🕰️and⌚️watches, beats can be used to measure durations, such as the time since this webpage was loaded: .
As an alternative to doty dates, Decalendar can express dates in fractional years, which are similar to the fractional days used in doty times. Fractional year dates11 can be combined with years into one number and are useful for marking periods of 73 days in the Decalendar year. Every 73 days, the Decalendar fractional year date increases by .2 (⅕). The current Decalendar fractional year date is .
Now it’s your turn! Move the sliders🎚️in Example 1 to adjust the doty, fractional year, month, and dotm values and see the corresponding 1) Northern Hemisphere season, 2) Southern Hemisphere season, and 3) Zodiac sign in ../List 1. Try selecting a special date, like your birthday🎂! You can also press the Play▶️button and then sit back and watch the sliders cycle🔄back and forth from the start to the end of the Decalendar year. The sliders cycle through an entire year in 36.5 beats, a million times faster🏎️than the actual speed of time!
Example 1
Year
Doty dates (day) and timestamps (day.stamp) do not include years and thus can be reused from year to year. When a doty is combined with a year, it forms a Decalendar ordinal12 (deco) and represents a specific date (year+day) or time (year+day.stamp) in a specific year, instead of a date (day) or time (day.stamp) that occurs every year or every leap year. Using the Observable datetime input in Example 2, you can select a year, month, dotm, hour, and minute to see the equivalent 1) deco timestamp (year+day.clock) and 2) year date (year.yyy) in ../List 2. For comparison, the current deco timestamp is .
Example 2
In Example 3, you can enter numeric year and doty values or type in a free-form deco to see the resulting 1) ISO 8601 timestamp (year-mm-ddThh:mm:ss) and 2) year date in ../List 3. Example 3 also has a Play▶️button in that cycles from Day 0 to Day 365 of Year 1969. The cycle goes up to Day 0 of Year 1970, because Year 1969 is a common year and does not have a Day 365.
Interacting with the number inputs in Example 3, standardizes the deco in the text input to ensure years are integers and doty values are positive numbers below 366. Nevertheless, all of the inputs in Example 3 support negative and fractional year and doty values. Negative doty values shift dates backward in time from the start of a given year into a previous year. Similarly, negative years shift dates backward starting from Year 0, which is 1 BCE (Before the Common Era).
Example 3
Dek
As mentioned in Section 0.3, deks function as both months and weeks in Decalendar. A dek consists of 2 groups of 5 days called pents13. Each pent can follow a sequence of workdays and restdays called a pently schedule14. fig-schedules compares the typical weekly schedule and the Schedule 3 pently schedule. Like other pently schedules, Schedule 3 is named after the number of workdays it contains. The 3 workdays in Schedule 3 are followed by a 2-day pentend, the Decalender equivalent of a weekend.
Unlike Schedule 3 and the other pently schedules, the weekly schedule is asymmetric and divides up workdays into proportions that are easier to express as fractions: 3/8 (.375), 1/3 (.3̅), and 7/24 (0.2916̅). In contrast, pently schedules divides each workday into simple, symmetrical proportions that are never repeating decimal numbers. The Schedule 3 workday is longer the typical 9-to-5, but the longer workday is compensated by more frequent restdays.
Example 4 provides the opportunity to explore all four of the pently schedules. Schedule 3 is the default pently schedule, and should be followed unless there is a compelling reason to do otherwise. Despite having different numbers of workdays, all pently schedules keep the amount of spent at work constant at 1.2 days (120 cents) per pent. Every pently schedule also splits up workdays symmetrically so that the time before work is equal to the time after work. You can select which schedule to view in Example 4 using the Observable radio inputs.
You can also use the interval sliders to create a custom schedule and the Download⬇️button to obtain the schedule data. Each interval is summarized by a Declock slice shown next to its slider. Decalendar and Declock slices are inspired by array slicing in computer programming and are used to represent date and time intervals. The slice that summarizes the Schedule 3 workday is .3:.7, where .3 (7.2 hours) is the start and .7 (16.8 hours) is the stop.
Example 4
Slices can be used to select Declock time intervals, as in Example 4 above, or Decalendar date intervals, as in Example 5 below. Decalendar date intervals can represent events take place over multiple days. In addition to slices, Decalendar and Declock intervals can also be chosen using spreads. Use the Observable button and checkbox inputs in Example 5 to display different dotd combinations in Figure 4. Figure 4 shows dates selected by a slice in blue, dates selected by a spread in orange, dates selected by both in green, and dates selected by neither in gray.
The Example 5 button and checkbox inputs will modify the associated slice and spread text and numeric inputs as needed to select the desired days-of-the-dek. The numeric inputs for slices are the start🎬index, the stop🛑index, and the step👣size, which are separated by colons (start:stop:step) when combined into a slice. Similarly, the numeric inputs for spreads are the start🎬or🛑stop index, the span🪽size, the split size, and the space size, which are typically delimited by greater-than signs (start>span>split>space) or a mix of greater-than and less-than signs (stop<span>split>space).
The numeric inputs in Example 5 cannot capture the full power of spreads and slices, because slices can have any number of steps and, likewise, spreads can have any number of alternating split and space sizes. We can select all even or odd days-of-the-dek using a slice with a single step (::2 or 1::2) or a spread with one split-space pair (>>>1 or 1>>>1). More complex patterns, such as prime or composite days-of-the-dek, require multiple step sizes for slices (2:::2:2:5 or 4::2:2::5) or multiple split-space pairs (2>>2>1>>1>>4 or 4>>>1>>1>2>4).
Example 5
References
Appendix
Doty Observable code
Functions
function set(input, value) {
input.value = value;
input.dispatchEvent(new Event("input", {bubbles: true}));
}
// 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
} = {}) {
values = Array.from(values);
const form = html`<form style="font: 12px var(--sans-serif); font-variant-numeric: tabular-nums; display: flex; height: 33px; align-items: center;">
<button name=b type=button style="background-color:#002ead;color:#fff;border-radius:10px;margin-left:.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="display: none;">
<output name=o style="display: none;"></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;
}
// 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;
}
setTransform = (input) => ({to: (value) => (input.value = value, {andDispatchEvent: (event = new Event("input")) => input.dispatchEvent(event)})});
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;
}
}
function identity(x) {
return x;
}
// https://observablehq.com/@observablehq/text-color-annotations-in-markdown#textcolor
function setStyle(content, style = {}) {
function yiq(color) {
const {r, g, b} = d3.rgb(color);
return (r * 299 + g * 587 + b * 114) / 1000 / 255; // returns values between 0 and 1
}
const {
background,
color = yiq(background) >= 0.6 ? "#111" : "white",
padding = "0 1px",
borderRadius = "4px",
fontWeight = 900,
fontSize = "1em",
...rest
} = typeof style === "string" ? {background: style} : style;
return htl.html`<span style=${{
background,
color,
padding,
borderRadius,
fontWeight,
...rest
}}>${content}</span>`;
}
// http://howardhinnant.github.io/date_algorithms.html#civil_from_days
function dote2doty(days = 719468) {
const era = Math.floor((days >= 0 ? days : days - 146096) / 146097),
dotc = days - era * 146097,
yotc = Math.floor((dotc - Math.floor(dotc / 1460) + Math.floor(dotc / 36524) - Math.floor(dotc / 146096)) / 365);
return [yotc + era * 400, dotc + Math.floor(yotc / 100) - yotc * 365 - Math.floor(yotc / 4)];
}
function unix2doty(ms = 0) {
return dote2doty(ms / 86400000 + 719468)
}
// https://howardhinnant.github.io/date_algorithms.html#days_from_civil
function doty2dote(year = 1969, doty = 0, zone = 0) {
const cycle = Math.floor((year >= 0 ? year : year - 399) / 400),
yote = year - cycle * 400;
return cycle * 146097 + yote * 365 + Math.floor(yote / 4) - Math.floor(yote / 100) + doty - zone
}
function doty2deco(yearDoty = [1969, 306], zone = 0) {
const yd = dote2doty(doty2dote(yearDoty[0], Math.floor(yearDoty[1])));
return `${yd[0]}+${(yd[1]).toString().padStart(3, "0")}${
yearDoty[1].toString().includes(".") ? "." + (
(yearDoty[1] > 0) ? (yearDoty[1] - zone).toString().split(".").pop()
: [...(yearDoty[1] - zone).toString().split(".").pop()].map(
(e, i, a) => (i + 1 === a.length) ? 10 - e : 9 - e
).join("")
) : ""
}`
}
function unix2deco(ms = 0) {
return doty2deco(unix2doty(ms));
};
// http://howardhinnant.github.io/date_algorithms.html#days_from_civil
function greg2doty(month = 1, day = 1) {
return Math.floor(
(153 * (month > 2 ? month - 3 : month + 9) + 2) / 5 + day - 1
)}
// http://howardhinnant.github.io/date_algorithms.html#civil_from_days
function doty2greg(doty = 0) {
const m = Math.floor((5 * doty + 2) / 153);
return [Math.floor(m < 10 ? m + 3 : m - 9), doty - Math.floor((153 * m + 2) / 5) + 1];
}
function doty2toty(doty = 306) {
doty = Math.floor(doty)
return (205 <= doty && doty < 295) ? ["Fall🍁", "Spring🌼"] :
(110 <= doty && doty < 205) ? ["Summer☀️", "Winter❄️"] :
(20 <= doty && doty < 110) ? ["Spring🌼", "Fall🍁"] :
(0 <= doty && doty < 20) || (295 <= doty && doty <= 365)
? ["Winter❄️", "Summer☀️"] : "Unknown"
}
function doty2zodi(doty = 306) {
doty = Math.floor(doty)
return (20 <= doty && doty < 50) ? "Aries♈️" :
(50 <= doty && doty < 81) ? "Taurus♉️" :
(81 <= doty && doty < 112) ? "Gemini♊️" :
(112 <= doty && doty < 144) ? "Cancer♋️" :
(144 <= doty && doty < 175) ? "Leo♌️" :
(175 <= doty && doty < 206) ? "Virgo♍️" :
(206 <= doty && doty < 236) ? "Libra♎️" :
(236 <= doty && doty < 266) ? "Scorpio♏️" :
(266 <= doty && doty < 296) ? "Sagittarius♐️" :
(296 <= doty && doty < 325) ? "Capricorn♑️" :
(325 <= doty && doty < 355) ? "Aquarius♒️" :
(355 <= doty && doty <= 365) || (0 <= doty && doty < 20)
? "Pisces♓️" : "Unknown"
}
function doty2month(doty = 0) {
const m = Math.floor((5 * doty + 2) / 153);
return Math.floor(m < 10 ? m + 3 : m - 9);
}
function month2doty(month = 1) {
return Math.floor(
(153 * (month > 2 ? month - 3 : month + 9) + 2) / 5
)}
function doty2dotm(doty = 0) {
const m = Math.floor((5 * doty + 2) / 153);
return doty - Math.floor((153 * m + 2) / 5) + 1;
}Variables
widthunix = {
while(true) {
yield Date.now();
}
}
tick = {
let i = 0;
while (true) {
yield Promises.tick(864, ++i);
}
}
tickTime = tick % 1e5
barTime = unix2dote(unix).toString().split(".")[1].slice(0, 8)
barCents = barTime.slice(0, 2)
barMils = barTime[2]
barBeats = barTime.slice(3, 5)
barMb = barTime.slice(5)
deco = unix2deco(unix).slice(0, 14)
doty = deco.slice(5)
dotyDate = doty.slice(0, 3)
dotyTime = doty.slice(4)
dotyDek = doty.slice(0, 2)
dotyDotd = doty[2]
iso = {
while(true) {
yield new Date().toISOString()
}
}
isoDate = iso.slice(5, 10)
isoTime = iso.slice(11, 19)
season = doty2toty(dotyDate)
zodiac = doty2zodi(dotyDate)
inputDotySeason = doty2toty(inputDoty)
inputDotyZodiac = doty2zodi(inputDoty)
numbers = Array.from({length: 366}, (_, i) => i)styledYear = setStyle(deco.slice(0, 4), d3.schemePaired[10])
styledDotyDate = setStyle(dotyDate, d3.schemePaired[1])
styledDotyTime = setStyle(dotyTime, d3.schemePaired[2])
styledDotyDate1 = setStyle(dotyDate, d3.schemePaired[1])
styledDotyTime1 = setStyle(dotyTime, d3.schemePaired[2])
styledTickTime = setStyle(tickTime, d3.schemePaired[2])
styledDek = setStyle(dotyDek, d3.schemePaired[0])
styledDotd = setStyle(dotyDotd, d3.schemePaired[9])
styledCent = setStyle(dotyTime.slice(0, 2), d3.schemePaired[7])
styledMil = setStyle(dotyTime[2], d3.schemePaired[6])
styledBeat = setStyle(dotyTime.slice(3, 5), d3.schemePaired[11])Set values
Deco Observable code
Functions
copyq:
function unix2doty(ms = 0) {
const days = ms / 86400000 + 719468,
dote = days - (era = Math.floor((days >= 0 ? days : days - 146096) / 146097)) * 146097,
year = Math.floor((dote - dote / 1460 + dote / 36524 - dote / 146096) / 365) + era * 400;
return [year, days - Math.floor(year * 365 + year / 4 - year / 100 + year / 400)];
}
const [year, doty] = unix2doty(Date.now()),
datetime = `${year.toString().padStart(4, "0")}+${
(day = Math.floor(doty)).toString().padStart(3, "0")}.${
(Math.round((doty - day) * 1e5)).toString().padStart(5, "0")}+0`;
copy(datetime)
copySelection(datetime)
paste()function zone2hour(zone = "Z") {
return (zone = zone.toUpperCase()) == "Z" ? 0
: zone > "@" && zone < "J" ? zone.charCodeAt() - 64
: zone > "J" && zone < "N" ? zone.charCodeAt() - 65
: zone < "Z" && zone > "M" ? -(zone.charCodeAt() - 77)
: zone;
}
function deco2doty(timestamp = "1969+306.00000Z") {
const arr = timestamp.toString().split(/(?=[+-]|[a-zA-Z])/, 3);
switch (arr.length) {
case 1: return [unix2doty(Date.now())[0], parseFloat(arr[0]), 0];
case 2: return (/^[a-zA-Z]+$/.test(arr[1]))
? [unix2doty(Date.now())[0], parseFloat(arr[0]), zone2hour(arr[1]) / 24]
: [parseFloat(arr[0]), parseFloat(arr[1]), 0];
};
return [parseFloat(arr[0]), parseFloat(arr[1]), /^[a-zA-Z]+$/.test(arr[2])
? zone2hour(arr[2]) / 24
: parseFloat(arr[2].replace(/([+-])/, "$1\."))];
}
function deco2deco(timestamp = "1969+306.00000Z") {
return doty2deco(deco2doty(timestamp))
}
function doty2doty(year = 1969, doty = 0, zone = 0) {
return dote2doty(doty2dote(year, doty, zone));
}
function doty2unix(year = 1969, doty = 306, zone = 0) {
return (doty2dote(year, doty, zone) - 719468) * 86400000;
}
function doty2isoc(year = 1969, doty = 306, zone = 0) {
return new Date(doty2unix(year, doty, zone)).toISOString().split(".")[0]
}
function deco2isoc(timestamp = "1969+306.00000Z") {
return doty2isoc(...deco2doty(timestamp))
}
function unix2dote(ms = 0) {
return ms / 86400000 + 719468;
}
function dote2year(days = 719468) {
const era = Math.floor((days >= 0 ? days : days - 146096) / 146097),
dotc = days - era * 146097,
yotc = (dotc - Math.floor(dotc / 1460) + Math.floor(dotc / 36524) - Math.floor(dotc / 146096)) / 365;
return yotc + era * 400;
}
function doty2year(year = 1969, doty = 306, zone = 0) {
return dote2year(doty2dote(year, doty, zone));
}
function unix2year(ms = 0) {
return dote2year(unix2dote(ms));
};Variables
tzOffsetInMs = inputDatetime.getTimezoneOffset() * 60000
utcDatetime = inputDatetime - tzOffsetInMs
outputDatetimeDeco = unix2deco(utcDatetime)
dtDoty = outputDatetimeDeco.split("+")[1]
dtDotyDateRaw = dtDoty.split(".")[0]
dtDotyTimeRaw = dtDoty.split(".")[1].slice(0, 5)
outputDatetimeYear = unix2year(utcDatetime).toString().slice(0, 8)
dtDotyTimeDelimiter = dtDotyTimeRaw ? "." : ""
outputDecoYear = doty2year(...yd).toString().slice(0, 8)
outputIsoc = doty2isoc(...yd)
yearDate = Math.ceil(unix2year(unix) * 1e3) / 1e3dtYear = setStyle(outputDatetimeDeco.split("+")[0], d3.schemePaired[10])
dtDotyDate = setStyle(dtDotyDateRaw, d3.schemePaired[1])
dtDotyTime = dtDotyTimeRaw ? setStyle(dtDotyTimeRaw, d3.schemePaired[2]) : ""
styledIsocYear = setStyle(outputIsoc.split(/(?=-)/)[0], d3.schemePaired[10])
styledIsocMd = setStyle(outputIsoc.slice(outputIsoc.split(/(?=-)/)[0].length+1,outputIsoc.split(/(?=-)/)[0].length+6), d3.schemePaired[5])
styledIsocTime = setStyle(outputIsoc.slice(outputIsoc.split(/(?=-)/)[0].length+7), d3.schemePaired[4])
styledOutputDatetimeYear = setStyle(outputDatetimeYear, d3.schemePaired[10])
styledOutputDecoYear = setStyle(outputDecoYear, d3.schemePaired[10])Dek Observable code
Functions
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: 50px;
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();
},
});
}
cssLength = v => v == null ? null : typeof v === 'number' ? `${v}px` : `${v}`
function randomScope(prefix = 'scope-') {
return prefix + (performance.now() + Math.random()).toString(32).replace('.', '-');
}
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;
}
function formatDecimal(number) {
return number == 1 ? number : (Math.round(number * 100) / 100).toString().slice(1)
}
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"
});
}
return DOM.download(
downloadData,
filename,
"Download"
);
}
function invert(arr) {
return invertDotd ? [...Array(10).keys()].map(n => !arr.includes(n)).map((x, i) => x ? i : null).filter(i => i !== null) : arr
}
// https://observablehq.com/@chrispahm/toggle-switch-input-button
function toggleSwitch(options = { textOn: 'True', textOff: 'False' }) {
const button = html`<div class="button-switch""></div>`;
button.innerText = options.textOn;
button.value = true;
button.onclick = () => {
button.value = !button.value;
button.innerText = button.value ? options.textOn : options.textOff;
button.dispatchEvent(new CustomEvent("input"));
};
addButtonStyle()
return button;
}
// https://observablehq.com/@chrispahm/toggle-switch-input-button
function addButtonStyle() {
var el = document.createElement('style');
el.setAttribute('type', 'text/css');
document.head.appendChild(el);
var sheet = el.sheet;
function addRule(rule) {
try {
sheet.insertRule(rule, sheet.cssRules.length);
} catch (e) {
console.warn('Error inserting rule', rule, e);
}
}
addRule(
'.button-switch { -webkit-touch-callout: none;-webkit-user-select: none;-khtml-user-select: none;-moz-user-select: none;-ms-user-select: none;user-select: none;display: inline-block;-webkit-border-radius: 2px;-moz-border-radius: 2px;border-radius: 2px;background-color: #EFEFEF;padding: 0px 7px 0px 7px;text-align: center;border: 1px solid grey;width: auto;color: #1c1c1c;font-size: 14px;font-family: sans-serif;text-decoration: none; }'
);
addRule(
'.button-switch:hover { background-color: #E5E5E5;border: 1px solid #454545; }'
);
addRule(
'.button-switch:active { background-color: #f5f5f5;border: 1px solid grey; }'
);
}
function setSpreadDotd(dotd) {
const dotdLength = dotd.length;
if (dotdLength === 10) {return ">"}
if (dotdLength === 0) {return ">0"}
if (dotdLength === 1) {return `${dotd[0]}>>1>9`}
const steps = dotd.map((x, i) => dotd[i + 1] - x)
.filter(i => !isNaN(i)),
result = [];
let split = 1;
for (const step of steps) {
if (step !== 1) {
result.push([split, step - 1])
split = 1
} else {
split += 1
}
}
result.push(split)
const flat = [].concat(...result),
resultSum = flat.reduce((a, b) => a + b, 0),
uniq = [...new Set(flat)];
return [dotd[0] ? dotd[0] : null, null].concat(JSON.stringify(uniq) === "[1]" ? [null, 1] : uniq.length === 2 && uniq.reduce((a, b) => a + b, 0) === 5 ? uniq : flat.concat(10 - resultSum).map((x, i) => i % 2 === 0 && x === 1 ? null : x)).join(">")
}
function setSliceDotd(dotd) {
const dotdLength = dotd.length;
if (dotdLength === 10) {return ":"}
if (dotdLength === 0) {return ":0"}
const steps = dotd.map((x, i) => dotd[i + 1] - x)
.filter(i => !isNaN(i)),
stepSum = steps.reduce((a, b) => a + b, 0),
uniq = [...new Set(steps.concat(10 - stepSum))];
return [dotd[0] ? dotd[0] : null, null].concat(uniq.length === 1 ? uniq : JSON.stringify(steps) === "[1,4,1]" ? [1, 4] : JSON.stringify(steps) === "[1,1,3,1,1]" ? [1, 1, 3] : JSON.stringify(steps) === "[1,1,1,2,1,1,1]" ? [1, 1, 1, 2] : steps.concat(10 - stepSum).map(x => x === 1 ? null : x)).join(":");
}
function encodeSlice(...args) {
return args.join().replaceAll(",", ":")
}
function decodeSlice(s) {
let [start, stop, ...steps] = s.split(":").map(i => i === "" ? null : Number(i))
const step = steps[0] == null ? 1: steps[0];
start = start == null && step >= 0 ? 0 : start == null && step < 0 ? 365 : start;
stop = stop == null && step >= 0 ? 366 : stop;
return [start, stop, step]
}
function decodeSpread(spread) {
return spread.replace(/^</, "366<").replace(/^>/, "0>")
.split(/(?=^\d|<|>)/).map(s => s.replace("<", "-")
.replace(">", "")).map(s => s === "" || isNaN(s) ? null : Number(s))
}
function encodeSpread(args) {
return args.map(a => isNaN(a) ? null : a).join(">").replace(">-", "<")
}
function slice(self, start, stop, steps, ...args) {
const len = self.length, result = [];
if (steps === 0 || len === 0) { return result };
steps = (!steps || steps.length === 0 ? [1] :
typeof steps === "number" ? [steps] :
typeof steps === "string" ? Array.from(steps, Number) :
steps).concat(args.map(i => i == null ? 1 : i));
const stepCount = steps.length,
stepSum = steps.reduce((a, b) => a + b, 0);
if (stepSum === 0) { return result };
start = Math.max(
start == null && stepSum > 0 ? 0 :
start == null && stepSum < 0 ? len - 1 :
start >= len ? len - 1 :
start < 0 ? start + len :
start, 0);
stop = Math.max(
stop == null && stepSum > 0 ? len :
stop == null && stepSum < 0 ? -1 :
stop >= len ? len :
stop < 0 ? stop + len :
stop, -1);
for (
let i = start, counter = -1;
stepSum > 0 ? i < stop : i > stop;
i += steps[counter]
) {
result.push(self[i]);
counter = (counter + 1) % stepCount;
};
return result;
}
function spread(self, startOrStop, span, splitsAndSpaces, ...args) {
const len = self.length, result = [];
if (span === 0 || len === 0) { return result };
splitsAndSpaces = (!splitsAndSpaces || splitsAndSpaces.length === 0 ? [1] :
typeof splitsAndSpaces === "number" ? [splitsAndSpaces] :
typeof splitsAndSpaces === "string" ? Array.from(splitsAndSpaces, Number) :
splitsAndSpaces).concat(args.map(i => i == null ? 1 : i));
const splitCount = splitsAndSpaces.length,
splitSpaceSum = splitsAndSpaces.reduce((a, b) => a + b, 0);
if (splitSpaceSum <= 0) { return result };
startOrStop = Math.max(
startOrStop == null && span > 0 || startOrStop == null && span == null ? 0 :
startOrStop == null || startOrStop > len && span < 0 ? len :
startOrStop < 0 ? startOrStop + len :
startOrStop, 0);
span = span == null || startOrStop + span > len ? len - startOrStop :
startOrStop + span < 0 ? -startOrStop: span;
const start = span > 0 ? startOrStop : startOrStop + span,
stop = span > 0 ? startOrStop + span : startOrStop;
for (let i = start, c = -1, arr = []; i < stop; i += splitsAndSpaces[c]) {
if ((c = (c + 1) % splitCount) % 2 === 0 && i + splitsAndSpaces[c] <= stop) {
if ((arr = Array.from({length: splitsAndSpaces[c]}, (_, j) => j + i).map(
index => self[index]).filter(item => item !== undefined)
).length == splitsAndSpaces[c]) { result.push(arr) }
}
};
return result;
}Variables
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"
}]))
durations = [].concat(...nested)
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));
}
`
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]],
]
dates = Array.from({length: 366}, (_, i) => i.toString().padStart(3, "0"))
sliceArray = slice(dates, ...sliceString.split(":").map(i => i === "" ? null : Number(i)))
spreadArray = [].concat(...spread(dates, ...decodeSpread(spreadString)))Set values
Footnotes
Decalendar: a calendar system that usesdeksinstead of months and weeks↩︎Declock: a timekeeping system that uses fractional days instead of hours, minutes, and seconds↩︎doty: day-of-the-year; an alternative to months, weeks, hours, minutes, and seconds↩︎dotydate: the integer part of thedoty; an alternate to calendar dates and week dates↩︎dotytime: the fractional part of thedoty; an alternate to hours, minutes and seconds↩︎dek: a group of 10 days; the first two digits of theDecalendardate; an alternate to month and weeks↩︎dotd: one of the 10 days in adek; the last digit of theDecalendardate; an alternate to the day-of-the-month and day-of-the-week↩︎cent: a hundredth (\(10^{-2}\)) of a day; the first 2 digits of thedotytime; an alternate to quarter hours↩︎mil: a thousandth (\(10^{-3}\)) of a day; the first 3 digits of thedotytime; an alternate to minutes↩︎beat: a hundred thousandth (\(10^{-5}\)) of a day; the first 5 digits of thedotytime; an alternate to seconds↩︎fractional year date: a year and the proportion of that year that has passed; an alternative to
Decalendardates↩︎deco: a date and time format consisting of a year and adoty; analogous to ISO 8601 dates↩︎pent: a group of 5 days; half of adek↩︎pentlyschedule: one of a set of workday and restday schedules for the 5 days of thepent↩︎
Citation
BibTeX citation:
@online{laptev2024,
author = {Laptev, Martin},
title = {Observable},
date = {2024},
urldate = {2024},
url = {https://maptv.github.io/observable/},
langid = {en}
}
For attribution, please cite this work as:
Laptev, Martin. 2024. “Observable.” 2024. https://maptv.github.io/observable/.