Public
Edited
Apr 15, 2024
7 forks
167 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
getRadiusScale = function(width) {
let rMax = 35.0;
let rMin = 0.25;

return d3.scaleSqrt().range([rMin, rMax]);
};
Insert cell
// Trim values to three decimal places and return as a number
trimValues = (val) => +val.toFixed(1);
Insert cell
createSubcategory = function (data, field) {
return d3.nest()
.key(d => d[field])
.entries(data);
};
Insert cell
Insert cell
schoolDataPacked = calculateCircleLayout(schoolDataRaw, width, height)
Insert cell
// In production, this accounted for pixel ratio slightly differently, since it was being precalculated in node.
calculateCircleLayout = function(data, width, height) {
const pr = window.devicePixelRatio || 1;
// packEnclose will mutate the original array if we're not careful
const packData = Array.from(data)
// Populate this array with enclosing circles for each group then write out to file.
const enclosingCircles = [];
const radiusScale = getRadiusScale(width);
radiusScale.domain([0, getMax('total17', data)]);
// Width and height in pixels for high DPI screens
const w = width * pr;
const h = height * pr;

// First we have to iterate over the data and determine a radius for each bubble
// So we can later use d3.packSiblings
data.forEach(d => {
// set radius values for 1995 and 2017 populations
// And initialize the radius to the 2017 values.
d.r95 = trimValues(radiusScale(d.total95));
d.r17 = trimValues(radiusScale(d.total17));
// .packEnclose uses the r property automatically, so set it to the 2017 value
d.r = d.r17;
});
// Divide the data into three groupings - for diverse, undiverse and extremely undiverse.
const data17 = createSubcategory(data, 'div17');
const group17Packs = [];

// Calculate the pack layout for each group.
data17.forEach(group => {
const groupPack = d3.packSiblings(group.values);
group17Packs.push({ key: group.key, circles: groupPack});
});
// Each circle in the pack layout has x and y coordinate relative to the center of the group
// So we need to add the offsets to get the actual screen position that we want.
group17Packs.forEach(group => {
// Calculate the top and left offset in pixels for each group
const offsetX = w * offsets[group.key].x;
const offsetY = h * offsets[group.key].y;
// Calculate the positioned x and y coordinates based on the pack layout positioning
// plus the canvas offset for that particular group.
// These values are then stored in the grp17X and grp17Y values to be accessed later.
group.circles.forEach(d => {
d.grp17X = trimValues(offsetX + (d.x * pr));
d.grp17Y = trimValues(offsetY + (d.y * pr));
})
});
// Now, swap the .r value with the r95 field
// so that we can run packSiblings.
// Our default value is 1995, which is why we run this last.
data.forEach(d => {
d.r = d.r95;
});
const data95 = createSubcategory(data, 'div95');
const group95Packs = [];

data95.forEach(group => {
const groupPack = d3.packSiblings(group.values);
group95Packs.push({ key: group.key, circles: groupPack});
});

group95Packs.forEach(group => {
// Calculate the top and left offset in pixels for each group
const offsetX = w * offsets[group.key].x;
const offsetY = h * offsets[group.key].y;

// Calculate the positioned x and y coordinates based on the pack layout positioning
// plus the canvas offset for that particular group
group.circles.forEach(d => {
d.grp95X = trimValues(offsetX + (d.x * pr) );
d.grp95Y = trimValues(offsetY + (d.y * pr) );
});
});
// delete unneeded values
packData.forEach(d => {
d.r95 = trimValues(d.r95 * pr);
d.r17 = trimValues(d.r17 * pr);
delete d.r;
delete d.x;
delete d.y;
})
return packData;
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
normalizedColors = normalizeColors(colors)
Insert cell
colors = ({
diversity: {
D: '#b494cc',
U: '#99C9DE',
H: '#d1eae5',
default: '#dedfdc'
},
integration: {
H: '#50AF9D',
M: '#F9DA8D',
L: '#DD9564',
default: '#FFFFFF',
},
})
Insert cell
normalizeColors = (colors) => {
const normalized = {};

const hexToNormalizedRgb = (hex) => {
const r = parseInt(hex.substring(1,3), 16);
const g = parseInt(hex.substring(3,5), 16);
const b = parseInt(hex.substring(5,7), 16);

return [trimValues(r / 255), g / 255, b / 255, 1.0];
};
// Walk through the colors object,
// Converting every value to hexadecimal
for (let c in colors) {
let color = colors[c];
if (typeof color === 'string') {
normalized[c] = hexToNormalizedRgb(colors[c]);
} else {
let colorObj = colors[c]
normalized[c] = {};

for (let d in colorObj) {
normalized[c][d] = hexToNormalizedRgb(colorObj[d]);
}
}
}
return normalized;
}
Insert cell
Insert cell
Insert cell
makeAnimation = function(data, animProps) {
let startTime = null;

const props = Object.assign(
{
duration: animProps.duration || 1000,
delay: animProps.delay || 0,
delayByIndex: animProps.delaybyIndex || 0.05,
overrideDefaultColorStart: animProps.overrideDefaultColorStart || [],
overrideDefaultColorEnd: animProps.overrideDefaultColorEnd || [],
overrideDefaultColorField: animProps.overrideDefaultColorField
},
animProps
);

const animation = makeShader(data, props);
const animationDuration = props.duration + props.delayByIndex * data.length;

// Kick off the animation loop
const frameloop = regl.frame(({ time }) => {
// Keep track of start time so we can track time elapsed
// This is important since time doesn't reset when starting a new animation
if (startTime === null) {
startTime = time;
}

// Clear the canvas before moving on. to the next frame
regl.clear({
color: [1.0, 1.0, 1.0, 1.0],
depth: 1
});

animation({ startTime });
});

setTimeout(frameloop.cancel, animationDuration);
}
Insert cell
Insert cell
Insert cell
Insert cell
getShaderAttributes = function(data, props) {
const startPosX = `${props.startPosField}X`,
startPosY = `${props.startPosField}Y`,
endPosX = `${props.endPosField}X`,
endPosY = `${props.endPosField}Y`,
startR = props.startR || null,
endR = props.endR || null,
startColor = props.startColorField,
endColor = props.endColorField,
// These were dynamic in the actual page, and would switch between
// the integration palette and the diversity palette.
startColors = normalizedColors['diversity'],
endColors = normalizedColors['diversity'];
// Build an array of values for each attribute needed by our shaders.
let attributes = {
positionStart: data.map(d => [d[startPosX], d[startPosY]]),
startR: data.map(d => d[startR]),
index: data.map((d, i) => i),
startColor: data.map(d => startColors[d[startColor]] || startColors['default']),
endColor: data.map(d => endColors[d[endColor] || 'default'] ),
};

// Many transitions didn't have a change in position or radius
if (props.endPosField) {
attributes.positionEnd = data.map(d => [d[endPosX], d[endPosY]]);
}

if (endR) {
attributes.endR = data.map(d => d[endR]);
}
return attributes;
}
Insert cell
Insert cell
makeVertexShader = function(props) {
let vertexShader = `
precision mediump float;

// variable to send to the fragment shader
// since the fragment shader does not have access to attributes
varying vec4 fragColor;
varying float pointRadius;

attribute vec4 startColor;
attribute vec2 positionStart;
attribute float startR;
attribute float index;
// These are all optional and should be added conditionally
${props.endColorField ? 'attribute vec4 endColor;' : ''}
${props.endPosField ? 'attribute vec2 positionEnd;' : ''}
${props.endR ? 'attribute float endR;' : ''}

uniform float stageWidth;
uniform float stageHeight;
uniform float pixelRatio;
uniform float delayByIndex;
uniform float duration;
uniform float elapsed;

// Stolen from Peter Beshai's great blog post:
// http://peterbeshai.com/beautifully-animate-points-with-webgl-and-regl.html
// helper function to transform from pixel space to normalized device coordinates (NDC)
// in NDC (0,0) is the middle, (-1, 1) is the top left and (1, -1) is the bottom right.
vec2 normalizeCoords(vec2 position) {
// read positions into x and y vars
float x = position[0];
float y = position[1];

return vec2(
2.0 * ((x / stageWidth) - 0.5),
// invert y since 1 is at the top of normalized coordinates
// and [0,0] is in the center
-(2.0 * ((y / stageHeight) - 0.5))
);
}

// Helper function to handle cubic easing
// There are also premade easing functions available via glslify
float easeCubicInOut(float t) {
t *= 2.0;
t = (t <= 1.0 ? t * t * t : (t -= 2.0) * t * t + 2.0) / 2.0;

if (t > 1.0) {
t = 1.0;
}

return t;
}

void main () {
float delay = delayByIndex * index;
float t;
float pointWidth;

// if there is no duration show end state immediately
if (duration == 0.0) {
t = 1.0;
// still in the delay interval before animating
} else if (elapsed < delay) {
t = 0.0;
} else {
t = easeCubicInOut((elapsed - delay) / duration);
}

pointWidth = ${props.endR ? 'mix(startR, endR, t)' : 'startR'};
fragColor = ${props.endColorField ? 'mix(startColor, endColor, t)' : 'startColor'};

pointRadius = pointWidth;

// Slightly less than total radius to add a bit of padding
gl_PointSize = pointWidth * 1.95;

// interpolating position
vec2 position = ${props.endPosField ? 'mix(positionStart, positionEnd, t)' : 'positionStart' };

// scale to normalized device coordinates
gl_Position = vec4(normalizeCoords(position), 0.0, 1.0);
}
`;

return vertexShader;
}
Insert cell
Insert cell
Insert cell
schoolDataRaw = d3.json(schoolDataUrl)
Insert cell
schoolDataUrl = 'https://gist.githubusercontent.com/emamd/21168ad4eb71e709023bc3fff52e32e4/raw/39a2c59a36ff745bd74a4f9ff9738acf511237d4/school-data.json'
Insert cell
d3 = require("d3-hierarchy@1.1.8", "d3-fetch@1.1.2", "d3-scale@3.1.0", "d3-array@2.3.1", "d3-collection@1.0.7")
Insert cell
height = 650
Insert cell
import {radio} from "@jashkenas/inputs"
Insert cell
import {reglCanvas} from "@rreusser/regl-tools"
Insert cell
require('regl')
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