Unlisted
Edited
Jul 20, 2024
Insert cell
Insert cell
statewide_summary_fm_agg_tidy@1.csv
Type Table, then Shift-Enter. Ctrl-space for more options.

Insert cell
statewide_summary_fm_agg_tidy
X
Percent
Y
Outcome
Color
Sex
Size
Facet X
Facet Y
Mark
bar
Type Chart, then Shift-Enter. Ctrl-space for more options.

Insert cell
selectedGroup = statewide_summary_fm_agg_tidy.filter(
(d) => d.Group === groupSelected
)
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
import { caption } from "bae7cbe100f70aa1"
Insert cell
import { html, svg } from "@observablehq/htl"
Insert cell
import { Plot } from "@mkfreeman/plot-tooltip"
Insert cell
import { addTooltips } from "@mkfreeman/plot-tooltip"
Insert cell
html`<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.4.1/css/bootstrap.min.css">`
Insert cell
html`<link rel="stylesheet" href="http://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css">`
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
// chart = {
// const svg = d3.select(DOM.svg(width, height));
// const g = svg
// .append("g")
// .attr("transform", `translate(${margin.left}, ${margin.top})`);

// // Apart from aesthetics, routes serve as trajectories for the moving particles.
// // We'll compute particle positions in the next step
// //
// const route = g
// .append("g")
// .attr("class", "routes")
// .attr("fill", "none")
// .attr("stroke-opacity", 0.5)
// .attr("stroke", "#EEE")
// .selectAll("path")
// .data(routes)
// .join("path")
// // use custom sankey function because we want nodes and links to be of equal height
// .attr("d", sankeyLinkCustom)
// .attr("stroke-width", bandHeight);

// // Compute particle positions along the routes.
// // This technic relies on path.getPointAtLength function that returns coordinates of a point on the path
// // Another example of this technic:
// // https://observablehq.com/@oluckyman/point-on-a-path-detection
// //
// route.each(function (nodes) {
// const path = this;
// const length = path.getTotalLength();
// const points = d3.range(length).map((l) => {
// const point = path.getPointAtLength(l);
// return { x: point.x, y: point.y };
// });
// // store points for each route in the cache to use during the animation
// const lastNode = nodes[nodes.length - 1];
// const key = "/" + nodes.map((n) => n.name).join("/");
// cache[key] = { points };
// });

// // Create a container for particles first,
// // to keep particles below the labels which are declared next
// const particlesContainer = g.append("g");

// // Labels
// //
// g.selectAll(".label")
// .data(sankey.nodes) // `.slice(1)` to skip the root node
// .join("g")
// .attr("class", "label")
// .attr(
// "transform",
// (d) => `translate(${d.x1 - bandHeight / 2}, ${d.y0 + bandHeight / 2})`
// )
// .attr("dominant-baseline", "middle")
// .attr("text-anchor", "end")
// // This is how we make labels visible on multicolor background
// // we create two <text> with the same label
// .call((label) =>
// label
// .append("text")
// // the lower <text> serves as outline to make contrast
// .attr("stroke", "white")
// .attr("stroke-width", 3)
// .style("font-family", "Verdana")
// .text((d) => d.name)
// )
// // the upper <text> is the actual label
// .call((label) =>
// label
// .append("text")
// .attr("fill", "#444")
// .style("font-family", "Verdana")
// .text((d) => d.name)
// );

// // Counters
// //
// const counters = g
// .selectAll(".counter")
// .data(leaves)
// .join("g")
// .attr("class", "counter")
// .attr("transform", (d) => `translate(${width - margin.left}, ${d.node.y0})`)
// .each(function (leaf, i) {
// d3.select(this)
// .selectAll(".group")
// .data(["males", "females"])
// .join("g")
// .attr("class", "group")
// .attr("transform", (d, i) => `translate(${-i * 60}, 0)`)
// // Align coutners to the right, because running numbers are easier for the eye to compare this way
// .attr("text-anchor", "end")
// // Use monospaced font to keep digits aligned as they change during the animation
// .style("font-family", "Arial") //Menlo
// // Add group titles only once, on the top
// .call(
// (g) =>
// i === 0 &&
// g
// .append("text")
// .attr("dominant-baseline", "hanging")
// .attr("fill", "#999")
// .style("font-size", 9)
// .style("text-transform", "uppercase")
// .style("letter-spacing", 0.7) // a rule of thumb: increase letter spacing a bit, when use uppercase
// .text((d) => d)
// )
// // Absolute counter values
// // .call(
// // (g) =>
// // g
// // .append("text")
// // .attr("class", "absolute")
// // .attr("fill", (d) => colorScale(d))
// // .attr("font-size", 20)
// // .attr("dominant-baseline", "middle")
// // .attr("y", bandHeight / 2 - 2)
// // .text(0) // will be updated during the animation
// // )
// // Percentage counter values
// .call(
// (g) =>
// g
// .append("text")
// .attr("class", "percent")
// .attr("dominant-baseline", "middle") //hanging
// .attr("fill", "#999")
// .attr("font-size", 20)
// .attr("y", bandHeight / 2 + 9)
// .text("0%") // will be updated during the animation
// );
// });

