Public
Edited
May 26, 2023
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
//create a simulation for an array of nodes, and compose the desired forces.
simulation = d3.forceSimulation()
.force("link", d3.forceLink() // This force provides links between nodes
.id(d => d.id) // This sets the node id accessor to the specified function. If not specified, will default to the index of a node.
.distance(80)//120
)
.force("charge", d3.forceManyBody().strength(-200)) // This adds repulsion (if it's negative) between nodes.
.force("y", d3.forceY(height / 2))
// .force("boundary", forceBoundary(0, 0, width - 0, height - 0))
.force("x", d3.forceY(width / 2))
.force("collide", d3.forceCollide(30))
.force("center", d3.forceCenter(width / 2, height / 2)); // This force attracts nodes to the center of the svg area
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
viewof confirm = Inputs.button([["Add new node & link", confirmed => confirmed +1 ]], {value: 0, label:'confirmed'})
Insert cell
// viewof button = Inputs.button("Click me")
Insert cell
confirm
Insert cell
st_data
Insert cell
// Object.values(st_data.nodes).includes(existingNode)
Insert cell
myChart = {
const div = html`<div style='max-width: 2000px; overflow-x: auto; padding: 0px; margin: 0px;'></div>`;
const svg = d3.select(div)
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);

const subgraphWidth = width * 2 / 8;
const subgraphHeight = height * 1 / 5;

const subgraph = svg.append("g")
.attr("id", "subgraph")
.attr("transform", `translate(${width - subgraphWidth - 20}, 0)`);

subgraph.append("text")
.style("font-size", "16px")

//appending little triangles, path object, as arrowhead
//The <defs> element is used to store graphical objects that will be used at a later time
//The <marker> element defines the graphic that is to be used for drawing arrowheads or polymarkers on a given <path>, <line>, <polyline> or <polygon> element.
svg.append('defs').append('marker')
.attr("id", 'arrowhead')
.attr('viewBox', '-0 -5 10 10') //the bound of the SVG viewport for the current SVG fragment. defines a coordinate system 10 wide and 10 high starting on (0,-5)
.attr('refX', 24) // x coordinate for the reference point of the marker. If circle is bigger, this need to be bigger.
.attr('refY', 0)
.attr('orient', 'auto')
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('xoverflow', 'visible')
.append('svg:path')
.attr('d', 'M 0,-5 L 10 ,0 L 0,5')
.attr('fill', '#999')
.style('stroke', 'none');

svg.append("text")
.text("Stakeholders")
.attr("text-anchor", "middle")
.attr("x", width / 2)
.style("font-size", "20px")

//create some data
const dataset = createData(stakeholders_links)

console.log("dataset is ...", dataset);

// Initialize the links
const link = svg.selectAll(".links")
.data(dataset.links)
.enter()
.append("line")
.attr("class", "nodeLinks")
.attr("stroke", "#999")
// .attr("stroke-width", "2px")
.attr("stroke-width", d => d.weight)
.style("opacity", 0.8)
.attr("id", d => "line" + d.source + d.target)
//.attr('marker-end','url(#arrowhead)') //The marker-end attribute defines the arrowhead or polymarker that will be drawn at the final vertex of the given shape.


//The <title> element provides an accessible, short-text description of any SVG container element or graphics element.
//Text in a <title> element is not rendered as part of the graphic, but browsers usually display it as a tooltip.
link.append("title")
.text("type");

const edgepaths = svg.selectAll(".edgepath") //make path go along with the link provide position for link labels
.data(dataset.links)
.enter()
.append('path')
.attr('class', 'edgepath')
.attr('fill-opacity', 0)
.attr('stroke-opacity', 0)
.attr('id', function (d, i) { return 'edgepath' + i })
.style("pointer-events", "none");

const edgelabels = svg.selectAll(".edgelabel")
.data(dataset.links)
.enter()
.append('text')
.style("pointer-events", "none")
.attr('class', 'edgelabel')
.attr('id', function (d, i) { return 'edgelabel' + i })
.attr('font-size', 10)
.attr('fill', '#aaa');

