Published
Edited
Sep 21, 2021
Importers
6 stars
Insert cell
Insert cell
viewof chart = MultidragAxis(data)
Insert cell
Insert cell
chart
Insert cell
Insert cell
Insert cell
Insert cell
data = (reset, d3.range(10)
.map((_, i, arr) => ({
date: d3.interpolate(...scale.domain())((i + 0.5) / arr.length),
id: i
})));
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
slope = 2
Insert cell
Insert cell
height = 130
Insert cell
margin = {
const bottom = 20;
return {top: height - bottom, right: 15, bottom, left: 15}
}
Insert cell
Insert cell
accessor = d => d.date
Insert cell
setter = (d, v) => d.date = v
Insert cell
scale = d3.scaleTime(
domain,
[margin.left, width - margin.right]
).clamp(true)
Insert cell
domain = [
d3.timeMonth.floor(new Date()),
d3.timeYear.offset(new Date(), 1)
]
Insert cell
inDomain = d => accessor(d) >= domain[0] && accessor(d) <= domain[1]
Insert cell
Insert cell
enterItem = g => g.append("g")
.attr("text-anchor", "middle")
.call(g => g.append("text")
.attr("font-size", "2em")
.attr("y", "-0.1em")
.text("👤"))
.call(g => g.append("text")
.attr("font-size", "10px")
.attr("font-family", "sans-serif")
.attr("y", "-3.75em")
.text(d => d.name))
Insert cell
Insert cell
renderItem = g => g
.attr("transform", d => {
const x = scale(accessor(d));
const y = d.__captor ? -5 : 0;
const tilt = d.__captor ? (d.__captor.x - x) / 10 : 0;
return `translate(${x}, ${y}) rotate(${tilt})`
})
.attr("opacity", d => d.__captor?.y > 0 ? 0.25 : 1)
Insert cell
Insert cell
MultidragAxis = (data) => {
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height]);
const node = svg.node();
let value;

svg.append("g").call(axis);
const dragG = svg.append("g");
const items = svg.append("g").attr("pointer-events", "none");
svg.append("g").call(addItemSurface(addItem));
// Update the display whenever the value changes
Object.defineProperty(node, "value", {
get() {
return value;
},
set(v) {
const oldValue = value;
for (const d of v.filter(d => d.__condemned))
v.splice(v.indexOf(d), 1);
value = v;
items.datum(value).call(renderItems);
if (oldValue !== value)
dragG.datum({items, value, set: () => set(node, value)}).call(dragSurface);
}
});

