Published
Edited
Feb 5, 2021
5 forks
Importers
13 stars
Insert cell
md`# Calendar

A circular calendar.

This is a complete rework of the circular calendar originally described here https://foulab.org/projects/strawdog/circular-calendar/

All in one language now, and a popular one at that!

Abstraction has been much improved - style is now controlled nearly entirely by a CSS, while the layout is highly parameterized. Localization to other languages SHOULD be easier.

The SVG can be downloaded by clicking to the left of it. SVG _should_ be easily printable at any desired size, although this can be surprisingly annoying. A workflow that seems to work for me
1. Create the SVG with the bounding box (otherwise things get cut off)
2. Download it (loading in inkscape now the fonts are wrong)
3. Load it into the browser
4. Print to PDF (this captures the fonts)
5. Load in inkscape,
6. Remove unwanted crud, page number, header bouding box, etc
7. Resize document to content
8. Save as PDF<BR><CENTER>_optionally_</CENTER>
9. pdf2ps to get postscript
10. use poster to map across multiple pages<BR>
<PRE> poster -mLet -p2x1Let -w5% -v calendar_2021.ps >calendar_2021_2page.ps</PRE>
11. use ps2pdf to convert back to pdf

Known issues
+ probable inaccuracies in astronomical calculations. Suncalc is used, but I already have caught some inconsistencies. See https://github.com/mourner/suncalc/issues/150 2021 moon phase manually entered
+ dominant baseline would be the right way to lay out the text, but Inkscape fails to support it since 2011 https://bugs.launchpad.net/inkscape/+bug/811862
+ Untested (and will probably fail)
- for timezones more than about 12hrs from UTC
- southern hemisphere
- polar day/night
- nonenglish localization
+ events I haven't implemented a calculation for are only entered for 2021
+ there is a todo list of things to calculate rather than list
- Easter
- Chinese New Year
- Hannukah
- Ramadan
- seasons, equinox, solstice
+ Kwanzaa wraps around Dec 31 - Jan 1, which makes it hard to show, not shown for now.
+ sometimes things are listed day/month/year vs month/day/year should be consistent.
+ Mostly consistent that tables of information list months as Jan=1, but javascript says that Jan=0, and this conversion is not fully consistent everywhere.
`
Insert cell
chart = {
let height = width;
const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);
const stylesheetnode=svg.append("style").attr("type","text/css").text(stylesheet);
const frame=svg.append("g");
// page border
if (parameters['parts']['bounding_box']) {
add_rectangle(frame,[0,0],[width,width]).attr("class","outerbox");
}
const g_centered = frame.append("g").attr("transform", `translate(${width/2}, ${height/2})`);
let cal=CalendarConstructor(parameters,g_centered,width);
CalendarBuildHolidays(cal,common_events);
CalendarBuildHolidays(cal,astronomical_events);
CalendarBuildCustomEvents(cal,custom_events);
CalendarDrawSpanEvents(cal,span_events);
CalendarDrawMonths(cal);
CalendarDrawDst(cal);
CalendarDrawMoonAndSun(cal);
if (cal.parameters['parts']['zodiac']) {
let ir=cal.parameters['parts']['zodiac'][0];
let or=cal.parameters['parts']['zodiac'][1];
CalendarDrawWedges(cal,ir,or,zodiac_dates,"zodiac");
}
if (cal.parameters['parts']['seasons']) {
let ir=cal.parameters['parts']['seasons'][0];
let or=cal.parameters['parts']['seasons'][1];
CalendarDrawWedges(cal,ir,or,season_dates,"seasons");
}
CalendarDrawMoonPhase(cal);
CalendarDrawEventText(cal);
CalendarFinalize(cal);
//-----------------Interactivity------------------------------------
svg.call(d3.zoom()
.extent([[0, 0], [width, height]])
.scaleExtent([0.5, 8])
.on("zoom", zoomed));

function zoomed({transform}) { frame.attr("transform", transform); }
return svg.node();
}
Insert cell
parameters=({
"year": 2021,
"location": "Montreal",
"timezone": "America/Montreal",
"latitude": 45.4833333333333,
"longitude": -73.583333333333, // negative is west
"locale": 'default', // for month names and such
"gap": 3.25,
// This is an abstract way of specifying the radius
// inner_radius_fraction is the minimum interior size
// radial step fraction, is the step per unit of arbitrary radius
// these are in fractions of width.
// This is because it is much easier to think in units of 1 radius, 2 radii
// than some complicated decimal
"inner_radius_fraction": 0.10,
"radial_step_fraction": 0.022,
"text_offset": -4,
"radial_text_pad": "\xa0\xa0", // xa0 is non breaking space
"radial_text_separator": " | ",
"month_text_fraction": 0.37, // specifically for the month with date, day of year
"date_text_fraction": 0.8,
"day_text_fraction": 0.9,
"sunmoon_hours_text_offset": 2, // specifically for the text on the sunmoon times
// comment any of these out to exclude them
// The two numbers, for most of them, are the inner and outer radii, specified in the
// abstract way defined above.
"parts": {
'bounding_box': [],
'dst': [1,2],
'seasons': [2,3],
'zodiac': [3,4],
'monthsdays': [4,5.5],
'eventtext': [8.7],
'centraltext':[],
'moonphase':[8.35,7.9,6],//center_radius,text_radius,moon_radius
'sunmoon':[6.8,7.8],
'spanevents':[5.5,12] //inner_radius,radial_step
}

})
Insert cell
// Basically I'm writing a class spread over several blocks. Making matters worse, classes aren't hoisted,
// so to follow usual observable practice and put it at the bottom, needs to be a factory.
// A class is only a structure that passes itself first to functions within it, so imitating that.
// There has to be a better way and I see myself refactoring this in future.
function CalendarConstructor(theParameters,mainGroup, width) {
var struct=new Object();
struct.parameters=theParameters;
// Inner group, centered
struct.g_fill= mainGroup.append("g");
struct.g_overlay= mainGroup.append("g");
// Short names for some parameters, and frequently used functions for brevity
struct.yr= theParameters["year"];
struct.numdays= isLeapYear(struct.yr)?366:365;
struct.iRad= theParameters["inner_radius_fraction"]*width;
struct.sa= theParameters["gap"]/2.;
struct.ea=-struct.sa;
struct.tz=theParameters["timezone"];
struct.da=(360-theParameters['gap'])/struct["numdays"];
struct.locale=theParameters['locale'];
struct.rad2pixel=(r) => { return r*theParameters["radial_step_fraction"]*width+struct["iRad"]; };
struct.pixel2rad=(r) => { return (r-struct["iRad"])/theParameters["radial_step_fraction"]/width; };
struct.bound=(step,theClass) => {
// Part circle, separating sections
const r=struct.rad2pixel(step);
var p=d3.path();
const sa_line=toRadians(-90-struct.sa);
const sa_arc=toRadians(struct.sa+180);
const ea_arc=toRadians(struct.ea+180);
p.moveTo(Math.sin(sa_line)*r,Math.cos(sa_line)*r);
p.arc(0,0,r,sa_arc,ea_arc);
var z=struct.g_overlay.append("path")
.attr("d",p.toString())
.attr("class",theClass);
return z;
};
struct.bounds=new Set();
// Days
struct.days=theDays(struct.yr);
struct.days_index=make_day_index(struct.days);
// Some guide marks
//add_crosshair(struct.g_overlay,[0,0],10,{"class": "crosshair"});
//add_crosshair(struct.g_overlay,[350,350],10,{"class": "crosshair"})
//add_crosshair(struct.g_overlay,[-350,-350],10,{"class": "crosshair"})
return struct;
}