// // Instead of `return svg.node()` we do this trick.
// // It's needed to expose `update` function outside of this cell.
// // It's Observable-specific, you can see more animations technics here:
// // https://observablehq.com/@d3/learn-d3-animation?collection=@d3/learn-d3
// //
// return Object.assign(svg.node(), {
// // update will be called on each tick, so here we'll perform our animation step
// update(t) {
// // add particles if needed
// //
// addParticlesMaybe(t);

// // update counters
// //
// counters.each(function (d) {
// const finished = particles
// .filter((p) => p.target.name === d.node.name)
// .filter((p) => p.pos >= p.length);

// d3.select(this)
// .selectAll(".group")
// .each(function (group) {
// const count = finished.filter(
// (p) => p.target.group === group
// ).length;
// d3.select(this).select(".absolute").text(count);
// d3.select(this)
// .select(".percent")
// .text(d3.format(".0%")(count / totalParticles));
// });
// });

// // move particles
// //
// particlesContainer
// .selectAll(".particle")
// .data(
// particles.filter((p) => p.pos < p.length),
// (d) => d.id
// )
// .join(
// (enter) =>
// enter
// .append("rect")
// .attr("class", "particle")
// .attr("opacity", 0.8)
// .attr("fill", (d) => d.color)
// .attr("width", psize)
// .attr("height", psize),
// (update) => update,
// (exit) => exit
// //.remove() // uncomment to remove finished particles
// )
// // At this point we have `cache` with all possible coordinates.
// // We just need to figure out which exactly coordinates to use at time `t`
// //
// .each(function (d) {
// // every particle appears at its own time, so adjust the global time `t` to local time
// const localTime = t - d.createdAt;
// d.pos = localTime * d.speed;
// // extract the current and the next point coordinates from the precomputed cache
// const index = Math.floor(d.pos);
// const coo = cache[d.target.path].points[index];
// const nextCoo = cache[d.target.path].points[index + 1];
// if (coo && nextCoo) {
// // `index` is integer, but `d.pos` is float, so there are ticks when a particle is
// // between the two precomputed points. We use `delta` to compute position between the current
// // and the next coordinates to make the animation smoother
// const delta = d.pos - index; // try to set it to 0 to see how jerky the animation is
// const x = coo.x + (nextCoo.x - coo.x) * delta;
// const y = coo.y + (nextCoo.y - coo.y) * delta;
// // squeeze particles when they close to finish
// const lastX = cache[d.target.path].points[d.length - 1].x;
// const squeezeFactor = Math.max(0, psize - (lastX - x)); // gets from 0 to `psize`, when finish
// const h = Math.max(2, psize - squeezeFactor); // gets from `psize` to 2
// const dy = (psize - h) / 2; // increases as the particle squeezes, to keep it centered
// const w = psize + squeezeFactor; // the width increses twice, when finish
// const dx = squeezeFactor / 2; // compensates x position when the width increases
// d3.select(this)
// .attr("x", x - dx)
// .attr("y", y + d.offset + dy)
// .attr("height", h)
// .attr("width", w);
// }
// });
// }
// });
// }
Insert cell
Insert cell
Insert cell
Insert cell
raw = ({
"Enr TX HE": {
//290 total females, 250 total males
Certificate: { males: 83, females: 54 },
Associate: { males: 229, females: 319 },
Bachelors: { males: 756, females: 1139 },
"No HE Credential": { males: 1430, females: 1391 }
// Certificate: { males: 8340, females: 5356 },
// Associate: { males: 22941, females: 31930 },
// Bachelors: { males: 75576, females: 113912 },
// "No HE Credential": { males: 142972, females: 139132 }
// Certificate: { males: 8, females: 5 },
// Associate: { males: 23, females: 32 },
// Bachelors: { males: 76, females: 114 },
// "No HE Credential": { males: 143, females: 139 }
},
"Not Enr TX HE": {
"No HE Credential": { males: 1879, females: 1481 } //438K male HS grads,
// "No HE Credential": { males: 187938, females: 148140 } //438K male HS grads,
// "No HE Credential": { males: 188, females: 148 } //438K male HS grads,
}
})
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
// Convert the raw data from nested object format into `d3-hierarchy` compatible format,
// so we can use the power of `d3` to traverse nodes and paths to calculate distribution of particles
hierarchy = {
const isLeaf = (d) => d.hasOwnProperty("males");
// converts an object { bitA, bitB, ... } into array [{ name: 'bitA', ... }, { name: 'bitB', ... }, ...]
// `d3.hierarchy` will use this array to build its data structure
const getChildren = ({ name, ...otherProps }) =>
isLeaf(otherProps)
? undefined // leaves have no children
: Object.entries(otherProps).map(([name, obj]) => ({ name, ...obj }));
const absolutePath = (d) =>
`${d.parent ? absolutePath(d.parent) : ""}/${d.data.name}`;
return (
d3
.hierarchy({ name: "HSGrads", ...raw }, getChildren)
// convert each nodes's data into universal format: `{ name, path, groups: [{ key, value }, ...] }`
// so it does not depend on exact group names ('males', 'females')
// later it will allow to reuse the chart with other groups
.each((d) => {
const datum = {
name: d.data.name,
// `path` is needed to distinguish nodes with the same name but different ancestors
// (e.g. /root/bit501/bit601 vs /root/bit502/bit601)
path: absolutePath(d)
};
if (isLeaf(d.data)) {
datum.groups = [
{
key: "males",
value: d.data.males
},
{
key: "females",
value: d.data.females
}
];
}
d.data = datum;
})
);
}
Insert cell
// Consider different groups of the same route as different targets
// Such data structure format simplifies particle creation and tracking
targetsAbsolute = hierarchy.leaves().flatMap(t => t.data.groups.map(g => ({
name: t.data.name,
path: t.data.path,
group: g.key,
value: g.value,
})))
Insert cell
targets = {
// normalize values
const total = d3.sum(targetsAbsolute, d => d.value)
return targetsAbsolute.map(t => ({ ...t, value: t.value / total }))
}
Insert cell
// Distribution of all possible types of particles (each route and each color)
thresholds = d3.range(targets.length).map(i => d3.sum(targets.slice(0, i + 1).map(r => r.value)))
Insert cell
Insert cell
// takes a random number [0..1] and returns a target, based on distribution
targetScale = d3.scaleThreshold()
.domain(thresholds)
.range(targets)
Insert cell
Insert cell
// Randomly add from 0 to `density` particles per tick `t`
addParticlesMaybe = (t) => {
const particlesToAdd = Math.round(Math.random() * density)
for (let i = 0; i < particlesToAdd && particles.length < totalParticles; i++) {
const target = targetScale(Math.random()) // target is an object: { name, path, group }
const length = cache[target.path].points.length

const particle = {
// `id` is needed to distinguish the particles when some of them finish and disappear
id: `${t}_${i}`,
speed: speedScale(Math.random()),
color: colorScale(target.group),
// used to position a particle vertically on the band
offset: offsetScale(Math.random()),
// current position on the route (will be updated in `chart.update`)
pos: 0,
// when the particle is appeared
createdAt: t,
// total length of the route, used to determine that the particle has arrived
length,
// target where the particle is moving
target,
}
particles.push(particle)
}
}
Insert cell
// Gets a list of the nodes from the root to a leaf and returns a path thru these nodes
sankeyLinkCustom = nodes => {
const p = d3.path()
const h = bandHeight / 2
nodes.forEach((n, i) => {
if (i === 0) {
p.moveTo(n.x0, n.y0 + h)
}
p.lineTo(n.x1, n.y0 + h)
const nn = nodes[i + 1]
if (nn) {
const w = nn.x0 - n.x1
p.bezierCurveTo(
n.x1 + w / 2, n.y0 + h,
n.x1 + w / 2, nn.y0 + h,
nn.x0, nn.y0 + h
)
}
})
return p.toString()
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
curve = 0.6 // [0..1] // 0 - smooth, 1 - square
Insert cell
Insert cell
bandHeight = 80 - padding / 2
Insert cell
Insert cell
height = margin.top + margin.bottom +
[...new Set(hierarchy.leaves().map(d => d.data.name))].length * (bandHeight + padding / 2) + padding / 2
Insert cell
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