Public
Edited
Feb 13, 2024
Insert cell
Insert cell
Insert cell
chart = {
if (document.getElementById("container")) {
//define initial settings
const width = window.innerWidth;
const height = width * 0.85;
const nodeSize = 3;
const strokeWidth = 1;
const speedOptions = { fast: 0.3, medium: 0.5, slow: 0.7 };

//grab input elements manually
let speedSetInput = document.getElementById("speedSetInput");
let numStepsInput = document.getElementById("numStepsInput");
let colorFilterInput = document.getElementById("colorFilterInput");
let convertFilterInput = document.getElementById("convertFilterInput");

//declare global vars
let timelineGroup;
let timelineLine;
let timelineDots;
let simulation;
let node;
let intervalId = "";

//get the last step possible in data and set the "number of steps" input max
let maxPossibleStepsArr = Object.keys(nodeArrayTotal[0])
.filter((d) => d.match("step"))
.map((d) => parseInt(d.replace("step", "")));
let maxPossibleSteps = d3.max(maxPossibleStepsArr);
if (numStepsInput != null) {
numStepsInput.max = String(maxPossibleSteps);
}

//define zones
let spaces = [
{ value: "group1", id: 1 },
{ value: "group2", id: 2 },
{ value: "group3", id: 3 },
{ value: "group4", id: 4 },
{ value: "group5", id: 5 },
{ value: "group6", id: 6 },
{ value: "group7", id: 7 },
{ value: "group8", id: 8 }
];

//set global step function
//change timeline when there's a change in step
let step = 1;
function currentStep(step) {
timelineDots.attr("fill", (e) => (e == step ? "darkgrey" : "white"));
return `step${step}`;
}

//calculate where each zone should go and set position
spaces.forEach(function (data, i) {
let theta = (2 * Math.PI) / spaces.length;
data["x"] = (width / 4) * Math.cos((i - 1) * theta) + width / 2.5;
data["y"] = (width / 4.5) * Math.sin((i - 1) * theta) + height / 2;
});
//add the extra zones manually
spaces.push({ value: "group0", id: 10, x: width / 2.5, y: height / 2 });
spaces.push({
value: "Continue",
id: 11,
x: width / 7 + width / 1.4,
y: height - height / 5.5
});
spaces.push({
value: "Exit",
id: 0,
x: width / 7 + width / 1.4,
y: height / 5.5
});

//color scale for nodes
const colorScale = d3
.scaleSequential(d3.interpolateTurbo)
.domain(d3.extent(spaces.map((d) => d.id)).reverse());

//declare svg container
const svg = d3
.select("#container")
.append("svg")
.attr("width", width)
.attr("height", height);

//declare main svg container
const spaceContainer = svg
.append("g")
.attr("id", "spaceContainer")
.selectAll("g")
.data(spaces)
.enter()
.append("g");

//add a nice gradient for the zones
let defs = svg.append("defs").attr("id", "maskDefs");
let gradient = defs.append("radialGradient").attr("id", "fadient");

//define radialGradient
gradient
.append("stop")
.attr("offset", "50%")
.attr("stop-color", "white")
.attr("stop-opacity", 1);
gradient
.append("stop")
.attr("offset", "100%")
.attr("stop-color", "white")
.attr("stop-opacity", 0);

//use a mask to make gradient usable
let mask = defs
.append("mask")
.attr("id", "mask")
.attr("maskContentUnits", "objectBoundingBox")
.append("circle")
.attr("fill", "url(#fadient)")
.attr("cx", 0.5)
.attr("cy", 0.5)
.attr("r", 0.5);

//add defind circles with associated mask
let bgCircles = spaceContainer
.append("circle")
.attr("cx", (d) => d.x)
.attr("cy", (d) => d.y)
.attr("r", width / 13)
.attr("mask", "url(#mask)")
.attr("fill", (d) => colorScale(d.id))
.attr("fill-opacity", 0.4);

//add text for zones
let bgText = spaceContainer
.append("text")
.text((d) => d.value)
.attr("x", (d) => d.x)
.attr("y", (d) => d.y)
.attr("dy", -width * 0.05)
.attr("text-anchor", "middle")
.attr("font-size", 17)
.attr("style", "font-family: sans-serif;")
.attr("fill", (d) => d3.color(colorScale(d.id)).darker(1.5));

//define node color function based on selection
const nodeColorScaleFunction = (d, maxSteps) => {
let colorFilterResult = colorFilterInput.value;

if (colorFilterResult == "convertFlag") {
return d.convert_flag == "1" ? "purple" : "lightblue";
}
if (colorFilterResult == "firstZone") {
return colorScale(
spaces.filter((e) => e.value == d[currentStep(1)])[0].id
);
}
if (colorFilterResult == "lastZone") {
return colorScale(
spaces.filter((e) => e.value == d[currentStep(maxSteps)])[0].id
);
}
};

//define the render nodes function
const render = () => {
let maxStepsArray;

//create a copy of the array to use and filter
let nodeArray = [...nodeArrayTotal];

//filter the data based on filter
if (convertFilterInput.value == "converters") {
nodeArray = nodeArray.filter((d) => d.convert_flag == "1");
}
if (convertFilterInput.value == "non-converters") {
nodeArray = nodeArray.filter((d) => d.convert_flag == "0");
}

//filter the data based on maximum number of steps
let maxSteps = parseInt(document.getElementById("numStepsInput").value);
//iterate through nodes to assign position
nodeArray.forEach((d, index, object) => {
let nextStepLocal = `step${maxSteps + 1}`;
let StepLocal = `step${maxSteps}`;

//if next step exists...
if (d.hasOwnProperty(nextStepLocal)) {
//if next step is exit, apply exit value to last step
if (d[nextStepLocal] == "Exit") {
d[StepLocal] = "Exit";
}
//if not, add continue
else {
d[StepLocal] = "Continue";
}
}

//filter based on next step
maxPossibleStepsArr
.filter((f) => f > maxSteps)
.forEach((e) => {
let stepLocal2 = `step${e}`;
delete object[index][stepLocal2];
});
});

//update counter based on current/max steps
document.getElementById("counter").innerText = [step, maxSteps].join("/");

//create timeline
svg.selectAll("#timelineGroup").remove();
timelineGroup = svg.append("g").attr("id", "timelineGroup");

//define maxSteps
maxStepsArray = d3.range(1, maxSteps + 1);

//create timeline scale
let timelineXScale = d3
.scaleLinear()
.domain([1, d3.max(maxStepsArray)])
.range([25, 400])
.nice();

//create timeline line
timelineLine = timelineGroup
.append("line")
.attr("x1", timelineXScale([1]))
.attr("x2", timelineXScale([maxSteps]) + 2)
.attr("y1", 12)
.attr("y2", 12)
.style("stroke-width", "2px")
.style("stroke", "lightgrey");

//create timeline dots
timelineDots = timelineGroup
.selectAll("circle")
.data(maxStepsArray)
.enter()
.append("circle")
.attr("r", 5)
.attr("fill", "white")
.style("stroke", "lightgrey")
.style("stroke-width", "2px")
.attr("cx", (d) => timelineXScale(d))
.attr("cy", 12)
.attr("id", (d) => d)
.style("cursor", "pointer")

.on("mousedown", function (f) {
console.log(f.target.id);
let clickedNode = f.target.id;
clearInterval(intervalId);
step = clickedNode;
currentStep(clickedNode);
moveNodes();
});

//declare actual pathing nodes with styles
node = svg
.append("g")
.attr("id", "nodeGroup")
.selectAll("circle")
.data(nodeArray)
.enter()
.append("circle")
.attr("r", nodeSize)
.style("fill", (d) => nodeColorScaleFunction(d, maxSteps))
.style("fill-opacity", 0)
.attr("stroke", "black")
.style("stroke-width", strokeWidth)
.style("stroke-opacity", 0)
.attr("class", "node");

//fade in nodes
node
.transition()
.duration(2000)
.style("stroke-opacity", 1)
.style("fill-opacity", 1);

//declare simulation with charge/collision/positions
simulation = d3
.forceSimulation()
.force("charge", d3.forceManyBody().strength(nodeSize * -0.5))
.force(
"collision",
d3.forceCollide().radius(nodeSize + strokeWidth - 1)
)
.force(
"x",
d3
.forceX()
.x(
(d) => spaces.filter((e) => e.value == d[currentStep(step)])[0].x
)
)
.force(
"y",
d3
.forceY()
.y(
(d) => spaces.filter((e) => e.value == d[currentStep(step)])[0].y
)
)
.nodes(nodeArray)
.on("tick", function (d) {
node.attr("cx", (d) => d.x).attr("cy", (d) => d.y);
});

//button click (next)
d3.select("#next").on("click", function () {
clearInterval(intervalId);
if (step + 1 <= maxSteps) {
currentStep(step++);
moveNodes();
}
});
//button click (previous)
d3.select("#previous").on("click", function () {
clearInterval(intervalId);
if (step - 1 >= 1) {
currentStep(step--);
moveNodes();
}
});
//button click (reset)
d3.select("#reset").on("click", function () {
clearInterval(intervalId);
step = 1;
currentStep(step);
moveNodes();
});
//button click (play)
d3.select("#play").on("click", function () {
if (step + 1 <= maxSteps) {
currentStep(step++);
moveNodes();
} else {
step = 1;
currentStep(step);
moveNodes();
}
//update 2sec interval
intervalId = window.setInterval(function () {
if (step + 1 <= maxSteps) {
currentStep(step++);
moveNodes();
}
}, 2000);
});

//declare the moveNodes function
function moveNodes() {
//reheat the simulation
simulation
.alpha(0.3)
.alphaTarget(0.3)
.velocityDecay(speedOptions[speedSetInput.value])
.restart();

//re-init the positions
simulation.force("x").initialize(nodeArray);
simulation.force("y").initialize(nodeArray);

//update counter
document.getElementById("counter").innerText = [step, maxSteps].join(
"/"
);
}
};
//end of render()

//filter click (convert)
d3.select("#convertFilterInput").on("input", function () {
clearInterval(intervalId);
step = 1;
currentStep(step);
node.transition().duration(2000).attr("r", 0).remove();
svg.selectAll("#nodeGroup").transition().duration(2000).remove();
simulation.stop();
render();
});
//filter click (color)
d3.select("#colorFilterInput").on("input", function () {
clearInterval(intervalId);
step = 1;
currentStep(step);
node.transition().duration(2000).attr("r", 0).remove();
svg.selectAll("#nodeGroup").transition().duration(2000).remove();
simulation.stop();
render();
});
//filter click (steps)
d3.select("#numStepsInput").on("change", function () {
clearInterval(intervalId);
step = 1;
currentStep(step);
node.transition().duration(2000).attr("r", 0).remove();
svg.selectAll("#nodeGroup").transition().duration(2000).remove();
simulation.stop();
render();
});

return render();
}
}
Insert cell
Insert cell
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