Insert cell
// events can be fixed (same date every year)
// or rule month, weekday, count
common_events=[
["New Year's Day", 'fixed',1,1, 'holiday'],
["Groundhog Day", 'fixed',2,2, 'event'],
["Valentine's Day", 'fixed',2,14,'event'],
["Family Day", 'rule1',2,0,3,'event'], // third monday in february
["St. Patrick's Day", 'fixed',3,17,'event'],
["April Fool's Day", 'fixed',4,1,'event'],
["Earth Day", 'fixed',4,22,'event'],
["Victoria / Patriot's Day", 'rule2',5,0,25,'holiday'],
// victoria day / patriots day last monday before May 25
["Mother's Day",'rule1',5,6,2,'event'], // second sunday in May in US/Canada
["Father's Day",'rule1',6,6,3,'event'], // third sunday in June
["St. Jean Baptiste Day",'fixed',6,24,'holiday'],
["Canada Day", 'fixed',7,1, 'holiday'],
["Independence Day (US)", 'fixed',7,4, 'event'],
["New Brunswick Day", 'rule1',8,0,1,'event'], // civic holiday for some
["Labour Day", 'rule1',9,0,1,'holiday'], // first monday september
["Thanksgiving (CAN)",'rule1',10,0,2,'holiday'], // second monday october
["Halloween","fixed",10,31,'event'],
["Remembrance Day", 'fixed',11,11,'event'],
["Thanksgiving (US)", 'rule1',11,3,4,'event'], // fourth thursday november
["Christmas Day", 'fixed',12,25, 'holiday'],
["Boxing Day", 'fixed',12,26, 'holiday'],
];

Insert cell
// dates i haven't figured out how to calculate yet
// correct for 2021 here
// Easter
// Christian holidays that derive from easter
// Passover
// Solstices and equinoxes
astronomical_events= [
['Vernal Equinox','fixed',3,20,'event'],
['Summer Solstice','fixed',6,20,'event'], // 21 atlantic time and eastwared
['Autumnal Equinox','fixed',9,22,'event'],
['Winter Solstice','fixed',12,21,'event'],
['Easter','fixed',4,4,'event'],
['Good Friday','fixed',4,2,'holiday'],
['Ash Wednesday','fixed',2,17,'event'],
['Mardi Gras','fixed',2,16,'event'],
['Chinese New Year (Ox)','fixed',2,12,'event'],
['Yom Kippur','fixed',9,16,'event'],
];

Insert cell
named_moons=({ 1: "Wolf Moon",
2: "Snow Moon",
3: "Worm Moon",
4: "Pink Moon",
5: "Flower Moon",
6: "Strawberry Moon",
7: "Sturgeon Moon",
8: "Barley Moon",
9: "Harvest Moon",
10: "Hunter's Moon",
11: "Beaver Moon",
12: "Long Nights Moon",
'blue': "Blue Moon"});
Insert cell
// span dates
span_events=[
['Lent',2,17,4,3,'event'],
['Hannukah',11,29,12,6,'event'],
['Passover',3,28,4,3,'event'],
['Ramadan month',4,13,5,12,'event'],
//['Kwanzaa',12,26,1,1,'event'], // fixed date, goes over end of year
];

