Published
Edited
Jun 9, 2020
2 stars
Insert cell
Insert cell
Insert cell
chart = {
///// DATA
const nodes = data.nodes.map(d => Object.create(d));
const links = data.links.map(d => Object.create(d));
// Filters on data
var min_occ = 1;
var min_occ_link = 1;
var min_occ_iter_new = 0;
var min_occ_iter = 0;
var show_l1 = true;
var show_l2 = true;
var show_l34 = true;
///// DOM ROOT ELEMENTS
const width = 2000;
const height = 2000;
const svg = d3.create("svg")
.attr("viewBox", [-width / 2, -height / 2, width, height])
.attr('style','background: #fff;');

const g = svg.append("g");
var legend_svg = svg.append('g')
.attr('class','legend_div')
.attr('active','no')
.attr('transform','translate(-'+width/2+',-'+height/2+')');
var legend_channel = svg.append('g')
.attr('class','legend_channel')
.attr('active','no')
.attr('transform','translate('+(width/2-300)+',-'+height/2+')');
var active_session = [];
var active_channel = [];
const tooltip = new Tooltip();
g.append(() => tooltip.node_link);
///// FORCE
const simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id))
.force("charge", d3.forceManyBody())
.force("x", d3.forceX())
.force("y", d3.forceY());
///// FULL SCREEN
function onresize () {
let fs = document.webkitFullscreenElement || document.mozFullscreenElement;
if (fs == svg) {
svg.width = window.innerWidth;
svg.height = window.innerHeight;
} else {
svg.width = width;
svg.height = height;
}
legend_svg.attr('transform','translate(-'+svg.width/2+',-'+svg.width/2+')');
}
window.addEventListener('resize', onresize, false);