// edgelabels.append('textPath') //To render text along the shape of a <path>, enclose the text in a <textPath> element that has an href attribute with a reference to the <path> element.
// .attr('xlink:href', function (d, i) {return '#edgepath' + i})
// .style("text-anchor", "middle")
// .style("pointer-events", "none")
// .attr("startOffset", "50%")
// .text(d => d.type);

// Initialize the nodes
const node = svg.selectAll(".nodes")
.data(dataset.nodes)
.enter()
.append("g")
.attr("class", "nodes")

node.call(d3.drag() //sets the event listener for the specified typenames and returns the drag behavior.
.on("start", dragstarted) //start - after a new pointer becomes active (on mousedown or touchstart).
.on("drag", dragged) //drag - after an active pointer moves (on mousemove or touchmove).
);

node.append("circle")
.attr("r", d => 25)//+ d.runtime/20 )
.attr("id", d => "circle" + d.id)
.classed("nodeCircle", true)
.style("stroke", "grey")
.style("stroke-opacity", 0.3)
.style("stroke-width", 5)
.style("fill", d => colorScale(d.group))

node.append("title")
.text(d => d.id + ": " + d.name + " - " + d.group);


let func = function (e) {
node.append("text")
.classed("nodeText", true)
.attr("id", d => "id" + d.id)
.attr("dy", 0)//4
.attr("dx", 0)//-15
.attr('text-anchor', 'middle')
.text(d => d.name)
.call(wrap, 55);
}
var timer = d3.timeout(func, 10);

// node.append("text")
// .attr("dy",12)
// .attr("dx", -8)
// .text(d=> d.runtime);

//set up dictionary of neighbors
var neighborTarget = {};
for(var i = 0; i<dataset.nodes.length; i++ ) {
var id = dataset.nodes[i].id;
neighborTarget[id] = dataset.links.filter(function (d) {
// console.log(d.source.id)
return d.source.id == id;
}).map(function (d) {
return d.target.id;
})
}
var neighborSource = {};
for (var i = 0; i < dataset.nodes.length; i++) {
var id = dataset.nodes[i].id;
neighborSource[id] = dataset.links.filter(function (d) {
return d.target.id == id;
}).map(function (d) {
return d.source.id;
})
}

console.log("neighborSource is ", neighborSource);
console.log("neighborTarget is ", neighborTarget);



///hover
var nodeSelected = null;
// d3.select("body").on("click", function(d){
// nodeSelected=null;
// d3.selectAll(".active").classed("active", false);
// d3.selectAll(".nodeCircle").transition().duration(300).style("opacity", 1);
// d3.selectAll(".nodeText").transition().duration(300).style("opacity", 1);
// d3.selectAll(".nodeLinks").transition().duration(300).style("opacity", 1);
// })

node.selectAll(".nodeCircle")
.on("mouseover", function (d) {
if (nodeSelected == null) {
var thisNode = d.id
var connected = dataset.links.filter(function (e) {
return e.source.id === thisNode || e.target.id === thisNode
});
d3.selectAll(".nodeLinks")
.transition().duration(300)
.style("opacity", function (d) {
// return (connected.map(d => d.source.id).indexOf(d.id) > -1 || connected.map(d => d.target.id).indexOf(d.id) > -1) ? 0.7 : 0.1
return (d.source.id == thisNode || d.target.id == thisNode) ? 1 : 0.05

});
d3.selectAll(".nodeCircle")
.transition().duration(300)
.style("opacity", function (d) {
return (connected.map(d => d.source.id).indexOf(d.id) > -1 || connected.map(d => d.target.id).indexOf(d.id) > -1) ? 0.7 : 0.1
});
d3.selectAll(".nodeText")
.transition().duration(300)
.style("opacity", function (d) {
return (connected.map(d => d.source.id).indexOf(d.id) > -1 || connected.map(d => d.target.id).indexOf(d.id) > -1) ? 0.7 : 0.1
});



d3.select(this).transition().duration(300).style('opacity', 1);
d3.selectAll("#id" + d.id + ".nodeText").transition().duration(300).style("opacity", 1);
// d3.select(this).attr('opacity', 1);
}
})

