Public
Edited
May 2
Fork of Untitled
Insert cell
Insert cell
chart = {
// Define symbol shapes based on review count ranges (adjusted for 1320–2000)
const reviewRanges = [
{ min: 0, max: 1300, symbol: d3.symbolCircle }, // < 3.25 rating
{ min: 1301, max: 1500, symbol: d3.symbolSquare }, // 3.26–3.75
{ min: 1501, max: 1700, symbol: d3.symbolTriangle }, // 3.76–4.25
{ min: 1701, max: 1900, symbol: d3.symbolCross }, // 4.26–4.75
{ min: 1901, max: 2000, symbol: d3.symbolDiamond }, // 4.76–5.0
{ min: 2001, max: Infinity, symbol: d3.symbolStar } // > 5.0 (unlikely)
];

// Function to get symbol type based on review count
const getSymbol = reviews => {
const range = reviewRanges.find(r => reviews >= r.min && reviews <= r.max);
return range ? range.symbol : d3.symbolCircle; // Default to circle
};

// Preprocess data to flatten hierarchy and add synthetic reviews
function cleanData(node) {
if (!node || typeof node !== "object") return null;
node.name = node.name || "Unnamed";
// Flatten "rating" nodes and collect establishment nodes
if (node.children) {
const establishments = [];
node.children.forEach(ratingNode => {
if (ratingNode.children) {
ratingNode.children.forEach(child => {
if (child && typeof child === "object") {
child.name = child.name || "Unnamed";
child.value = Number.isFinite(child.value) ? child.value : 1;
// Synthetic reviews: rating * 400 (3.3–5.0 → 1320–2000)
child.reviews = Number.isFinite(child.value) ? Math.round(child.value * 400) : 0;
child.group = child.group || "default";
delete child.children; // Ensure leaf nodes
establishments.push(child);
}
});
}
});
node.children = establishments.filter(child => child !== null);
if (node.children.length === 0) delete node.children;
} else {
delete node.children;
}
node.reviews = Number.isFinite(node.reviews) ? Math.max(0, node.reviews) : 0;
node.value = Number.isFinite(node.value) ? node.value : 1;
node.group = node.group || "default";
return node;
}

// Clean the input data
const cleanedData = cleanData(JSON.parse(JSON.stringify(data))); // Deep copy
if (!cleanedData || !cleanedData.name) {
throw new Error("Invalid data: Root node must have a name and be an object");
}

// Create hierarchy
const root = tree(d3.hierarchy(cleanedData)
.sort((a, b) => d3.descending(a.data.value, b.data.value)) // Sort by rating
// .sort((a, b) => a.data.name.toLowerCase().localeCompare(b.data.name.toLowerCase())) // Alphabetical
// .sort((a, b) => d3.descending(a.data.reviews, b.data.reviews)) // By reviews
);

// Debugging
console.log("Cleaned data:", cleanedData);
console.log("Hierarchy root:", root);
root.descendants().forEach(d => {
if (!d.data) console.warn("Node missing data:", d);
});

setColor(root);

const svg = d3.create("svg");

// Draw links between nodes
svg.append("g")
.attr("fill", "none")
.attr("stroke", "grey")
.attr("stroke-opacity", 0.4)
.attr("stroke-width", 1.5)
.selectAll("path")
.data(root.links())
.join("path")
.attr("d", d3.linkRadial()
.angle(d => d.x)
.radius(d => d.y))
.each(function(d) { d.target.linkNode = this; })
.attr("stroke", d => d.target.data ? color(d.target.data.name) : "grey");

// Draw nodes as symbols based on reviews
svg.append("g")
.selectAll("path")
.data(root.descendants())
.join("path")
.attr("transform", d => `
rotate(${d.x * 180 / Math.PI - 90})
translate(${d.y},0)
`)
.attr("fill", d => d.data ? color(d.data.name) : "#000")
.attr("d", d => d.data ? d3.symbol()
.type(d.children ? d3.symbolCircle : getSymbol(d.data.reviews || 0))
.size(d => d.children ? 50 : CirSize(d.data.value) * 10)() : "")
.append("title")
.text(d => d.data ? `Name: ${d.data.name}\nRating: ${d.data.value || 0}\nReviews: ${d.data.reviews || 0}` : "Invalid node");

// Add text labels
svg.append("g")
.attr("font-family", "sans-serif")
.attr("font-size", 12)
.attr("stroke-linejoin", "round")
.attr("stroke-width", 3)
.selectAll("text")
.data(root.descendants())
.join("text")
.attr("transform", d => `
rotate(${d.x * 180 / Math.PI - 90})
translate(${d.y},0)
rotate(${d.x >= Math.PI ? 180 : 0})
`)
.attr("dy", "0.31em")
.attr("x", d => d.x < Math.PI === !d.children ? 6 : -6)
.attr("text-anchor", d => d.x < Math.PI === !d.children ? "start" : "end")
.text(d => d.data ? d.data.name : "Unnamed")
.clone(true).lower()
.attr("stroke", "white");

// Add legend for review ranges
const legend = svg.append("g")
.attr("transform", "translate(-100, -100)");
reviewRanges.forEach((range, i) => {
legend.append("path")
.attr("d", d3.symbol().type(range.symbol).size(50)())
.attr("transform", `translate(0, ${i * 20})`)
.attr("fill", "black");
legend.append("text")
.attr("x", 10)
.attr("y", i * 20 + 5)
.attr("font-family", "sans-serif")
.attr("font-size", 10)
.text(`${range.min}–${range.max} reviews`);
});

return svg.attr("viewBox", autoBox).node();
}
Insert cell
md `## Radial Dendrogram Color Setting
Contents of the domain part of "color" variables are the names of topics. There are 16 topics and this is the domain of the following cell is "Topic 00" to "Topic 15".`
Insert cell
//get the min and max values of the topic word weightings. These values are obtained during the process of topic modeling. So, it is also possible to get the values from python and change the domain parts of CirSize and CircleAlpha manually.
//point to your data value
toppicData = data.children.map(d => d.rating)
Insert cell
color = d3.scaleOrdinal()
.domain(toppicData)
.range(d3.schemeCategory10)
Insert cell
// Set the color of each node by recursively inheriting.
function setColor(d) {
var rating = d.data.rating;
d.color = color.domain().indexOf(rating) >= 0 ? color(rating) : d.parent ? d.parent.color : null;
if (d.children) d.children.forEach(setColor);
}
Insert cell
function autoBox() {
document.body.appendChild(this);
const {x, y, width, height} = this.getBBox();
document.body.removeChild(this);
return [x, y, width, height];
}
Insert cell
md `## Data File for the Radial Dendrogram
The data file is in json format. The json file can be generated by following the python tutorial introduced in the introduction of this radial dendrogram.`
Insert cell
data = FileAttachment("myYelpData.json").json()
Insert cell
//get the min and max values of the topic word weightings. These values are obtained during the process of topic modeling. So, it is also possible to get the values from python and change the domain parts of CirSize and CircleAlpha manually.
function getMinMaxvalues() {
let mappedData = data.children.map(d => d.children)
var allthedata = []
for (let i = 0; i < mappedData.length; i++) {
let j = i * 2;
allthedata[j] = d3.min(mappedData[i].map(v => v.value));
allthedata[j+1] = d3.max(mappedData[i].map(v => v.value));
}
return allthedata;
}
Insert cell
//define circle sizes by using getMinMaxvalues(). It is also possible to use the values obtained from python. In this case, it is not needed to define getMinMaxvalues().
//CirSize = d3.scaleSqrt().domain([d3.min(getMinMaxvalues()),d3.max(getMinMaxvalues())]).range([1,8])
//CirSize = d3.scaleSqrt().domain([d3.min(data.children, d => d3.min(d.children, v => v.value)), d3.max(data.children, d => d3.max(d.children, v => v.value))]).range([1, 8])
CirSize = d3.scaleSqrt().domain([
d3.min(data.children.filter(d => d.children && d.children.length > 0), d => d3.min(d.children, v => v.value)),
d3.max(data.children.filter(d => d.children && d.children.length > 0), d => d3.max(d.children, v => v.value))
]).range([1, 8])
Insert cell
//define circle opacities by using getMinMaxvalues()
//CircleAlpha = d3.scaleLinear().domain([d3.min(getMinMaxvalues()),d3.max(getMinMaxvalues())]).range([0.3,1])
//CircleAlpha = d3.scaleLinear().domain([d3.min(data.children, d => d3.min(d.children, v => v.value)), d3.max(data.children, d => d3.max(d.children, v => v.value))]).range([0.3, 1])
CircleAlpha = d3.scaleLinear().domain([
d3.min(data.children.filter(d => data.children && data.children.length > 0), d => d3.min(d.children, v => v.value)),
d3.max(data.children.filter(d => data.children && data.children.length > 0), d => d3.max(d.children, v => v.value))
]).range([0.3, 1])
Insert cell
width = 975
Insert cell
radius = width / 2
Insert cell
tree = d3.cluster().size([2 * Math.PI, radius - 100])
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