Published
Edited
Nov 2, 2020
1 star
Insert cell
Insert cell
Insert cell
graph = {
const simLinkBaseLength = 100; // length of each link
const simLinkMinLength = 50;
const simulation = d3.forceSimulation(officerInfo)
.force("link", d3.forceLink(officerLinks)
.id((node) => node.id)
.distance((link) => Math.max(simLinkBaseLength - link.count * 1.5,
simLinkMinLength)))
.force("charge", d3.forceManyBody())
.force("center", d3.forceCenter(width / 2, height / 2));
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height]);
const svgg = svg.append('g');
svg.call(zoomInit, svgg);

const link = svgg.append("g")
.selectAll("line")
.data(officerLinks)
.join("line")
.attr("stroke", dat => d3.interpolateTurbo(Math.min(dat.count / 10, 1)))
.attr("stroke-opacity", dat => 0.5 + Math.min(dat.count / 10, 1) * 0.5)
.attr("stroke-width", dat => Math.sqrt(dat.count));

const node = svgg.append("g")
.attr("stroke", "#fff")
.attr("stroke-width", 1.5)
.selectAll("circle")
.data(officerInfo)
.join("circle")
.attr("r", 5)
.attr("fill", dat => d3.schemeCategory10[dat.cluster % 10])
.call(dragNodeInit(simulation))
const [hullUpdater, clusterGetter] = clusterDisplayInit(node, svg, svgg);
infoDisplayInit(node, svg, infoBox, clusterGetter);

const text = svgg.append("g")
.selectAll("text")
.data(officerInfo)
.join("text")
.text(dat => dat.id)
.call(dragNodeInit(simulation));

simulation.on("tick", () => {
hullUpdater();
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);
text
.attr("x", d => d.x)
.attr("y", d => d.y);
});

