Published
Edited
Dec 31, 2019
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
{
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])

return svg.aes({x: d => d.sepal_width, y: d => d.sepal_length}) // declare base aesthetics
.call(geom.point, iris, aes({color: d => d.species})) // draw a geom layer from data
.node()
}
Insert cell
iris = d3.csvParse(await FileAttachment("iris.csv").text())
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
{
let svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
let data = d3.ticks(0, 2 * Math.PI, 50)
while (true) {
let t = svg.transition()
.duration(750)
if (d3.select("#on").property("checked")) {
svg.aes({x: d => d, y: d => Math.sin(d)}, draw => ({ // expose the drawer (TODO: expose aesthetics)
enter: g => g.on("mouseover", d => d3.select("#text").text(d)) // return a joiner with "enter" key
.transition(t)
.call(draw) // joiner.enter calls draw when it's ready
}))
.call(geom.point, data)
}
yield svg.node()
await Promises.tick(2500)
}
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
d3 = require("d3@5")
Insert cell
margin = ({top: 25, right: 20, bottom: 35, left: 40})
Insert cell
height = 600
Insert cell
function aes({...aesthetics}, joiner) {
if (!joiner) { // one-argument
if (typeof aesthetics === "function") { // treat as a joiner
return { joiner: aesthetics }
} else { // treat as aesthetics
return _aes(aesthetics, defaults.joiner)
}
} else { // two arguments
if (typeof aesthetics !== "object" || typeof joiner !== "function") {
throw new Error("Type error")
}
return _aes(aesthetics, joiner)
}
}
Insert cell
function _aes(aesthetics, joiner) {
// gather aesthetics
return Object.keys(aesthetics)
.map(aesthetic_name => {
let declaration = aesthetics[aesthetic_name]
let aesthetic = {
[aesthetic_name]: {
joiner: joiner
},
}
let accessor
switch(aesthetic_name) {
case "x": // TODO: Move to defaults
case "y":
accessor = declaration
let extent = data => d3.extent(data, accessor)
Object.assign(aesthetic[aesthetic_name], {
accessor: accessor,
extent: extent,
scale: data => d3.scaleLinear()
.domain(extent(data)).nice()
.range(
aesthetic_name === "x"
? [margin.left, width - margin.right]
: [height - margin.bottom, margin.top]
)
})
break;
case "color":
case "colour":
accessor = declaration
Object.assign(aesthetic["color"], defaults.color(accessor))
break;
default:
break;
}
return aesthetic
})
.reduce((x, y) => Object.assign(x, y), {})
}
Insert cell
defaults = ({
joiner: function(draw) {
return {
enter: enter => enter.call(draw)
}
},
color: function(accessor=d => d, joiner=null) {
return Object.assign({
accessor: accessor,
scale: data => {
let scale_type = !isNaN(accessor(data[0])) ? "continuous" : "factor"
switch(scale_type) {
case "continuous":
return d3.scaleSequential(data.map(accessor), d3.interpolatePurples)
case "factor":
return d3.scaleOrdinal(data.map(accessor), d3.schemeCategory10)
break
}
}
}, joiner ? { joiner: joiner } : {})
}
})
Insert cell
/*
Extend the prototype to include an aesthetic "setter" function that:
1. Processes the argument aesthetics using the freestanding `aes` function defined above,
2. Store the result in the `d3.selection.prototype.aesthetic` entry for `this` node.
*/
d3.selection.prototype.aes = function({ ...aesthetics }, joiner) {
let base_aesthetics = this.aesthetics.get(this) ? this.aesthetics.get(this) : {}
this.aesthetics.set(this, Object.assign(base_aesthetics, aes(aesthetics, joiner)))
return this
}
Insert cell
/*
Track aesthetics attached to the tree using a d3 local variable.
This gives us easy inheritance of aesthetics in child nodes.
There's probably a more preferable way to do this than
modifying d3.selection.prototype, but this is fine for a first pass.
*/
d3.selection.prototype.aesthetics = d3.local();
Insert cell
debug = []
Insert cell
/*
`geom` is a module. Individual geometries (right now just `point`) are functions that take
1. A selection node,
2. A dataset,
3. Additional layer aesthetics.
*/
geom = ({
point: function(svg, data, layer_aesthetics={}) {
let base_aesthetics = svg.aesthetics.get(svg) ? svg.aesthetics.get(svg) : {}
let aesthetics = {...base_aesthetics, ...layer_aesthetics}

// TODO: move to coordinate systems
let x_accessor = aesthetics.x.accessor,
x_scale = aesthetics.x.scale(data),
y_accessor = aesthetics.y.accessor,
y_scale = aesthetics.y.scale(data)
svg.append('g')
.attr('transform', `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x_scale));
svg.append('g')
.attr('transform', `translate(${margin.left},0)`)
.call(d3.axisLeft(y_scale));
const to_draw = ["x", "y", "color"]
if (!aesthetics["color"]) aesthetics.color = defaults.color(d => "d", defaults.joiner)
const drawers = to_draw.reduce((drawers, aesthetic) => {
let aesthetic_options = aesthetics[aesthetic]
let accessor = aesthetic_options.accessor
let scale = aesthetic_options.scale(data)
let attributes
switch(aesthetic) {
case "x":
case "y":
attributes = [`c${aesthetic}`]
break;
case "color":
attributes = ["stroke", "fill"]
break;
}
let drawer = g => attributes
.reduce((h, attr) => h.attr(attr, d => scale(accessor(d))), g)
return drawers.set(aesthetic, drawer)
}, new Map())
function draw(g, to_draw, update_status) {
to_draw.forEach(aesthetic => {
let joiner = aesthetics[aesthetic].joiner
let _draw = drawers.get(aesthetic);
g.call(joiner(_draw)[update_status])
})
}
svg.append("g")
.selectAll("g")
.data(data)
.join(
enter => enter.append("g")
.append("circle") // TODO: create aesthetics ("shape" and "size") with defaults
.attr("r", 2)
.call(draw, to_draw, "enter")
)
}
})
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