.on("mouseout", function (d) {
if (nodeSelected == null) {

var thisNode = d.id
d3.selectAll(".nodeCircle").transition().duration(300).style("opacity", 1);
d3.selectAll(".nodeText").transition().duration(300).style("opacity", 1);
d3.selectAll(".nodeLinks").transition().duration(300).style("opacity", 1);
}
})

.on("click", function (d) {

var selectedNode = d3.select(this)
if (selectedNode.attr("class") != "nodeCircle active") {
if (d3.selectAll(".active")._groups[0].length != 0) {
d3.selectAll(".active")
.classed("active", false);
}
selectedNode.classed("active", true);
nodeSelected = selectedNode
var thisNode = d.id
var connected = dataset.links.filter(function (e) {
return e.source.id === thisNode || e.target.id === thisNode
});
d3.selectAll(".nodeLinks").style("opacity", function (d) {
// return (connected.map(d => d.source.id).indexOf(d.id) > -1 || connected.map(d => d.target.id).indexOf(d.id) > -1) ? 0.7 : 0.1
return (d.source.id == thisNode || d.target.id == thisNode) ? 1 : 0.05

});
d3.selectAll(".nodeCircle").style("opacity", function (d) {
return (connected.map(d => d.source.id).indexOf(d.id) > -1 || connected.map(d => d.target.id).indexOf(d.id) > -1) ? 0.7 : 0.1
});
d3.selectAll(".nodeText").style("opacity", function (d) {
return (connected.map(d => d.source.id).indexOf(d.id) > -1 || connected.map(d => d.target.id).indexOf(d.id) > -1) ? 0.7 : 0.1
});



d3.select(this).style('opacity', 1);
d3.selectAll("#id" + d.id + ".nodeText").style("opacity", 1);
} else {
nodeSelected = null;
selectedNode.classed("active", false);
}
if (d3.selectAll(".active")._groups[0].length == 0) {
subgraph.selectAll("text")
.transition().duration(300)
.text("")
.attr("dy", 14)
.attr("dx", 10)
.style("font-size", "14px")
.attr('text-anchor', 'start')
.call(wrap, 200);
} else {
subgraph.selectAll("text")
.transition().duration(300)
.text("Selected: " + d.name)
.attr("dy", 14)
.attr("dx", 10)
.style("font-size", "14px")
.attr('text-anchor', 'start')
.call(wrap, 200);
}

})



//Listen for tick events to render the nodes as they update in your Canvas or SVG.
simulation
.nodes(dataset.nodes)
.on("tick", ticked);

simulation.force("link")
.links(dataset.links);

if (confirm) {
var existSource = false
//add node
if (!existSource) {
console.log("insert new node");
stakeholders_nodes.push({
id: st_data.nodes.length,
name: newNode,
group: group,
index: st_data.nodes.length,

// weight: 0
});
}

//add link
stakeholders_links.push({
"source": newNode,
"target": existingNode,
"weight": 1,
"distance": weight,
index: st_data.links.length,

});
simulation
.nodes(dataset.nodes)
.on("tick", ticked);

simulation.force("link")
.links(dataset.links);

// simulation.restart()
// return "do this" + confirm
} else {
// return "do nothing"
}


// This function is run at each iteration of the force algorithm, updating the nodes position (the nodes data array is directly manipulated).
function ticked() {
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("transform", d => `translate(${d.x},${d.y})`);

edgepaths.attr('d', d => 'M ' + d.source.x + ' ' + d.source.y + ' L ' + d.target.x + ' ' + d.target.y);
}

//When the drag gesture starts, the targeted node is fixed to the pointer
//The simulation is temporarily “heated” during interaction by setting the target alpha to a non-zero value.
function dragstarted(d) {
if (!d3.event.active) simulation.alphaTarget(0.3).restart();//sets the current target alpha to the specified number in the range [0,1].
d.fy = d.y; //fx - the node’s fixed x-position. Original is null.
d.fx = d.x; //fy - the node’s fixed y-position. Original is null.
}

