Public
Edited
Apr 13, 2023
Insert cell
Insert cell
chart({
xAxis: {
title: "X Axis Title",
min: -2,
max: 2
},
yAxis: {
title: "Y Axis Title",
min: -2000,
max: 2000
},
points: x => 2000 * Math.sin(x)
})
Insert cell
Insert cell
import { make_scale } from "@joshdata/perceptually-valid-color-scales"
Insert cell
chart = function(data) {
// Get general properties.
let height = data.height || width / 2;
let margin = data.margin || { top: 20, right: 0, bottom: 40, left: 40 };

// Create the canvas.
const svg = d3
.create("svg")
.attr("viewBox", [0, 0, width, height])
.style("overflow", "visible");

// If there isn't a series key, use 'points' as the single series.
let series = data.series || [
{
points: data.points,
n: data.n
}
];

// Add to the right margin an estimate of the largest series
// title's width for series placed at the right margin.
var series_max_title_width = 0;
series.forEach(series => {
if (series.title && series.titlePosition == "right")
series_max_title_width = Math.max(
series_max_title_width,
series.title.length * 10
);
});
margin.right += series_max_title_width;

// Define the x and y axis scale methods to convert data values
// to canvas coordinates.
function range(axisindex, axisobj) {
// If a function is given to generate points, require
// that the axis min/max be given.
if (!Array.isArray(data.points)) return [axisobj.min, axisobj.max];
else
return [
d3.min(data.points, _ => _[axisindex]),
d3.max(data.points, _ => _[axisindex])
];
}
const x_scale = d3
.scaleLinear()
.domain(range(0, data.xAxis))
.nice()
.range([margin.left, width - margin.right]);
const y_scale = d3
.scaleLinear()
.domain(range(1, data.yAxis))
.nice()
.range([height - margin.bottom, margin.top]);

// For series whose points are given as functions, evaluate the functions
// and store the computed points.
series.forEach(series => {
if (typeof series.points != "function") return;
let n = series.n || width / 6; /* evaluate at about every 6th pixel */
let points = [];
for (let i = 0; i < n; i++) {
let xval = data.xAxis.min + (i / n) * (data.xAxis.max - data.xAxis.min);
let yval = series.points(xval);
points.push([xval, yval]);
}
series.points = points;
});

// Convert all of the data points to canvas points.
series.forEach(series => {
if (!series.points) return; // missing data?

// Compute canvas coordinates.
let canvas_points = series.points.map(p => [x_scale(p[0]), y_scale(p[1])]);

// If any point is NaN, stop.
for (let i = 0; i < canvas_points.length; i++)
if (isNaN(canvas_points[i][0]) || isNaN(canvas_points[i][1])) return;

// Store.
series.canvas_points = canvas_points;
});

// Solve for the coordinates of labels for series titles.
SolveSeriesLabelCoordinates(series);

// Create the lines from series.
let default_colors = make_scale(
'#2c6ba6',
'#99cc99',
series.length > 2 ? series.length : 2
);
series.forEach(series => {
if (!series.canvas_points) return;
let datum = series.canvas_points;

// Add the line.
var series_color = series.color || default_colors.shift().css;
svg
.append("path")
.datum(datum)
.attr("fill", "none")
.attr("stroke", series_color)
.attr("stroke-width", series.width || 1.5)
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr(
"d",
d3
.line()
.defined(_ => !isNaN(_[0]))
.x(_ => _[0])
.y(_ => _[1])
);

// Add the series label.
if (series.title) {
var pt = [width - margin.right, datum[datum.length - 1][1]];
var pa = 0;
if (series.titlePosition != "right" && series.titleLabelCoordinate) {
pt = series.titleLabelCoordinate;

// Add an offset from the line perpendicular to the line.
// First compute a perpendicular vector (in series coordinates)
// from the tangent vector.
var v = [
-series.titleLabelLineVector[1],
series.titleLabelLineVector[0]
];

// Compute the angle of this vector.
pa = Math.round((Math.atan2(v[1], v[0]) * 180) / 3.14159) - 90;

// Add the offset above the curve.
pt[0] -= v[0] * 9 * series.titleLabelSide;
pt[1] -= v[1] * 9 * series.titleLabelSide;
}
svg
.append("text")
.attr(
"transform",
"translate(" + pt[0] + "," + pt[1] + ") rotate(" + pa + ")"
)
.attr("dy", ".35em")
.attr("text-anchor", !series.titleLabelCoordinate ? "start" : "middle")
.style("fill", series_color)
.text(series.title);
}
});

// Add x-axis and its title.
svg
.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(
d3
.axisBottom(x_scale)
.ticks(width / 80)
.tickSizeOuter(0)
)
.call(g =>
g
.select(".tick:last-of-type text")
.clone()
.attr("text-anchor", "middle")
.attr("x", -(width - margin.left - margin.right) / 2)
.attr("y", margin.bottom - 10)
.attr("font-weight", "bold")
.text(data.xAxis ? data.xAxis.title : "")
);

// Add y-axis and its title.
svg
.append("g")
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y_scale))
.call(g => g.select(".domain").remove())
.call(g =>
g
.select(".tick:last-of-type text")
.clone()
.attr("transform", `rotate(-90)`)
.attr("text-anchor", "middle")
.attr("x", -(height - margin.top - margin.bottom) / 2)
.attr("y", -margin.left)
.attr("font-weight", "bold")
.text(data.yAxis ? data.yAxis.title : "")
);