Insert cell
// birthday - for someone still alive
// birth - for someone deceased
// death - obvious
// event
custom_events=[
['Linus Torvalds', 12, 28, 1969, 'birthday'],
['Donald Knuth', 1, 10, 1938, 'birthday'],
['Charles Babbage', 12, 26, 1791, 'birth'],
['Charles Babbage', 10, 18, 1871, 'death'],
['Ada Lovelace', 12, 10, 1815, 'birth'],
['Ada Lovelace', 11, 27, 1852, 'death'],
['Grace Hopper', 12, 9, 1906, 'birth'],
['Grace Hopper', 1, 1, 1992, 'death'],
['Alan Turing', 6, 23, 1912, 'birth'],
['Alan Turing', 6, 7, 1954, 'death'],
['Alexander Graham Bell', 3, 3, 1847, 'birth'],
['Alexander Graham Bell', 8, 2, 1922, 'death'],
['Philo Farnsworth', 8, 19, 1906, 'birth'],
['Philo Farnsworth', 3, 11, 1971, 'death'],
['Robert Morris', 11, 8, 1965, 'birthday'],
['Richard Stallman', 3, 16, 1953, 'birthday'],
['Margaret Hamilton', 8, 17, 1936, 'birthday'],

['Electronic Frontier Foundation founded', 7, 10, 1990, 'event'],
['Morris worm deployed', 11, 2, 1988, 'event'],
['Free Software Foundation founded', 10, 4, 1985, 'event'],

['John von Neumann', 12, 28, 1903, 'birth'],
['John von Neumann', 2, 8, 1957, 'death'],
['Seymour Cray', 9, 28, 1925, 'birth'],
['Seymour Cray', 10, 5, 1996, 'death'],

['HAL 9000 operational', 1,12,1997,'event'],
['Skynet became sentient',8,4,1997,'event'],
['UNIX time started',1,1,1970,'event'],
['Intel 4004 released',11,15,1971,'event'],
/*REM Oct 2 MSG BIR:Martin_Edward_Hellman's_[ord(year(trigdate())-1945)]_birthday
REM Jun 5 MSG BIR:Bailey_Whitfield_'Whit'_Diffie's_[ord(year(trigdate())-1944)]_birthday
REM Nov 16 MSG BIR:Gene_Myron_Amdahl_born_[year(trigdate())-1922]_years_ago
REM Nov 10 MSG BIR:Gene_Myron_Amdahl_died_[year(trigdate())-2015]_years_ago
REM Jan 3 MSG BIR:Gordon_Earle_Moore's_[ord(year(trigdate())-1929)]_birthday
REM Feb 4 MSG BIR:Lotfali_Askar_Zadeh_born_[year(trigdate())-1921]_years_ago
REM Sep 6 MSG BIR:Lotfali_Askar_Zadeh_died_[year(trigdate())-2017]_years_ago
REM Nov 16 MSG BIR:David_Andrew_Patterson's_[ord(year(trigdate())-1947)]_birthday
REM May 20 MSG BIR:Michael_J._Flynn's_[ord(year(trigdate())-1934)]_birthday
REM Jan 19 MSG BIR:John_L._Gustafson's_[ord(year(trigdate())-1955)]_birthday
#REM Feb 3 MSG BIR:George_Armitage_Miller's_[ord(year(trigdate())-1920)]_birthday
# died july 22 2012, but really does he fit on this calendar
REM Oct 28 MSG BIR:Bill_Gates'_[ord(year(trigdate())-1955)]_birthday

REM Feb 4 MSG BIR:Ken_Thompson's_[ord(year(trigdate())-1943)]_birthday
REM Dec 30 MSG BIR:Bjarne_Stroustrup's_[ord(year(trigdate())-1950)]_birthday
REM Dec 16 MSG BIR:Emmanuel_Goldstein's_[ord(year(trigdate())-1959)]_birthday
REM Feb 24 MSG BIR:Steve_Jobs_born_[year(trigdate())-1955]_years_ago
REM Oct 5 MSG BIR:Steve_Jobs_died_[year(trigdate())-2011]_years_ago
REM Sep 9 MSG BIR:Dennis_Ritchie_born_[year(trigdate())-1941]_years_ago
REM Oct 12 MSG BIR:Dennis_Ritchie_died_[year(trigdate())-2011]_years_ago
REM Sep 4 MSG BIR:John_McCarthy_born_[year(trigdate())-1927]_years_ago
REM Oct 24 MSG BIR:John_McCarthy_died_[year(trigdate())-2011]_years_ago
REM Apr 30 MSG BIR:Claude_Elwood_Shannon_born_[year(trigdate())-1916]_years_ago
REM Feb 24 MSG BIR:Claude_Elwood_Shannon_died_[year(trigdate())-2001]_years_ago
REM Aug 9 MSG BIR:David_Albert_Huffman_born_[year(trigdate())-1925]_years_ago
REM Oct 7 MSG BIR:David_Albert_Huffman_died_[year(trigdate())-1999]_years_ago
REM Nov 2 MSG BIR:George_Boole_born_[year(trigdate())-1815]_years_ago
REM Dec 8 MSG BIR:George_Boole_died_[year(trigdate())-1864]_years_ago
REM Mar 2 MSG BIR:Murray_Newton_Rothbard_born_[year(trigdate())-1926]_years_ago
REM Jan 7 MSG BIR:Murray_Newton_Rothbard_died_[year(trigdate())-1995]_years_ago
REM Jul 22 MSG BIR:Donald_Olding_Hebb_born_[year(trigdate())-1904]_years_ago
REM Aug 20 MSG BIR:Donald_Olding_Hebb_died_[year(trigdate())-1985]_years_ago */
];

Insert cell
function CalendarBuildHolidays(c, events) {
// Mark events with fixed date
function insert_in_days(ev) {
if (ev[1]=='fixed') {
let idx=c.days_index[ev[2]-1][ev[3]];
c.days[idx].events.push(ev);
if (ev[4]=="holiday") { c.days[idx].isholiday=true; }
}
}
events.forEach(insert_in_days);
// Find events based on rules like "First monday"
let rule1_based_events=[];
let rule2_based_events=[];
function insert_if_rule(ev) {
if (ev[1]=='rule1') rule1_based_events.push(ev);
if (ev[1]=='rule2') rule2_based_events.push(ev);
}
events.forEach(insert_if_rule);
let m=-1;
let dayscounter=[0,0,0,0,0,0,0];
for(let i=0;i<c.days.length;i++) {
let dow=c.days[i]['dow'];
let dom=c.days[i]['dom'];
if (c.days[i]['month']!=m) {
m=c.days[i]['month'];
dayscounter=[0,0,0,0,0,0,0];
}
dayscounter[dow]++;
function rule1_match(ev) {
if (m+1==ev[2] && dow==ev[3] && dayscounter[dow]==ev[4]) {
console.log(ev);
c.days[i].events.push([ev[0],ev[1],m+1,c.days[i]['dom'],ev[5]])
if (ev[4]=="holiday") { c.days[i].isholiday=true; }
}
}
rule1_based_events.forEach(rule1_match);
function rule2_match(ev) {
if (m+1==ev[2] && dow==ev[3] && dom+7>=ev[4]) {
console.log(ev);
c.days[i].events.push([ev[0],ev[1],m+1,c.days[i]['dom'],ev[5]])
if (ev[4]=="holiday") { c.days[i].isholiday=true; }
}
}
rule2_based_events.forEach(rule2_match);
}

}
Insert cell
function CalendarBuildCustomEvents(c, events) {
// Mark events with fixed date
function insert_in_days(ev) {
let years_ago=c.yr-ev[3];
let theText="";
let idx=c.days_index[ev[1]-1][ev[2]];
switch(ev[4]) {
case "birthday":
theText=`${ev[0]}'s ${ordinal(years_ago)} birthday`;
break;
case "birth":
theText=`${ev[0]} born ${years_ago} years ago`;
break;
case "death":
theText=`${ev[0]} died ${years_ago} years ago`;
break;
case "event":
theText=`${ev[0]} ${years_ago} years ago`;
break;
}
c.days[idx].events.push([theText,ev[1],ev[2],ev[4]]);
}
events.forEach(insert_in_days);

}
Insert cell
//add_span(base_item,loc,radii,angles,attributes={})
function CalendarDrawSpanEvents(c, events,inner_radius,radial_step) {
if (c.parameters.parts['spanevents']) {
const inner_radius=c.parameters['parts']['spanevents'][0];
const radial_step=c.parameters['parts']['spanevents'][1];
let r1=c.rad2pixel(inner_radius);
let dr=radial_step; //c.rad2pixel(radial_step);
function process_span(s) {
console.log(s)
let start_idx=c.days_index[s[1]-1][s[2]];
let end_idx=c.days_index[s[3]-1][s[4]];
let startdelta=0;
for (let i=start_idx;i<=end_idx;++i) {
if (c.days[i]["spancount"]>startdelta) {startdelta=c.days[i]["spancount"];}
c.days[i]["spancount"]++;
}
let start_angle=c.sa+c.da*start_idx;
let end_angle=c.sa+c.da*(end_idx+1);
add_span(c.g_overlay,[0,0],[r1,r1+dr*(startdelta+0.5)],[start_angle,end_angle],
{"class":`line span ${s[5]}` });
add_arc_text(c.g_overlay,s[0],[0,0],r1+dr*(startdelta+0.75),(start_angle+end_angle)/2,
{"class":`label span ${s[5]}`});
}
events.forEach(process_span);
}
}