//When the drag gesture starts, the targeted node is fixed to the pointer
function dragged(d) {
d.fx = d3.event.x;
d.fy = d3.event.y;
}

//drawing the legend
const legend_g = svg.selectAll(".legend")
.data(colorScale.domain())
.enter().append("g")
.attr("transform", (d, i) => `translate(${width - 50},${i * 20})`);

legend_g.append("circle")
.attr("cx", 0)
.attr("cy", 0)
.attr("r", 5)
.attr("fill", colorScale);

legend_g.append("text")
.attr("x", 10)
.attr("y", 5)
.text(d => d);



return div
}
Insert cell
d3.dijkstra = function () {
var dijkstra = {}, nodes, edges, source, dispatch = d3.dispatch("start", "tick", "step", "end");
dijkstra.run = function (src) {
source = src;
var unvisited = [];

nodes.forEach(function (d) {
if (d != src) {
d.distance = Infinity;
unvisited.push(d);
d.visited = false;
}
});
var current = src;
current.distance = 0;

function tick() {
current.visited = true;
current.links.forEach(function(link) {
var tar = link.target;
if (!tar.visited) {
var dist = current.distance + link.value;
tar.distance = Math.min(dist, tar.distance);
}
});
if (unvisited.length == 0 || current.distance == Infinity) {
dispatch.end()
return true;
}
unvisited.sort(function(a, b) {
return b.distance - a.distance
});

current = unvisited.pop()

dispatch.tick();
return false;
}

d3.timer(tick);
};
dijkstra.nodes = function (_) {
if (!arguments.length)
return nodes;
else {
nodes = _;
return dijkstra;
}
};
dijkstra.edges = function (_) {
if (!arguments.length)
return edges;
else {
edges = _;
return dijkstra;
}
};

dijkstra.source = function(_) {
if (!arguments.length)
return source;
else {
source = _;
return dijkstra;
}
};

dispatch.on("start.code", dijkstra.run);
return d3.rebind(dijkstra, dispatch, "on", "end", "start", "tick");
};
Insert cell
myChart1={
const div = html`<div style='max-width: 2000px; overflow-x: auto; padding: 0px; margin: 0px;'></div>`;
const svg = d3.select(div)
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const subgraphWidth = width*2/8;
const subgraphHeight = height*1/5;

const subgraph = svg.append("g")
.attr("id", "subgraph")
.attr("transform", `translate(${width - subgraphWidth - 20}, 0)`);
subgraph.append("text")
.style("font-size","16px")
//appending little triangles, path object, as arrowhead
//The <defs> element is used to store graphical objects that will be used at a later time
//The <marker> element defines the graphic that is to be used for drawing arrowheads or polymarkers on a given <path>, <line>, <polyline> or <polygon> element.
svg.append('defs').append('marker')
.attr("id",'arrowhead')
.attr('viewBox','-0 -5 10 10') //the bound of the SVG viewport for the current SVG fragment. defines a coordinate system 10 wide and 10 high starting on (0,-5)
.attr('refX',24) // x coordinate for the reference point of the marker. If circle is bigger, this need to be bigger.
.attr('refY',0)
.attr('orient','auto')
.attr('markerWidth',6)
.attr('markerHeight',6)
.attr('xoverflow','visible')
.append('svg:path')
.attr('d', 'M 0,-5 L 10 ,0 L 0,5')
.attr('fill', '#999')
.style('stroke','none');
svg.append("text")
.text("Stakeholders")
.attr("text-anchor","middle")
.attr("x",width/2)
.style("font-size","20px")
//create some data
const dataset= st_data

console.log("dataset is ...",dataset);

// Initialize the links
const link = svg.selectAll(".links")
.data(dataset.links)
.enter()
.append("line")
.attr("class", "links")
.attr("stroke","#999")
.attr("stroke-width","2px")
.style("opacity", 0.8)
.attr("id",d=> "line"+d.source+d.target)
.attr("class", "links")
//.attr('marker-end','url(#arrowhead)') //The marker-end attribute defines the arrowhead or polymarker that will be drawn at the final vertex of the given shape.


//The <title> element provides an accessible, short-text description of any SVG container element or graphics element.
//Text in a <title> element is not rendered as part of the graphic, but browsers usually display it as a tooltip.
link.append("title")
.text("type");

const edgepaths = svg.selectAll(".edgepath") //make path go along with the link provide position for link labels
.data(dataset.links)
.enter()
.append('path')
.attr('class', 'edgepath')
.attr('fill-opacity', 0)
.attr('stroke-opacity', 0)
.attr('id', function (d, i) {return 'edgepath' + i})
.style("pointer-events", "none");

const edgelabels = svg.selectAll(".edgelabel")
.data(dataset.links)
.enter()
.append('text')
.style("pointer-events", "none")
.attr('class', 'edgelabel')
.attr('id', function (d, i) {return 'edgelabel' + i})
.attr('font-size', 10)
.attr('fill', '#aaa');

// edgelabels.append('textPath') //To render text along the shape of a <path>, enclose the text in a <textPath> element that has an href attribute with a reference to the <path> element.
// .attr('xlink:href', function (d, i) {return '#edgepath' + i})
// .style("text-anchor", "middle")
// .style("pointer-events", "none")
// .attr("startOffset", "50%")
// .text(d => d.type);
// Initialize the nodes
const node = svg.selectAll(".nodes")
.data(dataset.nodes)
.enter()
.append("g")
.attr("class", "nodes")

node.call(d3.drag() //sets the event listener for the specified typenames and returns the drag behavior.
.on("start", dragstarted) //start - after a new pointer becomes active (on mousedown or touchstart).
.on("drag", dragged) //drag - after an active pointer moves (on mousemove or touchmove).
);

node.append("circle")
.attr("r", d=> 25)//+ d.runtime/20 )
.attr("id",d=> "circle"+d.id)
.style("stroke", "grey")
.style("stroke-opacity",0.3)
.style("stroke-width", 5)
.style("fill", d => colorScale(d.group))

node.append("title")
.text(d => d.id + ": " + d.name + " - " + d.group );


let func = function (e) {
node.append("text")
.classed("nodeText",true)
.attr("dy", 0)//4
.attr("dx", 0)//-15
.attr('text-anchor', 'middle')
.text(d => d.name)
.call(wrap, 55);
}
var timer = d3.timeout(func, 10);
// node.append("text")
// .attr("dy",12)
// .attr("dx", -8)
// .text(d=> d.runtime);

//set up dictionary of neighbors
var neighborTarget= {};
for (var i=0; i < dataset.nodes.length; i++ ){
var id = dataset.nodes[i].id;
neighborTarget[id] = dataset.links.filter(function(d){
return d.source == id;
}).map(function(d){
return d.target;
})
}
var neighborSource = {};
for (var i=0; i < dataset.nodes.length; i++ ){
var id = dataset.nodes[i].id;
neighborSource[id] = dataset.links.filter(function(d){
return d.target == id;
}).map(function(d){
return d.source;
})
}
console.log("neighborSource is ",neighborSource);
console.log("neighborTarget is ",neighborTarget);
node.selectAll("circle").on("click",function(d){
console.log(this)
var active = d.active? false : true // toggle whether node is active
, newStroke = active ? "#014058":"grey"
, newStrokeIn = active ? "#07728F":"grey"
, newStrokeOut = active? "#549AA9": "grey"
, newOpacity = active? 0.6: 0.3
, subgraphOpacity = active? 0.2:0.9;

subgraph.selectAll("text")
.text("Selected: " +d.name)
.attr("dy",14)
.attr("dx",100)
.style("font-size", "14px")
.attr('text-anchor', 'end')
.call(wrap, 200);

//extract node's id and ids of its neighbors
var id =d.id
, neighborS = neighborSource[id]
, neighborT = neighborTarget[id];

d3.selectAll(".nodes").style("opacity",subgraphOpacity)
d3.selectAll("line").style("opacity",subgraphOpacity)
d3.selectAll(".nodeText").style("opacity",subgraphOpacity)

// .filter(function(d) { return allNodeIDs.indexOf(d.source.id) > -1 && allNodeIDs.indexOf(d.target.id) > -1; })

console.log("neighbors is from ",neighborS," to ", neighborT);
d3.selectAll("#circle"+id).style("stroke-opacity", newOpacity);
d3.selectAll("#circle"+id).style("stroke", newStroke);

//highlight the current node and its neighbors
for (var i =0; i < neighborS.length; i++){
d3.selectAll("#line"+neighborS[i]+id).style("stroke", newStrokeIn).style("stroke-opacity",newOpacity);
d3.selectAll("#circle"+neighborS[i]).style("stroke-opacity", newOpacity).style("opacity",subgraphOpacity).style("stroke", newStrokeIn).classed("neighbor", true);
}
for (var i =0; i < neighborT.length; i++){
d3.selectAll("#line"+id+neighborT[i]).style("stroke", newStrokeOut).style("stroke-opacity",newOpacity);
d3.selectAll("#circle"+neighborT[i]).style("stroke-opacity", newOpacity).style("opacity",subgraphOpacity).style("stroke", newStrokeOut).classed("neighbor", true);
}


// .filter(function(d) { return allNodeIDs.indexOf(d.source.id) > -1 && allNodeIDs.indexOf(d.target.id) > -1; })

//update whether or not the node is active
d.active =active;
})

//Listen for tick events to render the nodes as they update in your Canvas or SVG.
simulation
.nodes(dataset.nodes)
.on("tick", ticked);

simulation.force("link")
.links(dataset.links);


// This function is run at each iteration of the force algorithm, updating the nodes position (the nodes data array is directly manipulated).
function ticked() {
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("transform", d => `translate(${d.x},${d.y})`);

edgepaths.attr('d', d => 'M ' + d.source.x + ' ' + d.source.y + ' L ' + d.target.x + ' ' + d.target.y);
}

//When the drag gesture starts, the targeted node is fixed to the pointer
//The simulation is temporarily “heated” during interaction by setting the target alpha to a non-zero value.
function dragstarted(d) {
if (!d3.event.active) simulation.alphaTarget(0.3).restart();//sets the current target alpha to the specified number in the range [0,1].
d.fy = d.y; //fx - the node’s fixed x-position. Original is null.
d.fx = d.x; //fy - the node’s fixed y-position. Original is null.
}

//When the drag gesture starts, the targeted node is fixed to the pointer
function dragged(d) {
d.fx = d3.event.x;
d.fy = d3.event.y;
}
//drawing the legend
const legend_g = svg.selectAll(".legend")
.data(colorScale.domain())
.enter().append("g")
.attr("transform", (d, i) => `translate(${width-50},${i * 20})`);

legend_g.append("circle")
.attr("cx", 0)
.attr("cy", 0)
.attr("r", 5)
.attr("fill", colorScale);

legend_g.append("text")
.attr("x", 10)
.attr("y", 5)
.text(d => d);

return div
}
Insert cell
Insert cell
stakeholders_nodes_ids@3.csv
Type Table, then Shift-Enter. Ctrl-space for more options.

Insert cell
Insert cell
st_data= createData(stakeholders_links)

Insert cell
forceBoundary = require("d3-force-boundary")
Insert cell
function createData(data) {
let graph_data = {
"nodes": stakeholders_nodes,
"links": []
}
stakeholders_links.forEach((obj_list, indx) => {
//console.log(obj1, obj2)
let index_obj1= stakeholders_nodes.findIndex(function(item, i){
return item.name === obj_list.source
});
let index_obj2= stakeholders_nodes.findIndex(function(item, i){
return item.name === obj_list.target
});
console.log(obj_list.source, index_obj1, obj_list.target, index_obj2)
// console.log(stakeholders_nodes[index_obj1].group, stakeholders_nodes[index_obj2].group)
let group1 = stakeholders_nodes[index_obj1].group;
let group2 = stakeholders_nodes[index_obj2].group;

graph_data["links"].push({
"source": index_obj1,
"target": index_obj2,
"weight": group1 == group2 ? 4:1, //smaller distance if within the same group
"distance": group1 == group2 ? 5:10 //smaller distance if within the same group
//"value": 8
});
})

return graph_data
}
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