// Terminate the force layout when this cell re-runs.
invalidation.then(() => simulation.stop());
return svg.node();
}
Insert cell
Insert cell
getHull = (cluster) => d3.polygonHull(cluster.map((d) => [d.x, d.y]));
Insert cell
getPath = (hull) => `M${hull.join("L")}Z`
Insert cell
getClusterInfo = (officers) => {
const roundYear = (year) => `${year - year % 10}s`;
const binsToString = (bins) => bins.map(d => `${d[0]}: ${d[1]}`).join(", ");
const percent = (accessor) => binsToString(d3.rollups(officers, v => d3.format(".2%")(v.length / officers.length), accessor));
const bin = (accessor, thresholds) => {
const hist = d3.rollups(officers, v => d3.format(".2%")(v.length / officers.length), d => {
const val = accessor(d);
for(var i=0; i<thresholds.length-1; i++) {
if (thresholds[i] <= val && thresholds[i+1] > val)
return `${thresholds[i]}~${thresholds[i+1]}`;
}
});
return binsToString(
hist.sort((a, b) => d3.ascending(a[0], b[0]))
);
}
return [
["officerNum", officers.length],
["gender", percent(d => d.gender)],
["race", percent(d => d.race)],
["appointedYear", percent(d => roundYear(d.appointed_year))],
["birthYear", percent(d => roundYear(d.birth_year))],
["civilianAllegationPercentile", bin(d => d.ca_per, [0, 51.61, 77.42, 90.32, 96.77, 100])],
["internalAllegationPercentile", bin(d => d.ia_per, [0, 51.61, 77.42, 90.32, 96.77, 100])],
["salary", bin(d => d.salary, [0, 40000, 80000, 120000, 160000, 200000, 1000000])],
]
}
Insert cell
d3.select(infoBox).select("tbody")
Insert cell
infoDisplayInit = (node, svg, infoBox, clusterGetter) => {
const updateInfo = () => {
debugger;
const cluster = clusterGetter();
const info = cluster === null ? [] : getClusterInfo(officerClusters[cluster]);
const rows = d3.select(infoBox).select("tbody")
.selectAll("tr")
.data(info)
.join(
enter => enter.append("tr"),
update => update,
exit => exit.remove()
);
const cells = rows.selectAll("td")
.data(d => d)
.join(
enter => enter.append("td").text(d => d),
update => update.text(d => d),
exit => exit.remove()
);
}
svg.on("update-info", updateInfo);
}
Insert cell
clusterDisplayInit = (node, svg, svgg) => {
const hull = svgg.insert("path", ":first-child");
var selCluster = null;
var isHover = true;
const mouseclick = (eve, dat) => {
// prevent entering mouseclickSvg
eve.stopPropagation();
isHover = false;
const color = d3.schemeCategory10[dat.cluster % 10];
hull.attr("stroke", color)
.attr("fill", color)
.attr("d", getPath(getHull(officerClusters[dat.cluster])))
.attr("stroke-opacity", 0.5)
.attr("fill-opacity", 0.3);
selCluster = dat.cluster;
svg.dispatch("update-info");
}
const mouseclickSvg = (eve) => {
if (selCluster !== null
&& d3.polygonContains(getHull(officerClusters[selCluster]), [eve.x, eve.y]))
return;
isHover = true;
selCluster = null;
hull.attr("stroke-opacity", 0)
.attr("fill-opacity", 0);
svg.dispatch("update-info");
}
const mouseover = (eve, dat) => {
if (!isHover) return;
const color = d3.schemeCategory10[dat.cluster % 10];
hull.attr("stroke", color)
.attr("fill", color)
.attr("d", getPath(getHull(officerClusters[dat.cluster])))
.attr("stroke-opacity", 0)
.attr("fill-opacity", 0)
.transition()
.duration(500)
.attr("stroke-opacity", 0.5)
.attr("fill-opacity", 0.3);
selCluster = dat.cluster;
svg.dispatch("update-info");
}
const mouseout = (eve, dat) => {
if (!isHover) return;
selCluster = null;
svg.dispatch("update-info");
hull.transition()
.duration(500)
.attr("stroke-opacity", 0)
.attr("fill-opacity", 0);
}
const updater = () => {
if (selCluster === null) return;
hull.attr("d", getPath(getHull(officerClusters[selCluster])));
}
const clusterGetter = () => {
return selCluster;
}
node.on("mouseover", mouseover)
.on("mouseout", mouseout)
.on("click", mouseclick);
svg.on("click", mouseclickSvg);
return [updater, clusterGetter];
}
Insert cell
dragNodeInit = (simulation) => {
const dragstarted = (eve, dat) => {
if (!eve.active) simulation.alphaTarget(0.3).restart(); // reset annealing temperature
dat.fx = eve.x;
dat.fy = eve.y;
}
const dragged = (eve, dat) => {
dat.fx = eve.x;
dat.fy = eve.y;
}
const dragended = (eve, dat) => {
if (!eve.active) simulation.alphaTarget(0); // shutdown annealing
dat.fx = null;
dat.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
Insert cell
// zoom includes zomming and panning
// svgg is the top layer g in avg, acting as a container
zoomInit = (svg, svgg) => {
const zoomed = (event) => {
// transform svg viewport
const transform = event.transform;
svgg.attr("transform", "translate(" + transform.x + "," + transform.y + ") scale(" + transform.k + ")");
}
svg.call(d3.zoom().on("zoom", zoomed));
}
Insert cell
Insert cell
officerClusters = officerInfo.reduce(
(acc, ele) => (( ele.cluster in acc ? acc[ele.cluster].push(ele) : acc[ele.cluster] = [ele]), acc),
{}
)
Insert cell
// perform clustering
// "cluster" attribute will be append to each officerInfo
// This computation will take a few seconds
{
const ids = officerInfo.map(of => ""+ of.id);
return nc.cluster(officerInfo,
badRel.map(rel => ({source: ids.indexOf(rel.officer_id),
target: ids.indexOf(rel.bad_id),
value: rel.count})))
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
height = 500
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