Insert cell
function CalendarDrawMonths(c) {
if (c.parameters.parts['monthsdays']) {
const inner_radius=c.parameters['parts']['monthsdays'][0];
const outer_radius=c.parameters['parts']['monthsdays'][1];

let m=c.days[0]['month'];
let monthname=c.days[0]['date'].toLocaleString(c.locale,{month: 'long'});
let start=0;
let boundaries=[c.sa];
let boundary_count=1;
const r1=c.rad2pixel(inner_radius);
const r2=c.rad2pixel(outer_radius);
c.bounds.add(inner_radius);
c.bounds.add(outer_radius);
const delta=r2-r1;
const r_month_text=r1+c.parameters['month_text_fraction']*delta;
const r_day_text=r1+c.parameters['date_text_fraction']*delta;
const r_doy_text=r1+c.parameters['day_text_fraction']*delta;

function getState(i) {
if (c.days[i].isholiday) return "holiday";
if (c.days[i]['dow']==0 || c.days[i]['dow']==6) {return "weekend";}
return "weekday";
}
let state=getState(0);
let lastbound=0;
function emit_day_block(from_idx,to_idx,theClass) {
add_wedge(c.g_fill,[0,0],[r1,r2],
[c.sa+c.da*from_idx,c.sa+c.da*to_idx],
{"class":`wedge day ${theClass}`});
}
function emit_month(i) {
boundaries[boundary_count]=c.sa+i*c.da;
boundary_count++;
//add_wedge(g_fill,[0,0],[r1,r2],
// [boundaries[boundary_count-2],boundaries[boundary_count-1]],
// {"fill":color, "stroke":"none"});
add_arc_text(c.g_overlay,monthname,[0,0],r_month_text,
(boundaries[boundary_count-2]+boundaries[boundary_count-1])/2,
{"class": "label month"});
}
for(let i=0;i<c.numdays;++i)
{
let newstate=getState(i);
if (newstate!=state) {
emit_day_block(lastbound,i,state);
state=newstate;
lastbound=i;
}

if (c.days[i]['month']!=m) {
emit_month(i);
m=c.days[i]['month'];
monthname=c.days[i]['date'].toLocaleString(c.locale,{month: 'long'});
}

add_arc_text(c.g_overlay,i+1,[0,0],r_doy_text,
c.sa+c.da*(i+0.5),
{"class": "label day day_of_year"});
add_arc_text(c.g_overlay,c.days[i]['dom'],[0,0],r_day_text,
c.sa+c.da*(i+0.5),
{"class": "label day day_of_month"});

}
emit_day_block(lastbound,c.numdays,state);
emit_month(c.numdays);
// skip first and last, they are covered by other boundary
for(let i=1; i<boundary_count-1;++i) {
add_radial(c.g_overlay,[0,0],[r1,r2],boundaries[i], {"class": "line radial month"});
}
}
}
Insert cell
function CalendarDrawDst(c) {
if (c.parameters.parts['dst']) {
const inner_radius=c.parameters['parts']['dst'][0];
const outer_radius=c.parameters['parts']['dst'][1];
let start=0;
let boundaries=[c.sa];
let boundary_count=1;
const r1=c.rad2pixel(inner_radius);
const r2=c.rad2pixel(outer_radius);
const rt=(r1+r2)/2+c.parameters['text_offset'];
c.bounds.add(inner_radius);
c.bounds.add(outer_radius);

function emit_dst(i,state,title) {
let b=c.sa+i*c.da;
// skip zone of zero size
// will occur for years with no offset between euro and amer
if (b==boundaries[boundary_count-1]) { return; }
boundaries[boundary_count]=c.sa+i*c.da;
boundary_count++;
let theClass="";
switch(state) {
case 0:
theClass="normal";
break;
case 1:
theClass="mixed";
break;
case 2:
theClass="daylight";
break;
}
add_wedge(c.g_fill,[0,0],[r1,r2],
[boundaries[boundary_count-2],boundaries[boundary_count-1]],
{"class": `wedge dst ${theClass}` });
//{"fill":color, "stroke":"none"});
if (title) {
add_arc_text(c.g_overlay,title,[0,0],rt,
(boundaries[boundary_count-2]+boundaries[boundary_count-1])/2,
{"class": `label dst ${theClass}`});
}
}
let state=0;
let statedir=1;
function handle_dst(i) {
let this_offset=c.days[i-i]['this_offset'];
let formatted_offset=(this_offset<0)?`GMT ${this_offset}`:`GMT +${this_offset}`;
if (state==1) {formatted_offset="";}
emit_dst(i,state,formatted_offset);
}

for(let i=1;i<c.numdays;++i)
{
let newstate=state;
//console.log("day:",i,newstate,america_offset_now,euro_offset_now);
if (c.days[i]['america_offset'] != c.days[i-1]['america_offset']) {
newstate=newstate+statedir;
}
if (c.days[i]['euro_offset'] != c.days[i-1]['euro_offset']) {
newstate=newstate+statedir;
}
if (newstate != state) {
handle_dst(i,newstate);
state=newstate;
switch(state) {
case 0:
statedir=1;
break;
case 2:
statedir=-1;
break;
}
}
}
handle_dst(c.numdays,state);
// skip first and last, they are covered by other boundary
for(let i=1; i<boundary_count-1;++i) {
add_radial(c.g_overlay,[0,0],[r1,r2],boundaries[i], {"class":"line radial dst"} );
}
}
}
Insert cell
function CalendarDrawMoonAndSun(c) {
if (c.parameters.parts['sunmoon']) {
const inner_radius=c.parameters['parts']['sunmoon'][0];
const outer_radius=c.parameters['parts']['sunmoon'][1];
let start=0;
let boundaries=[c.sa];
let boundary_count=1;
const r1=c.rad2pixel(inner_radius);
const r2=c.rad2pixel(outer_radius);
const delta=r2-r1;
const r6h=r2-delta*0.25;
const r12h=r2-delta*0.5;
const r18h=r2-delta*0.75;
c.bounds.add(inner_radius);
c.bounds.add(outer_radius);
// Astronomical data
let astro_data=calculate_astronomical_data(c.days,c.parameters['latitude'],c.parameters['longitude']);
// handle wrapping round the day
function getHoursInTimezone(d,offset) {
d.setHours(d.getHours()+offset);
return d.getUTCHours()+d.getUTCMinutes()/60+d.getUTCSeconds()/3600;
}
for (let i=0; i<c.numdays;++i) {
const z=astro_data[i];
let offset=c.days[i]['this_offset'];
let sunstart=getHoursInTimezone(z['sunrise'],offset);
let sunend=getHoursInTimezone(z['sunset'],offset);
let sunangle=c.sa+(i+0.25)*c.da;
let moonangle=c.sa+(i+0.75)*c.da;
let r_rise=r2-delta*sunstart/24.;
let r_set=r2-delta*sunend/24.;
add_radial(c.g_overlay,[0,0],[r_rise,r_set],sunangle, {"class":"line radial sunup"} );
if (astro_data[i]['moonstate']!=-1) {
let moonstart=z['moonrise']?getHoursInTimezone(z['moonrise'],offset):0;
let moonend=z['moonset']?getHoursInTimezone(z['moonset'],offset):24;
let r_rise=r2-delta*moonstart/24.;
let r_set=r2-delta*moonend/24.;
if (moonstart<moonend) {
add_radial(c.g_overlay,[0,0],[r_rise,r_set],moonangle, {"class":"line radial moonup"} );
} else {
add_radial(c.g_overlay,[0,0],[r_set,r2],moonangle, {"class":"line radial moonup"} );
add_radial(c.g_overlay,[0,0],[r1,r_rise],moonangle, {"class":"line radial moonup"});
}
}
//console.log(astro_data[i]);
//console.log(days[i]);
//console.log("sun",i,sunstart,sunend,angle,r_rise,r_set);
}
c.bound(c.pixel2rad(r6h),"line sunmoon hour");
c.bound(c.pixel2rad(r12h),"line sunmoon hour");
c.bound(c.pixel2rad(r18h),"line sunmoon hour");
const off=c.parameters['sunmoon_hours_text_offset'];
const num_time_indicators=5;
for(let i=0;i<num_time_indicators;++i) {
const angle=c.sa+c.numdays*c.da*((i+0.5)/num_time_indicators);
add_arc_text(c.g_overlay,"06:00",[0,0],r6h+off,c.sa+c.numdays*c.da*angle,
{"class": `label sunmoon hour`});
add_arc_text(c.g_overlay,"12:00",[0,0],r12h+off,c.sa+c.numdays*c.da*angle,
{"class": `label sunmoon hour`});
add_arc_text(c.g_overlay,"18:00",[0,0],r18h+off,c.sa+c.numdays*c.da*angle,
{"class": `label sunmoon hour`});
}
}
}
Insert cell
function CalendarDrawMoonPhase(c) {
if (c.parameters.parts['moonphase']) {
const center_radius=c.parameters['parts']['moonphase'][0];
const text_radius=c.parameters['parts']['moonphase'][1];
const moon_radius=c.parameters['parts']['moonphase'][2];
const phases=moon_phases(c.yr);
//console.log(phases);
var lastfullmonth=-1;
function drawmoon(m) {
// convert to timezone of calendar
let tzDate=new Date(m.date.toLocaleString('en-US', {timeZone: c.tz}));
const theMonth=m.date.getUTCMonth();
const theDay=m.date.getUTCDate();
let idx=c.days_index[theMonth][theDay];
const angle=c.sa+c.da*(idx+0.5);
const a0_line=toRadians(-90-angle);
const r1=c.rad2pixel(center_radius);
const rt=c.rad2pixel(text_radius);
const centre=[Math.sin(a0_line)*r1,Math.cos(a0_line)*r1];
var theClass=m.type;
if (m.type=="first quarter") {
theClass="quarter";
add_sector(c.g_overlay, centre, moon_radius, [angle-180,angle], {"class": "moon sector"});
} else if (m.type=="last quarter") {
theClass="quarter";
add_sector(c.g_overlay, centre, moon_radius, [angle,angle+180], {"class": "moon sector"});
} else if (m.type=="full") {
if (lastfullmonth==theMonth) {
theClass="blue";
c.days[idx].events.push([named_moons['blue'],"moon",theMonth+1,theDay,"moon"])
} else {
theClass="full";
c.days[idx].events.push([named_moons[theMonth+1],"moon",theMonth+1,theDay,"moon"])
lastfullmonth=theMonth;
}
}
add_circle(c.g_overlay,centre,moon_radius,{"class":`moon ${theClass}`});
add_arc_text(c.g_overlay,`(${theDay})`,[0,0],rt,
angle,
{"class": `label moon ${theClass}`});

}
phases.forEach(drawmoon);
}
}
Insert cell
function CalendarDrawWedges(c,inner_radius,outer_radius,dates,classname) {
let start=0;
let boundaries=[c.sa];
let boundary_count=1;
const r1=c.rad2pixel(inner_radius);
const r2=c.rad2pixel(outer_radius);
const rt=(r1+r2)/2+c.parameters['text_offset'];
c.bounds.add(inner_radius);
c.bounds.add(outer_radius);

for(let i=0;i<dates.length;i++) {
let z1=dates[i];
let start_idx=c.days_index[z1[1]-1][z1[2]];
let end_idx=0;
if (i<dates.length-1){
let z2=dates[i+1];
//console.log("z2",z2)
end_idx=c.days_index[z2[1]-1][z2[2]];
} else {end_idx=c.numdays;}
let title=z1[0];
let style_key=title;
if (title.charAt(0)=='_') {
title='';
style_key=style_key.substr(1);
}
add_wedge(c.g_fill,[0,0],[r1,r2],
[c.sa+start_idx*c.da,c.sa+end_idx*c.da],
{"class": `wedge ${classname} ${style_key}`});
if(i>0) {
add_radial(c.g_overlay,[0,0],[r1,r2],c.sa+start_idx*c.da, {"class": `line radial ${classname}`});
}
if (title) {
add_arc_text(c.g_overlay,title,[0,0],rt,
(c.sa+start_idx*c.da+c.sa+end_idx*c.da)/2, {"class":`label ${classname}`}
);
}
}
}