// Set the initial value
node.value = data;
function addItem({x}) {
node.value.push(newItem({
id: node.value.length,
date: scale.invert(x)
}));
set(node, node.value);
}
return node;
}
Insert cell
axis = g => g
.attr("transform", `translate(0, ${margin.top})`)
.attr("pointer-events", "none")
.call(d3.axisBottom(scale).ticks(width / 80))
Insert cell
renderItems = g => g
.attr("transform", `translate(0, ${margin.top})`)
.selectAll("g")
.data(data => data.filter(inDomain), d => d.id)
.join(enterItem, g => g, g => g.remove())
.call(renderItem)
Insert cell
dragSurface = g => g.attr("transform", `translate(0, ${margin.top})`)
.style("cursor", "grab")
.call(
g => g.selectAll("rect.click-capture")
.data([0])
.join(
enter => enter.append("rect")
.attr("class", "click-capture")
.attr("visibility", "hidden")
.attr("pointer-events", "all"),
update => update,
exit => exit.remove())
.attr("y", -margin.top)
.attr("width", width)
.attr("height", height - margin.bottom))
.call(
g => g.selectAll("g.instructions")
.data([0])
.join(
enter => enter.append("g").attr("class", "instructions"),
update => update,
exit => exit.remove())
.call(drawInstructions(initInstructions)))
.call(drag)
.call(hover)
Insert cell
Insert cell
addItemSurface = addItem => g => g.attr("transform", `translate(0, ${margin.top})`)
.style("cursor", "copy")
.call(g => g.append("rect")
.attr("width", width)
.attr("height", margin.bottom)
.attr("visibility", "hidden")
.attr("pointer-events", "all"))
.on("mousemove", function(event) {
d3.select(this)
.selectAll("text")
.data(pointers(event))
.join(enter => enter.append("text")
.text("+")
.attr("dy", "-0.2em")
.attr("fill", coneFill)
.attr("stroke", coneBaseline)
.attr("font-size", "28px")
.attr("font-weight", "bold")
.attr("text-anchor", "middle")
.attr("pointer-events", "none"))
.attr("x", d => d.x)
})
.on("mouseleave.rm click.rm", function(event) {
d3.select(this).selectAll("text").remove();
})
.on("click", function(event) {
addItem(pointers(event)[0]);
})
Insert cell
Insert cell
newItem = d => d
Insert cell
Insert cell
hover = g => g
.on("mousemove.hover", mousemove)
.on("mouseleave.hover", mouseleave)
Insert cell
// similar to a combination of `drag`, `enter`, and `update`
mousemove = function(event, d) {
if (d.dragging) return;
d3.select(this).selectAll("g.hover")
.style("cursor", "grab")
.data(pointers(event), d => d.id)
.join(
enter => enter.append("g")
.attr("class", "hover")
.attr("pointer-events", "none")
.call(g => g.append("path").attr("class", "cone")
.attr("fill", "none").attr("stroke", coneStroke))
.call(g => g.append("path").attr("class", "range")
.attr("stroke", "#6384dd").attr("stroke-width", 1.5))
)
.each(getCone)
.call(g => g.select(".cone").attr("d", d => d.conePath))
.call(g => g.select(".range").attr("d", d => d.rangePath));
}
Insert cell
mouseleave = function(event) {
d3.select(this).selectAll("g.hover").remove();
}
Insert cell
Insert cell
drag = {
function render(event, {items, value, set}) {
const sel = d3.select(this).selectAll("g.drag");
const oldData = sel.data();
const newData = pointers(event, this).filter(validPointers);
sel
.data(joinData(oldData, newData), d => d.id)
.join(enter(value, set), d => d, exit(value, set))
.call(update(value, set));
items.call(renderItems);
}

function dragstart() {
d3.select(this)
.style("cursor", "grabbing")
.call(g => g.selectAll("g.hover").remove())
.call(g => g.select(".instructions")
.call(drawInstructions(dragInstructions)));
}

function dragend() {
d3.select(this)
.style("cursor", "grab")
.call(g => g.select(".instructions")
.attr("opacity", 1)
.call(drawInstructions(initInstructions))
.transition()
.delay(1000)
.duration(1000)
.attr("opacity", 0)
.remove())
}

return d3.drag()
.on("start.init", dragstart)
.on("end.cleanup", dragend)
.on("start drag end", render);
}
Insert cell
Insert cell
coneFill = "#f0f4ff"
Insert cell
coneStroke = "#ccdaff"
Insert cell
coneBaseline = "#6384dd"
Insert cell
enter = (value, set) => enter => enter.append("g")
.attr("class", "drag")
.attr("pointer-events", "none")
.call(g => g.append("path").attr("class", "cone").attr("fill", coneFill).attr("stroke", coneStroke))
.call(g => g.append("path").attr("class", "range").attr("stroke", coneBaseline).attr("stroke-width", 1.5))
.each(getCone)
.each(capture(value, set))
Insert cell
update = (value, set) => update => update
.each(getCone)
.call(g => g.select(".cone").attr("d", d => d.conePath))
.call(g => g.select(".range").attr("d", d => d.rangePath))
.each(tug(value, set))
Insert cell
exit = (value, set) => exit => exit
.each(release(value, set))
.remove()
Insert cell
capture = (value, set) => cone => {
const [lower, upper] = cone.range;
itemLoop: for (const d of value) {
if (accessor(d) >= scale.invert(lower) && accessor(d) <= scale.invert(upper)) {
d.__captor = cone;
// If cone is small enough, capture at most 1 item
if (Math.abs(upper - lower) < 50) break itemLoop;
}
}
set();
}
Insert cell
release = (value, set) => cone => {
for (const d of value) {
if (d.__captor === cone) {
if (d.__captor.y > 0) d.__condemned = true;
delete d.__captor;
}
}
set();
}
Insert cell
tug = (value, set) => function(cone) {
const {x0, y0, x, y} = cone;
if (!x0) return;
for (const d of value) {
if (d.__captor === cone) {
// center of cone
// + (distance from item to old center of cone)
// * (height of cone / old height of cone)
// The `|| 1` prevents the singularity of all items collapsing irreversibly to center
const itemX = scale(accessor(d));
const newItemX = x + (itemX - x0) * ((y || 1) / (y0 || 1));
setter(d, scale.invert(newItemX));
}
}
set();
}
Insert cell
Insert cell
getCone = d => {
const {x, y} = d;
const range = [x + y * slope, x - y * slope];
const conePath = `M ${x} ${y} ${range[0]} 0 H ${range[1]} Z`;
const rangePath = `M ${range[0]} 0 H ${range[1]}`;
return Object.assign(d, {range, conePath, rangePath});
}
Insert cell
Insert cell
drawInstructions = instructions => g => g
.style("font-family", "var(--sans-serif)")
.style("font-size", 10)
.style("color", "gray")
.attr("transform", `translate(${width - 115}, ${-margin.top + 10})`)
.attr("pointer-events", "none")
.selectAll("g")
.data(instructions, d => d.text)
.join(
enter => enter.append("g")
.attr("transform", (d, i) => `translate(0, ${i * 15})`)
.call(
g => g.append("path")
.attr("d", d => d.path)
.attr("stroke", "currentColor")
.attr("stroke-width", 1.5)
.attr("fill", "none"))
.call(
g => g.append("text")
.text(d => d.text)
.attr("fill", "currentColor")
.attr("font-weight", "500")
.attr("dy", "0.31em")),
update => update,
exit => exit.remove())
Insert cell
initInstructions = ([
{
path: "M -20 0 m 3 -3 l -3 3 3 3 m 6 0 l 3 -3 -3 -3",
text: "Drag to move"
},
{
path: "M -14 -3 v 6 m -3 -3 h 6",
text: "Click below line to add"
}
])
Insert cell
dragInstructions = ([
{
path: "M -20 0 m 3 -3 l -3 3 3 3 m 6 0 l 3 -3 -3 -3",
text: "Drag up to stretch"
},
{
path: "M -20 0 m 0 -3 l 3 3 -3 3 m 12 0 l -3 -3 3 -3",
text: "Drag down to squish"
},
{
path: "M -17 0 h 6",
text: "Drag below line to remove"
}
])
Insert cell
Insert cell
Insert cell
joinData = (oldData, newData) => {
const data = [];
const oldMap = new Map(oldData.map(d => [d.id, d]));
for (const newCone of newData) {
if (oldMap.has(newCone.id)) {
const oldCone = oldMap.get(newCone.id)
oldCone.x0 = oldCone.x;
oldCone.y0 = oldCone.y;
Object.assign(oldCone, newCone);
data.push(oldCone);
} else {
data.push(newCone);
}
}
return data;
}
Insert cell
Insert cell
validPointers = d => d.type !== "mouseup"
Insert cell
Insert cell
import {pointers} from "@tophtucker/multitouch-circles"
Insert cell
import {set} from "@observablehq/synchronized-inputs"
Insert cell
import {CSVText} from "@tophtucker/structured-text-inputs"
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