return svg.node();
}
Insert cell
SolveSeriesLabelCoordinates = function(series) {
// Get the min and max coordinates.
var minx, miny, maxx, maxy;
for (var i = 0; i < series.length; i++) {
for (var j = 0; j < series[i].points.length; j++) {
if (i == 0 && j == 0) {
minx = series[i].canvas_points[j][0];
miny = series[i].canvas_points[j][1];
maxx = series[i].canvas_points[j][0];
maxy = series[i].canvas_points[j][1];
} else {
minx = Math.min(minx, series[i].canvas_points[j][0]);
miny = Math.min(miny, series[i].canvas_points[j][1]);
maxx = Math.max(maxx, series[i].canvas_points[j][0]);
maxy = Math.max(maxy, series[i].canvas_points[j][1]);
}
}
}

// Compute a Voronoi diagram over all of the points.
var all_points = [];
series.forEach(series => {
series.canvas_points.forEach(pt => {
all_points.push(pt);
});
});
var voronoi = d3.Delaunay.from(all_points);
voronoi = voronoi.voronoi([minx, miny, maxx, maxy]);

// For each cell in the Voronoi diagram, determine whether any of the
// curve points falls within it, and compute its area.
var candidate_labels = [];
for (const cell of voronoi.cellPolygons()) {
series.forEach((s, si) => {
s.canvas_points.forEach((p, pi) => {
if (d3.polygonContains(cell, p)) {
if (cell.ADDED) return; // multiple series are in this cell?

var cg = d3.polygonCentroid(cell);
var w = d3.polygonArea(cell);

// Down-weight points that are close to the canvas edges.
if (p[0] < minx + 100) w /= 101 - (p[0] - minx);
if (p[1] < miny + 100) w /= 101 - (p[1] - miny);
if (p[0] > maxx - 100) w /= 101 - (maxx - p[0]);
if (p[1] > maxy - 100) w /= 101 - (maxy - p[1]);

candidate_labels.push({
series: si,
point: pi,
score: w,
cell_centroid: [cg[0] - p[0], cg[1] - p[1]]
});
cell.ADDED = true;
}
});
});
}

// Sort candidate labels by the Voronoi cell area in increasing order.
candidate_labels.sort((a, b) => a.score - b.score);

// Greedily assign points to series.
var solution = series.map(series => null);
series.forEach((s, si) => {
candidate_labels.forEach(c => {
if (c.series != si) return;
if (solution[si]) return;

solution[si] = c;

// Down-weight nearby cells to avoid label placement nearby.
let pt1 = s.canvas_points[c.point];
candidate_labels.forEach(lbl => {
let pt2 = series[lbl.series].canvas_points[lbl.point];
let d = Math.sqrt((pt1[0] - pt2[0]) ** 2 + (pt1[1] - pt2[1]) ** 2);
if (d < 250) lbl.score /= 251 / d;
});
candidate_labels.sort((a, b) => a.score - b.score);
});
});

// Assign coordinates and tangents, which are used to offset the labels.
function cross(a, b) {
return a[0] * b[1] - a[1] * b[0];
}
function computeVector(a, b) {
var pt = [b[0] - a[0], b[1] - a[1]];
var n = Math.sqrt(pt[0] ** 2 + pt[1] ** 2);
pt = [pt[0] / n, pt[1] / n];
return pt;
}
series.forEach((series, i) => {
if (!solution[i]) return;
series.titleLabelCoordinate = series.canvas_points[solution[i].point];
series.titleLabelLineVector = computeVector(
solution[i].point > 0
? series.canvas_points[solution[i].point - 1]
: series.canvas_points[solution[i].point],
solution[i].point < series.points.length - 1
? series.canvas_points[solution[i].point + 1]
: series.canvas_points[solution[i].point]
);

series.titleLabelSide = Math.sign(
cross(solution[i].cell_centroid, series.titleLabelLineVector)
);
});
}
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