Insert cell
//add_text(base_item,theText,loc,angle,attributes={})
function CalendarDrawEventText(c) {
if (c.parameters.parts['eventtext']) {
const radius=c.parameters['parts']['eventtext'][0];
let r=c.rad2pixel(radius);
const textpad=c.parameters['radial_text_pad'];
for (let i=0; i<c.numdays;++i) {
let d=c.days[i];
if (d.events.length>0) {
let dom=d['dom'];
// useing i+.25 here puts baseline of text at start of day, which is what we want
const angle=c.sa+(i+.25)*c.da;
const a0_line=toRadians(-90-angle);
const coords=[Math.sin(a0_line)*r,Math.cos(a0_line)*r];
let eventtext=[];
function addeventtext(ev) {
if (eventtext.length > 0) {eventtext.push(["tspanseparator",c.parameters['radial_text_separator']]);}
eventtext.push([ev[4],ev[0]+textpad]);}
d.events.forEach(addeventtext);
eventtext.push(['date',`${textpad}(${dom})`]);
add_tspans(c.g_overlay,
eventtext,
coords,
angle,
{"class": `label radial`});
}
}
}
}

Insert cell
function CalendarFinalize(c) {
// Draw the bounds, put the end caps on
c.bounds=[...c.bounds].sort();
if (c.bounds.length>1) {
let r1=c.rad2pixel(c.bounds[0]);
let r2=c.rad2pixel(c.bounds[c.bounds.length-1]);
add_radial(c.g_overlay,[0,0],[r1,r2],c.sa, {"class":"line radial endcap"});
add_radial(c.g_overlay,[0,0],[r1,r2],c.ea, {"class":"line radial endcap"});
}
c.bounds.forEach((r)=>{c.bound(r,"line separator");});
// Draw the central text
if (c.parameters['parts']['centraltext'] ){
add_text(c.g_overlay,c.yr,[0,-25],0,{"class":"label year"});
add_text(c.g_overlay,"Make your own!",[0,25],0,{"class":"label reference"});
add_text(c.g_overlay,"https://observablehq.com/@str4w/circular_calendar",[0,35],0,{"class":"label reference"});
}
}