///// ZOOM
function zoomed() {
g.attr("transform", d3.event.transform);
}
svg.call(d3.zoom()
.extent([[0, 0], [width, height]])
.scaleExtent([1, 8])
.on("zoom", zoomed));
///// GRAPH
// Rendering functions
var node_opacity = function(d){
if( (!show_l1) & d.group != 'recommendation' | (!show_l2) & d.group == 'recommendation' ) return 0.02;
if(parseInt(d.iteration_all) < min_occ_iter_new) return 0.02;
if(d.group != 'recommendation') return 1;
else return 0.2;
}
var link_opacity = function(d){
if( (!show_l1) & d.level == 1 | (!show_l2) & d.level == 2 | (!show_l34) & d.level > 2 ) return 0.002;
if(parseInt(d.iteration_all) < min_occ_iter_new) return 0.002;
if(d.level == 1) return 1;
else if (d.level == 2) return 0.2;
else return 0;
}
////////////////////////////////////////////////////////////////////////////////
// GRAPH
////////////////////////////////////////////////////////////////////////////////
const link = g.append("g")
.selectAll("line")
.data(links)
.join("line")
.attr("type", d => d.type)
.attr("level", d => d.level)
.attr("target", d => d.target.id)
.attr("source", d => d.source.id)
.attr("channel_source", d => d.source.channel)
.attr("channel_target", d => d.target.channel)
.attr("channel_source_id", d => d.source.channel_id)
.attr("channel_target_id", d => d.target.channel_id)
.attr("session_source", d => d.source.group)
.attr("session_target", d => d.target.group)
.attr("session_all", d => d.session_all)
.attr("session_direct", d => d.session_direct)
.attr("iteration_all", d => d.iteration_all)
.attr("stroke", '#000')
.attr('stroke-linecap', 'round')
.attr("stroke-opacity", d => link_opacity(d))
.attr("stroke-width", d => link_width(d.level));
// Nodes
const node = g.append("g")
.selectAll("circle")
.data(nodes)
.join("a")
.attr("href", d => 'https://www.youtube.com/watch?v=' + d.id)
.attr('target', '_blank')
.append("circle")
.attr("id", d => d.id)
.attr("class", 'video_node')
.attr("channel", d => d.channel)
.attr("channel_id", d => d.channel_id)
.attr("session", d => d.session)
.attr("session_all", d => d.session_all)
.attr("session_direct", d => d.session_direct)
.attr("session_from_reco", d => d.session_from_reco)
.attr("group", d => d.group)
.attr("iteration_all", d => d.iteration_all)
.attr("stroke", "#fff")
.attr("stroke-width", 0.5)
.attr("r", d => r_scale(d3.max([10^6, +d.views^0.5])))
.attr("fill", d => color_scale(d.session))
.attr("opacity", d => node_opacity(d))
.call(drag(simulation));
/////////////////////////////////////////////////////////////////////////////////////
///// UX FUNCTIONS
/////////////////////////////////////////////////////////////////////////////////////
const undo_highlight = function(){
d3.selectAll('.session_legend').attr('opacity',1);
d3.selectAll('.legend_selector_channel').attr('opacity',0);
d3.selectAll('circle').attr('opacity', d => node_opacity(d));
d3.selectAll('line').attr('stroke-opacity', d => link_opacity(d));
}
const hide_all = function(){
d3.selectAll('.session_legend').attr('opacity',0.5);
d3.selectAll('.legend_selector_channel').attr('opacity',0.5);
d3.selectAll('circle').attr('opacity',0.02);
d3.selectAll('line').attr('stroke-opacity',0.001);
}
const hide_all_light = function(){
d3.selectAll('.session_legend').attr('opacity',0.5);
d3.selectAll('.legend_selector_channel').attr('opacity',0.5);
d3.selectAll('circle').attr('opacity',0.08);
d3.selectAll('line').attr('stroke-opacity',0.03);
}
const show_session = function(label){
d3.selectAll('text[session_legend="'+label+'"]').attr('opacity', 1);
if(min_occ_iter == 0){
d3.selectAll('line[session_all*="'+label+'"]').attr("stroke-opacity", d => 0.1); // link_opacity(d));
d3.selectAll('line[session_all*="'+label+'"]').attr("stroke-opacity", d => link_opacity(d));
d3.selectAll('circle[session_direct*="'+label+'"]').attr('opacity', d => node_opacity(d));
d3.selectAll('circle[session_all*="'+label+'"]').attr('opacity', d => node_opacity(d));
d3.selectAll('circle[session_from_reco*="'+label+'"]').attr('opacity', d => node_opacity(d));
} else {
for(let i=1;i<=min_occ_iter;i++){
d3.selectAll('line[session_all*="'+label+'"][iteration_all*="'+i+'"]')
.attr("stroke-opacity", d => 0.1); // link_opacity(d));
d3.selectAll('line[session_all*="'+label+'"][iteration_all*="'+i+'"]')
.attr("stroke-opacity", d => link_opacity(d));
d3.selectAll('circle[session_direct*="'+label+'"][iteration_all*="'+i+'"]')
.attr('opacity', d => node_opacity(d));
d3.selectAll('circle[session_all*="'+label+'"][iteration_all*="'+i+'"]')
.attr('opacity', d => node_opacity(d));
d3.selectAll('circle[session_from_reco*="'+label+'"][iteration_all*="'+i+'"]')
.attr('opacity', d => node_opacity(d));
//await sleep(2000);
}
}
}
const show_session_direct = function(label){
d3.selectAll('text[session_legend="'+label+'"]').attr('opacity', 1);
if(min_occ_iter == 0){
d3.selectAll('line[session_direct*="'+label+'"][level="1"]')
.attr("stroke-opacity", d => link_opacity_nofilter(d));
d3.selectAll('circle[session_direct*="'+label+'"]').attr('opacity', d => node_opacity_nofilter(d));
} else {
for(let i=1;i<=min_occ_iter;i++){
d3.selectAll('line[session_direct*="'+label+'"][level="1"][iteration_all*="'+i+'"]')
.attr("stroke-opacity", d => link_opacity_nofilter(d));
d3.selectAll('circle[session_direct*="'+label+'"][iteration_all*="'+i+'"]')
.attr('opacity', d => node_opacity_nofilter(d));
//await sleep(2000);
}
}
}
const show_channel = function(label){
d3.selectAll('rect[channel_legend="'+label+'"]').attr('opacity', 0);
if(min_occ_iter == 0){
d3.selectAll('line[channel_source="'+label+'"]').attr("stroke-opacity", d => link_opacity(d));
d3.selectAll('line[channel_target="'+label+'"]').attr("stroke-opacity", d => link_opacity(d));
d3.selectAll('circle[channel="'+label+'"]').attr('opacity', d => node_opacity(d));
} else {
for(let i=1;i<=t;i++){
d3.selectAll('line[channel_source="'+label+'"][iteration_all*="'+i+'"]')
.attr("stroke-opacity", d => link_opacity(d));
d3.selectAll('line[channel_target="'+label+'"][iteration_all*="'+i+'"]')
.attr("stroke-opacity", d => link_opacity(d));
d3.selectAll('circle[channel="'+label+'"][iteration_all*="'+i+'"]')
.attr('opacity', d => node_opacity(d));
//await sleep(2000);
}
}
}
var reshow_session_channel = function(){
if(active_session.length == 0 & active_channel.length == 0 & min_occ_iter == 0){
d3.selectAll('.legend_session').attr('active', 'no');
d3.selectAll('.legend_channel').attr('active', 'no');
undo_highlight();
} else {
hide_all();
active_session.forEach(label => show_session(label));
active_channel.forEach(label => show_channel(label));
}
}
///// TOOLTIP - interaction
node.on('mouseover', d => {
tooltip.show(d);
hide_all_light();
// Higlight basic links
d3.selectAll('circle[id="'+d.id+'"]').attr('opacity', node_opacity(d));
var links_src = d3.selectAll('line[source="'+d.id+'"]');
var links_tar = d3.selectAll('line[target="'+d.id+'"]');
links_src.attr('stroke-opacity', dd => link_opacity(dd));
links_tar.attr('stroke-opacity', dd => link_opacity(dd));
links_src.each(e => {
d3.selectAll('circle[id="'+e.target.id+'"]').attr('opacity', node_opacity(e.target));
});
links_tar.each(e => {
d3.selectAll('circle[id="'+e.source.id+'"]').attr('opacity', node_opacity(e.source));
});
// Highlight nodes & session links
var session_list = [];
d.session_all.split(';').forEach(s => {
// s <- s.trim(); if(session_list.indexOf(s) ==-1) { session_list.push(s); }
session_list.push(s.trim());
});
session_list.forEach(label => {
show_session_direct(label);
d3.selectAll('text[session_legend="'+label+'"]').attr('opacity', 1);
});
});
node.on('mouseout', () => {
tooltip.hide();
hide_all();
reshow_session_channel();
});
////// LEGEND SESSION
// Rendering functions
var toggle_legend = function(label){
var element = d3.selectAll('rect[session_legend="'+label+'"]');
if(element.attr('active') == 'yes'){
element.attr('active', 'no');
active_session.splice(active_session.indexOf(label), 1);
hide_all();
reshow_session_channel();
} else {
element.attr('active', 'yes');
var div = d3.selectAll('.legend_div');
if(div.attr('active')=='no'){ div.attr('active', 'yes');}
active_session.push(label);
reshow_session_channel();
}
}
// Svg elements
legend_svg.selectAll("rect")
.data(groups)
.join("rect")
.attr('class', "session_swatch")
.attr("session_swatch", d => d)
.attr('width', 15)
.attr('height', 15)
.attr('fill', d => color_scale(d))
.attr('x',20)
.attr('y', (d,i) => 45+i*25);
legend_svg.selectAll("text")
.data(groups)
.join("text")
.attr('class', "session_legend")
.attr("session_legend", d => d)
.attr('height', 15)
.attr('fill', '#000')
.attr('x', 45)
.attr('y', (d,i) => 59+i*25)
.html(d => d);
var legend_rect = legend_svg.append("g")
.selectAll("rect")
.data(groups)
.join("rect")
.attr('class','legend_selector')
.attr('style', 'cursor: pointer;')
.attr('width', 270)
.attr('height', 15)
.attr('opacity', 0)
.attr('x', 10)
.attr('y', (d,i) => 45+i*25)
.attr('session_legend', d => d)
.attr('active','no')
legend_rect.on('click', d => toggle_legend(d));
legend_rect.on('mouseover', d => {
if(active_session.length == 0 & active_channel.length == 0 & min_occ == 1 & min_occ_link == 1) hide_all();
show_session_direct(d);
});
legend_rect.on('mouseout', d => {
reshow_session_channel();
});
legend_svg.append("text")
.html('Sessions:')
.attr('font-weight','bold')
.attr('x', 25)
.attr('y', 30);
////////////////////////////////////////////////////////////////////////
////// LEGEND CHANNEL
////////////////////////////////////////////////////////////////////////
// Rendering functions
var toggle_legend_channel = function(label){
var element = d3.selectAll('rect[channel_legend="'+label+'"]');
if(element.attr('active') == 'yes'){
element.attr('active', 'no');
d3.selectAll('rect[channel_legend="'+label+'"]').attr('opacity', 0.5);
active_channel.splice(active_channel.indexOf(label), 1);
hide_all();
reshow_session_channel();
} else {
element.attr('active', 'yes');
var div = d3.selectAll('.legend_channel');
if(div.attr('active')=='no'){
div.attr('active', 'yes');
}
active_channel.push(label);
reshow_session_channel();
}
}
// Svg elements
legend_channel.selectAll("rect")
.data(channel_sorted)
.join("rect")
.attr('class', "channel_swatch")
.attr("channel_swatch", d => d)
.attr('width', 15)
.attr('height', 15)
.attr('fill', '#ddd')
//.attr('fill', d => color_scale_channel(d))
.attr('x',20)
.attr('y', (d,i) => 45+i*25);
legend_channel.selectAll("text")
.data(channel_sorted)
.join("text")
.attr('class', "channel_legend")
.attr("channel_legend", d => d)
.attr('height', 15)
.attr('fill', '#000')
.attr('x', 45)
.attr('y', (d,i) => 59+i*25)
.html(d => d);
var legend_rect_channel = legend_channel.append("g")
.selectAll("rect")
.data(channel_sorted)
.join("rect")
.attr('class','legend_selector_channel')
.attr('style', 'cursor: pointer;')
.attr('width', 300)
.attr('height', 18)
.attr('opacity', 0)
.attr('fill', '#fff')
.attr('x', 10)
.attr('y', (d,i) => 45+i*25)
.attr('channel_legend', d => d)
.attr('active','no')
legend_rect_channel.on('click', d => toggle_legend_channel(d));
legend_rect_channel.on('mouseover', d => {
if(active_channel.length == 0 & active_session.length == 0 & min_occ == 1 & min_occ_link == 1) hide_all();
show_channel(d);
});
legend_rect_channel.on('mouseout', d => {
reshow_session_channel();
});
legend_channel.append("text")
.attr('x', 25)
.attr('y', 30)
.append('tspan')
.html('Channels:')
.attr('font-weight','bold')
.append('tspan')
.html(' (most frequent first)')
.attr('font-weight','normal');
////// FILTERS
// Channel or Video
var color_video = function(){
d3.select('#color_video').attr('opacity', 1);
d3.select('#color_channel').attr('opacity', 0.5);
d3.select('#color_none').attr('opacity', 0.5);
d3.selectAll('.session_swatch').attr('fill', d => color_scale(d));
d3.selectAll('.channel_swatch').attr('fill', '#ddd');
d3.selectAll('.video_node').attr('fill', d => color_scale(d.session));
};
var color_channel = function(){
d3.select('#color_video').attr('opacity', 0.5);
d3.select('#color_channel').attr('opacity', 1);
d3.select('#color_none').attr('opacity', 0.5);
d3.selectAll('.session_swatch').attr('fill', '#ddd');
d3.selectAll('.channel_swatch').attr('fill', d => color_scale_channel(d));
d3.selectAll('.video_node').attr('fill', d => color_scale_channel(d.channel));
};
var color_none = function(){
d3.select('#color_video').attr('opacity', 0.5);
d3.select('#color_channel').attr('opacity', 0.5);
d3.select('#color_none').attr('opacity', 1);
d3.selectAll('.session_swatch').attr('fill', '#ddd');
d3.selectAll('.channel_swatch').attr('fill', '#ddd');
d3.selectAll('.video_node').attr('fill', '#999');
};
var filter_level_label = svg.append('g')
.attr('transform','translate('+ (300 - width/2) +','+ (30 - height/2) +')')
.append('text').html(`<tspan font-weight="bold">Color</tspan>:&nbsp;&nbsp;`);
filter_level_label.append('a').attr('style', 'cursor: pointer;').attr('id', 'color_video')
.html('&nbsp;&nbsp; Session &nbsp;&nbsp;&nbsp;').on('click', () => color_video());
filter_level_label.append('a').attr('style', 'cursor: pointer;').attr('id', 'color_channel')
.html('&nbsp;&nbsp;&nbsp; Channel &nbsp;&nbsp;').on('click', () => color_channel())
.attr('opacity', 0.5);
filter_level_label.append('a').attr('style', 'cursor: pointer;').attr('id', 'color_none')
.html('&nbsp;&nbsp;&nbsp; (none) &nbsp;&nbsp;').on('click', () => color_none())
.attr('opacity', 0.5);
// Levels of links
var link_l1 = function(){
show_l1 = !show_l1;
if(show_l1) d3.select('#filter_l1').attr('opacity', 1);
else d3.select('#filter_l1').attr('opacity', 0.5);
hide_all();
reshow_session_channel();
};
var link_l2 = function(){
show_l2 = !show_l2;
if(show_l2) d3.select('#filter_l2').attr('opacity', 1);
else d3.select('#filter_l2').attr('opacity', 0.5);
hide_all();
reshow_session_channel();
};
var link_l34 = function(){
show_l34 = !show_l34;
if(show_l34) d3.select('#filter_l34').attr('opacity', 1);
else d3.select('#filter_l34').attr('opacity', 0.5);
hide_all();
reshow_session_channel();
};
var filter_level_label = svg.append('g')
.attr('transform','translate('+ (300 - width/2) +','+ (60 - height/2) +')')
.append('text').html(`<tspan font-weight="bold">Filter recommendations</tspan>:`);
filter_level_label.append('a').attr('style', 'cursor: pointer;').attr('id', 'filter_l1')
.html('&nbsp;&nbsp; Watched &nbsp;&nbsp;').on('click', () => link_l1());
filter_level_label.append('a').attr('style', 'cursor: pointer;').attr('id', 'filter_l2')
.html('&rarr;&nbsp;&nbsp; Recommended &nbsp;&nbsp;').on('click', () => link_l2());
//filter_level_label.append('a').attr('style', 'cursor: pointer;').attr('id', 'filter_l34')
// .html('&rarr;&nbsp;&nbsp; Recommended from a recommendation').on('click', () => link_l34());
// Nodes
var min_occ_plus = function(){};
var min_occ_minus = function(){};
var reset_min_occ = function(){};
var filter_svg = svg.append('g').attr('transform','translate('+ (300 - width/2) +','+ (90 - height/2) +')');
var filter_node_label = filter_svg.append('text');
var min_occ_update = function(){
filter_node_label.html(`<tspan font-weight="bold">Filter nodes</tspan>: Min no. of occurences <tspan font-weight="bold">${min_occ}</tspan>`);
filter_node_label.append('a').attr('style', 'cursor: pointer;')
.html('&nbsp;&nbsp;&#x25B2;&nbsp;More').on('click', () => min_occ_plus());
filter_node_label.append('a').attr('style', 'cursor: pointer;')
.html('&nbsp;&nbsp;&#x25BC;&nbsp;Less').on('click', () => min_occ_minus());
filter_node_label.append('a').attr('style', 'cursor: pointer;')
.html('&nbsp;&nbsp;&nbsp;&nbsp;(resest)').on('click', () => reset_min_occ());
}
min_occ_plus = function(){
min_occ = min_occ +1;
min_occ_update();
hide_all();
reshow_session_channel();
};
min_occ_minus = function(){
min_occ = min_occ -1;
min_occ_update();
hide_all();
reshow_session_channel();
};
reset_min_occ = function(){
min_occ = 1;
min_occ_update();
hide_all();
reshow_session_channel();
};
min_occ_update();
// Links
var min_occ_plus_link = function(){};
var min_occ_minus_link = function(){};
var reset_min_occ_link = function(){};
var filter_svg_link = svg.append('g').attr('transform','translate('+(300-width/2)+','+(120-height/2)+')');
var filter_link_label = filter_svg_link.append('text');
var min_occ_update_link = function(){
filter_link_label.html(`<tspan font-weight="bold">Filter links</tspan>:&nbsp;&nbsp;&nbsp;Min no. of occurences <tspan font-weight="bold">${min_occ_link}</tspan>`);
filter_link_label.append('a').attr('style', 'cursor: pointer;')
.html('&nbsp;&nbsp;&#x25B2;&nbsp;More').on('click', () => min_occ_plus_link());
filter_link_label.append('a').attr('style', 'cursor: pointer;')
.html('&nbsp;&nbsp;&#x25BC;&nbsp;Less').on('click', () => min_occ_minus_link());
filter_link_label.append('a').attr('style', 'cursor: pointer;')
.html('&nbsp;&nbsp;&nbsp;&nbsp;(resest)').on('click', () => reset_min_occ_link());
}
min_occ_plus_link = function(){
min_occ_link = min_occ_link +1;
min_occ_update_link();
hide_all();
reshow_session_channel();
};
min_occ_minus_link = function(){
min_occ_link = min_occ_link -1;
min_occ_update_link();
hide_all();
reshow_session_channel();
};
reset_min_occ_link = function(){
min_occ_link = 1;
min_occ_update_link();
hide_all();
reshow_session_channel();
};
min_occ_update_link();