Insert cell
stylesheet=`
/* basics and unique items */
.outerbox {
stroke: yellow;
stroke-width: 3;
fill: none;
}
.crosshair {
stroke: gray;
stroke-width:0.5;
fill: none
}
.line {
stroke: black;
stroke-width: 1;
fill: none;
}
.wedge { stroke: none; }
.label {
font-family: serif;
stroke-linejoin: round;
stroke-width: 3;
text-anchor: middle;
/* dominant-baseline: middle; // the right way, but fails in inkscape */
}
.label.year {font-size: 48px;}
.label.reference {font-size: 8px;}
.label.radial {text-anchor: end; font-size: 5px;}
.radialtspan.holiday {fill: red;}
.radialtspan.event {fill: magenta;}

/* days */
.wedge.day.weekday { fill: rgb(95%,95%,100%); }
.wedge.day.weekend { fill: rgb(83%,83%,95%); }
.wedge.day.holiday { fill: rgb(100%,83%,83%); }
.label.day.day_of_year {font-size: 2px;}
.label.day.day_of_month {font-size: 2px;}
.label.month {font-size: 12px;}

/* Daylight savings */
.wedge.dst.normal { fill: rgb(213,228,213); }
.wedge.dst.mixed { fill: gray;}
.wedge.dst.daylight { fill: rgb(207,175,175);}
.label.dst {font-size: 12px; }

/* Astronomy */
.line.radial.sunup {stroke: yellow; stroke-width: 2;}
.line.radial.moonup {stroke: blue; stroke-width: 2;}
.line.sunmoon.hour {stroke-width: 0.5;}
.label.sunmoon.hour {font-size: 3px;}

/* Zodiac */
.label.zodiac {font-size: 12px;}
.wedge.zodiac.Capricorn {fill: rgb(193,193,255);}
.wedge.zodiac.Aquarius {fill: rgb(172,200,236);}
.wedge.zodiac.Pisces {fill: rgb(190,215,236);}
.wedge.zodiac.Aries {fill: rgb(233,229,182);}
.wedge.zodiac.Taurus {fill: rgb(255,233,140);}
.wedge.zodiac.Gemini {fill: rgb(227,255,130);}
.wedge.zodiac.Cancer {fill: rgb(180,240,160);}
.wedge.zodiac.Leo {fill: rgb(170,250,170);}
.wedge.zodiac.Virgo {fill: rgb(208,255,154);}
.wedge.zodiac.Libra {fill: rgb(255,210,110);}
.wedge.zodiac.Scorpio {fill: rgb(255,200,150);}
.wedge.zodiac.Sagittarius {fill: rgb(255,200,200);}

/* Seasons */
.label.seasons {font-size: 12px;}
.wedge.seasons.Spring {fill: rgb(255,247,96);}
.wedge.seasons.Summer {fill: rgb(156,216,154);}
.wedge.seasons.Autumn {fill: rgb(234,202,127);}
.wedge.seasons.Winter {fill: rgb(169,179,239);}

/* Spans */
.line.span {fill: none; stroke: magenta; }
.label.span { font-size: 7px; fill: magenta;}

/* Moon */
.moon.sector {stroke: none; fill: black;}
.moon.full {stroke: black; fill: yellow;}
.moon.blue {stroke: black; fill: blue;}
.moon.new {stroke: black; fill: black;}
.moon.quarter {stroke: black; fill: none;}
.label.moon {font-size: 3px; stroke: none; fill: black;}

`

Insert cell
Insert cell
testdays=theDays(2021)
Insert cell
function theDays(year) {
let days=[];
let numDays=isLeapYear(year)?366:365;
let c=0;
let tz=parameters['timezone'];
for(let i = 1; i <= numDays; i++) {
var day=new Date(Date.UTC(year,0,i,12));
days[c++]={
"date": day,
"month": day.getUTCMonth(),
"dom": day.getUTCDate(),
"dow": day.getUTCDay(),
"america_offset": gtcOffset(day,'America/Montreal'),
"euro_offset": gtcOffset(day,'Europe/Berlin'),
"this_offset": gtcOffset(day,tz),
"events":[],
"isholiday":false,
"spancount":0,
}
}
return days;
}
Insert cell
function make_day_index(days) {
var days_index={};
for(let i=0;i<12;i++) {days_index[i]={};}
for(let i=0;i<days.length;i++) {
days_index[days[i]['month']][days[i]['dom']]=i;
}
return days_index;
}
Insert cell
function gtcOffset(day,tz) {
// This risks being buggy for the extreme time zones +/- 12h or more.
// A problem for future me.
let utcDay=new Date(day.toLocaleString('en-US', {timeZone: 'UTC'}));
let tzDay=new Date(day.toLocaleString('en-US', {timeZone: tz}));
return (tzDay.getTime()-utcDay.getTime())/3600000;
}
Insert cell
zodiac_dates=[
['Capricorn',1,1], // Dec 22 MSG Capricorn
['Aquarius',1,20], // Jan 20 MSG Aquarius
['Pisces',2,19], // Feb 19 MSG Pisces
['Aries',3,21], // Mar 21 MSG Aries
['Taurus',4,20], // Apr 20 MSG Taurus
['Gemini',5,21], // May 21 MSG Gemini
['Cancer',6,21], // Jun 21 MSG Cancer
['Leo',7,23], // Jul 23 MSG Leo
['Virgo',8,23], // Aug 23 MSG Virgo
['Libra',9,23], // Sep 23 MSG Libra
['Scorpio',10,23], // Oct 23 MSG Scorpio
['Sagittarius',11,22], // Nov 22 MSG Sagittarius
['_Capricorn',12,22] // Dec 22 MSG Capricorn no label for eoy part
];