// Iteration
var min_occ_plus_iter = function(){};
var min_occ_minus_iter = function(){};
var reset_min_occ_iter = function(){};
var filter_svg_iter = svg.append('g').attr('transform','translate('+(300-width/2)+','+(150-height/2)+')');
var filter_iter_label = filter_svg_iter.append('text');
var min_occ_update_iter = function(){
var n_iter = min_occ_iter_new;
if(n_iter == 0) {
n_iter = '(All)';
}
filter_iter_label.html(`<tspan font-weight="bold">Filter iterations</tspan>:&nbsp;&nbsp;&nbsp;Show sessions until iteration <tspan font-weight="bold">${n_iter}</tspan>`);
filter_iter_label.append('a').attr('style', 'cursor: pointer;')
.html('&nbsp;&nbsp;&#x25B2;&nbsp;More').on('click', () => min_occ_plus_iter());
filter_iter_label.append('a').attr('style', 'cursor: pointer;')
.html('&nbsp;&nbsp;&#x25BC;&nbsp;Less').on('click', () => min_occ_minus_iter());
filter_iter_label.append('a').attr('style', 'cursor: pointer;')
.html('&nbsp;&nbsp;&nbsp;&nbsp;(resest)').on('click', () => reset_min_occ_iter());
}
min_occ_plus_iter = function(){
min_occ_iter_new = min_occ_iter_new +1;
min_occ_update_iter();
hide_all();
reshow_session_channel();
};
min_occ_minus_iter = function(){
min_occ_iter_new = min_occ_iter_new -1;
min_occ_update_iter();
hide_all();
reshow_session_channel();
};
reset_min_occ_iter = function(){
min_occ_iter_new = 0;
min_occ_update_iter();
hide_all();
reshow_session_channel();
};
min_occ_update_iter();
///// ANIMATION
simulation.on("tick", () => {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);

node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
});

invalidation.then(function () {
simulation.stop();
window.removeEventListener('resize', onresize);
});
///// DONE
return svg.node();
}
Insert cell
viewof t = Scrubber(d3.ticks(0, 200, 200), {
autoplay: false,
loop: false,
initial: 50,
format: x => `t = ${x.toFixed(0)}`
})
Insert cell
Insert cell
Insert cell
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
Insert cell
data = FileAttachment("results_v2_L2_pureSelenium_09_06_2020_exper.json").json();
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
color_scale_channel = d3.scaleOrdinal(channel, d3.schemeCategory10);
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
import {Scrubber} from "@mbostock/scrubber"
Insert cell
Insert cell
Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more