Insert cell
// For 2021 - need to make general calc
season_dates=[
['Winter',1,1],
['Spring',3,20],
['Summer',6,20], // 21 atlantic time and eastwared
['Autumn',9,22],
['_Winter',12,21]
];
Insert cell
z=calculate_astronomical_data(theDays(2020),parameters['latitude'],parameters['longitude'])
Insert cell
function calculate_astronomical_data(days,latitude,longitude) {
return days.map(day => {
var noon = d3.timeHour.offset(day['date'], 12),
sun = suncalc.getTimes(noon, latitude, longitude),
moon = suncalc.getMoonTimes(noon, latitude, longitude);
return {
sunrise: sun.sunrise,
sunset: sun.sunset,
moonrise: moon.rise,
moonset: moon.set,
moonstate: moon.alwaysUp ? 1 : moon.alwaysDown ? -1 : 0,
moonfraction: suncalc.getMoonIllumination(noon).fraction
};
})
}
Insert cell
function findMinimum(f, x0, x1) {
let c=0;
x0 = +x0, x1 = +x1;
while (Math.abs(x1 - x0) > 1) {
var dx = (x1 - x0) / 3;
if (f(x0 + dx) > f(x1 - dx)) x0 += dx;
else x1 -= dx;
c++;
if (c>100) {
console.log("Find minimum error",x0,x1);
break;
}
}
return new Date((x0 + x1) / 2);
}
Insert cell
moon_phases(2021);
Insert cell
function moon_phases( year ) {
// copied from https://www.timeanddate.com/moon/phases/timezone/utc?year=2021
// stupid suncalc is borked
return [
{date: new Date("2021-01-06T09:37+00:00"), type: "last quarter"},
{date: new Date("2021-01-13T05:00+00:00"), type: "new"},
{date: new Date("2021-01-20T21:01+00:00"), type: "first quarter"},
{date: new Date("2021-01-28T19:16+00:00"), type: "full"},
{date: new Date("2021-02-04T17:37+00:00"), type: "last quarter"},
{date: new Date("2021-02-11T19:05+00:00"), type: "new"},
{date: new Date("2021-02-19T18:47+00:00"), type: "first quarter"},
{date: new Date("2021-02-27T08:17+00:00"), type: "full"},
{date: new Date("2021-03-06T01:30+00:00"), type: "last quarter"},
{date: new Date("2021-03-13T10:21+00:00"), type: "new"},
{date: new Date("2021-03-21T14:40+00:00"), type: "first quarter"},
{date: new Date("2021-03-28T18:48+00:00"), type: "full"},
{date: new Date("2021-04-04T10:02+00:00"), type: "last quarter"},
{date: new Date("2021-04-12T02:30+00:00"), type: "new"},
{date: new Date("2021-04-20T06:58+00:00"), type: "first quarter"},
{date: new Date("2021-04-27T03:31+00:00"), type: "full"},
{date: new Date("2021-05-03T19:50+00:00"), type: "last quarter"},
{date: new Date("2021-05-11T18:59+00:00"), type: "new"},
{date: new Date("2021-05-19T19:12+00:00"), type: "first quarter"},
{date: new Date("2021-05-26T11:13+00:00"), type: "full"},
{date: new Date("2021-06-02T07:24+00:00"), type: "last quarter"},
{date: new Date("2021-06-10T10:52+00:00"), type: "new"},
{date: new Date("2021-06-18T03:54+00:00"), type: "first quarter"},
{date: new Date("2021-06-24T18:39+00:00"), type: "full"},
{date: new Date("2021-07-01T21:10+00:00"), type: "last quarter"},
{date: new Date("2021-07-10T01:16+00:00"), type: "new"},
{date: new Date("2021-07-17T10:10+00:00"), type: "first quarter"},
{date: new Date("2021-07-24T02:36+00:00"), type: "full"},
{date: new Date("2021-07-31T13:15+00:00"), type: "last quarter"},
{date: new Date("2021-08-08T13:50+00:00"), type: "new"},
{date: new Date("2021-08-15T15:19+00:00"), type: "first quarter"},
{date: new Date("2021-08-22T12:01+00:00"), type: "full"},
{date: new Date("2021-08-30T07:13+00:00"), type: "last quarter"},
{date: new Date("2021-09-07T00:51+00:00"), type: "new"},
{date: new Date("2021-09-13T20:39+00:00"), type: "first quarter"},
{date: new Date("2021-09-20T23:54+00:00"), type: "full"},
{date: new Date("2021-09-29T01:57+00:00"), type: "last quarter"},
{date: new Date("2021-10-06T11:05+00:00"), type: "new"},
{date: new Date("2021-10-13T03:25+00:00"), type: "first quarter"},
{date: new Date("2021-10-20T14:56+00:00"), type: "full"},
{date: new Date("2021-10-28T20:05+00:00"), type: "last quarter"},
{date: new Date("2021-11-04T21:14+00:00"), type: "new"},
{date: new Date("2021-11-11T12:45+00:00"), type: "first quarter"},
{date: new Date("2021-11-19T08:57+00:00"), type: "full"},
{date: new Date("2021-11-27T12:27+00:00"), type: "last quarter"},
{date: new Date("2021-12-04T07:43+00:00"), type: "new"},
{date: new Date("2021-12-11T01:35+00:00"), type: "first quarter"},
{date: new Date("2021-12-19T04:35+00:00"), type: "full"},
{date: new Date("2021-12-27T02:23+00:00"), type: "last quarter"}
];
/* var start=new Date(year,0,1)
var events = [],
d0,
d1 = d3.timeDay.offset(start, -1),
d2 = d3.timeDay.offset(start, 0),
x0,
x1 = suncalc.getMoonIllumination(d1).fraction,
x2 = suncalc.getMoonIllumination(d2).fraction,
q0,
q1 = Math.abs(x1-0.5),
q2 = Math.abs(x2-0.5);
const numdays=isLeapYear(year)?366:365;
var c=0;
for (var i = 0; i < numdays; ++i) {
d0 = d1, d1 = d2, d2 = d3.timeDay.offset(start, i + 1);
x0 = x1, x1 = x2, x2 = suncalc.getMoonIllumination(d2).fraction;
q0 = q1, q1 = q2, q2 = Math.abs(x2-0.5);
if (x1 > x0 && x1 > x2) {
events.push({date: findMinimum(x => 1 - suncalc.getMoonIllumination(x).fraction, d0, d2), type: "full"});
} else if (x1 < x0 && x1 < x2) {
events.push({date: findMinimum(x => suncalc.getMoonIllumination(x).fraction, d0, d2), type: "new"});
} else if (q1 < q0 && q1 < q2) {
events.push({date: findMinimum(x => Math.abs(suncalc.getMoonIllumination(x).fraction-0.5),d0,d2), type: (x1<x2)?"first quarter":"last quarter"});
}
c++;
if (c>400) {
console.log("Loop error");
break;
}
}
return events;*/
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
function add_sector(base_item,loc,radius,angles,attributes={}) {
var p=d3.path();
const a0_line=toRadians(-90-angles[0]);
const a1_line=toRadians(-90-angles[1]);
const a0_arc=toRadians(angles[0]+180);
const a1_arc=toRadians(angles[1]+180);
p.moveTo(loc[0],loc[1]);
p.lineTo(loc[0]+Math.sin(a0_line)*radius,loc[1]+Math.cos(a0_line)*radius);
p.arc(loc[0],loc[1],radius,a0_arc,a1_arc);
p.closePath();
var z=base_item.append("path")
.attr("d",p.toString());
add_attributes(z,attributes);
return z;
}
Insert cell
function add_span(base_item,loc,radii,angles,attributes={}) {
var p=d3.path();
const a0_line=toRadians(-90-angles[0]);
const a1_line=toRadians(-90-angles[1]);
const a0_arc=toRadians(angles[0]+180);
const a1_arc=toRadians(angles[1]+180);
p.moveTo(loc[0]+Math.sin(a0_line)*radii[0],loc[1]+Math.cos(a0_line)*radii[0]);
p.lineTo(loc[0]+Math.sin(a0_line)*radii[1],loc[1]+Math.cos(a0_line)*radii[1]);
p.arc(loc[0],loc[1],radii[1],a0_arc,a1_arc);
p.lineTo(loc[0]+Math.sin(a1_line)*radii[0],loc[1]+Math.cos(a1_line)*radii[0]);
var z=base_item.append("path")
.attr("d",p.toString());
add_attributes(z,attributes);
return z;
}
Insert cell
function add_radial(base_item,loc,radii,angle,attributes={}) {
var p=d3.path();
const a=toRadians(-90-angle);
p.moveTo(Math.sin(a)*radii[0],Math.cos(a)*radii[0]);
p.lineTo(Math.sin(a)*radii[1],Math.cos(a)*radii[1]);
var z=base_item.append("path")
.attr("d",p.toString());
add_attributes(z,attributes);
return z;
}
Insert cell
Insert cell
function add_crosshair(base_item,loc,radius,attributes={}) {
var c = base_item.append("g")
{
var p=d3.path();
p.moveTo(loc[0]-radius,loc[1]);
p.lineTo(loc[0]+radius,loc[1]);
c.append("path")
.attr("d",p.toString());
}
{
var p=d3.path();
p.moveTo(loc[0],loc[1]-radius);
p.lineTo(loc[0],loc[1]+radius);
c.append("path")
.attr("d",p.toString());
}
add_attributes(c,attributes);
return c;
}
Insert cell
Insert cell
function add_text(base_item,theText,loc,angle,attributes={}) {
//var a=toRadians(angle);
var transform=`translate(${loc[0]},${loc[1]}) rotate(${angle})`;
//console.log(transform);
var t = base_item.append("text")
.attr("transform",transform)
.text(theText);
add_attributes(t,attributes);
return t;
}
Insert cell
Insert cell
function ordinal(x) {
switch (x%10) {
case 1:
return `${x}st`;
case 2:
return `${x}nd`;
case 3:
return `${x}rd`;
default:
return `${x}th`;
}
}
Insert cell
Insert cell
Insert cell
Insert cell
d3 = require("d3@6")
Insert cell
suncalc = require("suncalc@1")
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