d3-ternary
Version:
Generate ternary plots
518 lines (513 loc) • 19.3 kB
JavaScript
// d3-ternary v3.0.7 Copyright 2025, Jules Blom
import { scaleLinear } from 'd3-scale';
/**
* Constructs a new ternary plot using the provided barycentric converter
*/
function ternaryPlot(barycentric) {
let radius = 300;
let tickFormat = "%";
// axes configurations
const A = {
label: "A",
labelAngle: 0,
labelOffset: 45,
tickAngle: 0,
tickSize: 6,
tickTextAnchor: "start",
};
const B = {
label: "B",
labelAngle: 60,
labelOffset: 45,
tickAngle: 60,
tickSize: 6,
tickTextAnchor: "end",
};
const C = {
label: "C",
labelAngle: -60,
labelOffset: 45,
tickAngle: -60,
tickSize: 6,
tickTextAnchor: "end",
};
/** Transform coordinates only applying radius */
function transform(x, y) {
return [x * radius, y * radius];
}
const ternaryPlot = function (d) {
const [x, y] = barycentric(d);
return transform(x, y); // Apply radius
};
// Get the three corners of the triangle - using full triangle for display
function getVertices() {
const a = transform(...barycentric.unscaled([1, 0, 0]));
const b = transform(...barycentric.unscaled([0, 1, 0]));
const c = transform(...barycentric.unscaled([0, 0, 1]));
return [a, b, c];
}
ternaryPlot.triangle = function () {
const [cA, cB, cC] = getVertices();
return `M${cA}L${cB}L${cC}Z`;
};
function gridLines(count = 10) {
const [scaleA, scaleB, scaleC] = barycentric.scales();
const counts = Array.isArray(count) ? count : [+count, +count, +count];
const gridLines = [
// A axis grid lines (vertical lines, parallel to BC edge)
scaleA
.ticks(counts[0])
.map((t) => {
const scaledT = scaleA(t);
const start = transform(...barycentric.unscaled([scaledT, 1 - scaledT, 0]));
const end = transform(...barycentric.unscaled([scaledT, 0, 1 - scaledT]));
return [start, end];
}),
// B axis grid lines (lines parallel to AC edge, from left vertex)
scaleB
.ticks(counts[1])
.map((t) => {
const scaledT = scaleB(t);
const start = transform(...barycentric.unscaled([0, scaledT, 1 - scaledT]));
const end = transform(...barycentric.unscaled([1 - scaledT, scaledT, 0]));
return [start, end];
}),
// C axis grid lines (lines parallel to AB edge, from right vertex)
scaleC
.ticks(counts[2])
.map((t) => {
const scaledT = scaleC(t);
const start = transform(...barycentric.unscaled([1 - scaledT, 0, scaledT]));
const end = transform(...barycentric.unscaled([0, 1 - scaledT, scaledT]));
return [start, end];
}),
];
return gridLines;
}
ternaryPlot.gridLines = gridLines;
ternaryPlot.axisLabels = function ({ center = false } = {}) {
const [cA, cB, cC] = getVertices(); // c1=[1,0,0], c2=[0,1,0], c3=[0,0,1]
if (center) {
// Calculate midpoints of each edge
const midAB = [(cA[0] + cB[0]) / 2, (cA[1] + cB[1]) / 2];
const midBC = [(cB[0] + cC[0]) / 2, (cB[1] + cC[1]) / 2];
const midCA = [(cC[0] + cA[0]) / 2, (cC[1] + cA[1]) / 2];
return [
// C axis label (opposite to C vertex)
{
position: [
(midAB[0] / radius) * (radius + A.labelOffset),
(midAB[1] / radius) * (radius + A.labelOffset),
],
label: A.label,
angle: A.labelAngle,
},
// A axis label (opposite to A vertex)
{
position: [
(midBC[0] / radius) * (radius + B.labelOffset),
(midBC[1] / radius) * (radius + B.labelOffset),
],
label: B.label,
angle: B.labelAngle,
},
// B axis label (opposite to B vertex)
{
position: [
(midCA[0] / radius) * (radius + C.labelOffset),
(midCA[1] / radius) * (radius + C.labelOffset),
],
label: C.label,
angle: C.labelAngle,
},
];
}
// Original vertex-based label positioning
return [
// A axis label (at top vertex where A=1)
{
position: [
cA[0] + cA[0] * (A.labelOffset / radius),
cA[1] + cA[1] * (A.labelOffset / radius),
],
label: A.label,
angle: A.labelAngle,
},
// B axis label (at left vertex where B=1)
{
position: [
cB[0] + cB[0] * (B.labelOffset / radius),
cB[1] + cB[1] * (B.labelOffset / radius),
],
label: B.label,
angle: B.labelAngle,
},
// C axis label (at right vertex where C=1)
{
position: [
cC[0] + cC[0] * (C.labelOffset / radius),
cC[1] + cC[1] * (C.labelOffset / radius),
],
label: C.label,
angle: C.labelAngle,
},
];
};
function ticks(count = 10) {
const [scaleA, scaleB, scaleC] = barycentric.scales();
const counts = Array.isArray(count) ? count : [+count, +count, +count];
const formatA = typeof tickFormat === "function"
? tickFormat
: scaleA.tickFormat(counts[0], tickFormat);
const formatB = typeof tickFormat === "function"
? tickFormat
: scaleB.tickFormat(counts[1], tickFormat);
const formatC = typeof tickFormat === "function"
? tickFormat
: scaleC.tickFormat(counts[2], tickFormat);
const ticks = [
// A axis ticks (top)
scaleA.ticks(counts[0]).map((t) => {
const scaledT = scaleA(t);
return {
tick: formatA(t),
angle: A.tickAngle,
textAnchor: A.tickTextAnchor,
size: A.tickSize,
position: transform(...barycentric.unscaled([
scaledT, // A
0, // B
1 - scaledT, // C
])),
};
}),
// B axis ticks (left side)
scaleB.ticks(counts[1]).map((t) => {
const scaledT = scaleB(t);
return {
tick: formatB(t),
angle: B.tickAngle,
textAnchor: B.tickTextAnchor,
size: B.tickSize,
position: transform(...barycentric.unscaled([
1 - scaledT, // A
scaledT, // B,
0, // C
])),
};
}),
// C axis ticks (right side)
scaleC.ticks(counts[2]).map((t) => {
const scaledT = scaleC(t);
return {
tick: formatC(t),
angle: C.tickAngle,
textAnchor: C.tickTextAnchor,
size: C.tickSize,
position: transform(...barycentric.unscaled([
0, // A
1 - scaledT, // B
scaledT, //C
])),
};
}),
];
return ticks;
}
ternaryPlot.ticks = ticks;
function tickFormatFn(_) {
if (!arguments.length)
return tickFormat;
tickFormat = _ ?? "%";
return ternaryPlot;
}
ternaryPlot.tickFormat = tickFormatFn;
function radiusFn(_) {
if (typeof _ === "undefined")
return radius;
radius = +_;
return ternaryPlot;
}
ternaryPlot.radius = radiusFn;
ternaryPlot.invert = function (_) {
return barycentric.invert([_[0] / radius, _[1] / radius]);
};
function labels(_) {
return _
? ((A.label = String(_[0])),
(B.label = String(_[1])),
(C.label = String(_[2])),
ternaryPlot)
: [A.label, B.label, C.label];
}
ternaryPlot.labels = labels;
function tickAngles(_) {
return _
? ((A.tickAngle = _[0]),
(B.tickAngle = _[1]),
(C.tickAngle = _[2]),
ternaryPlot)
: [A.tickAngle, B.tickAngle, C.tickAngle];
}
ternaryPlot.tickAngles = tickAngles;
function labelAngles(_) {
return _
? ((A.labelAngle = _[0]),
(B.labelAngle = _[1]),
(C.labelAngle = _[2]),
ternaryPlot)
: [A.labelAngle, B.labelAngle, C.labelAngle];
}
ternaryPlot.labelAngles = labelAngles;
function tickTextAnchors(_) {
return _
? ((A.tickTextAnchor = _[0]),
(B.tickTextAnchor = _[1]),
(C.tickTextAnchor = _[2]),
ternaryPlot)
: [A.tickTextAnchor, B.tickTextAnchor, C.tickTextAnchor];
}
ternaryPlot.tickTextAnchors = tickTextAnchors;
function tickSizes(_) {
return _
? Array.isArray(_)
? ((A.tickSize = _[0]),
(B.tickSize = _[1]),
(C.tickSize = _[2]),
ternaryPlot)
: ((A.tickSize = B.tickSize = C.tickSize = +_), ternaryPlot)
: [A.tickSize, B.tickSize, C.tickSize];
}
ternaryPlot.tickSizes = tickSizes;
function labelOffsets(_) {
return _
? Array.isArray(_)
? ((A.labelOffset = _[0]),
(B.labelOffset = _[1]),
(C.labelOffset = _[2]),
ternaryPlot)
: ((A.labelOffset = B.labelOffset = C.labelOffset = +_), ternaryPlot)
: [A.labelOffset, B.labelOffset, C.labelOffset];
}
ternaryPlot.labelOffsets = labelOffsets;
return ternaryPlot;
}
/**
* Constructs a new barycentric converter. Uses an equilateral triangle with unit height.
*/
function barycentric() {
/** rotation angle in degrees */
let rotation = 0;
let a = (d) => d[0];
let b = (d) => d[1];
let c = (d) => d[2];
// domain scales
const scaleA = scaleLinear().domain([0, 1]);
const scaleB = scaleLinear().domain([0, 1]);
const scaleC = scaleLinear().domain([0, 1]);
const radian = Math.PI / 180;
const [vA, vB, vC] = [-90, 150, 30].map((d) => [
Math.cos(d * radian),
Math.sin(d * radian),
]);
/**
* Converts barycentric coordinates to cartesian coordinates
*/
function barycentricToCartesian([a, b, c]) {
return [
a * vA[0] + b * vB[0] + c * vC[0],
a * vA[1] + b * vB[1] + c * vC[1],
];
}
/**
* Applies rotation to cartesian coordinates
*/
function rotate(x, y) {
const rad = (rotation * Math.PI) / 180;
const ca = Math.cos(rad);
const sa = Math.sin(rad);
return [x * ca + y * sa, y * ca - x * sa];
}
/**
* Computes normalized ternary values
*/
function normalize([a, b, c]) {
if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c)) {
throw new Error("Invalid ternary coordinates: values must be numbers");
}
const [na, nb, nc] = [Number(a), Number(b), Number(c)];
const total = na + nb + nc;
return total === 0 ? [0, 0, 0] : [na / total, nb / total, nc / total];
}
const barycentric = function (d) {
const [dA, dB, dC] = normalize([a(d), b(d), c(d)]);
const [x, y] = barycentricToCartesian([scaleA(dA), scaleB(dB), scaleC(dC)]);
return rotate(x, y);
};
barycentric.unscaled = function (d) {
const [dA, dB, dC] = normalize(d);
const [x, y] = barycentricToCartesian([dA, dB, dC]);
return rotate(x, y);
};
/**
* Computes ternary values from coordinates
*/
barycentric.invert = function ([x, y]) {
// Remove rotation
const rad = (-rotation * Math.PI) / 180;
const ca = Math.cos(rad);
const sa = Math.sin(rad);
const rx = x * ca + y * sa;
const ry = y * ca - x * sa;
// en.wikipedia.org/wiki/Barycentric_coordinate_system#Conversion_between_barycentric_and_Cartesian_coordinates
const [xA, yA] = vA, [xB, yB] = vB, [xC, yC] = vC;
const yByC = yB - yC, xCxB = xC - xB, xAxC = xA - xC, yAyC = yA - yC, yCyA = yC - yA, xxC = rx - xC, yyC = ry - yC;
const d = yByC * xAxC + xCxB * yAyC, lambda1 = Math.abs((yByC * xxC + xCxB * yyC) / d), lambda2 = Math.abs((yCyA * xxC + xAxC * yyC) / d), lambda3 = Math.abs(1 - lambda1 - lambda2);
// Invert through scales
return [
scaleA.invert(lambda1),
scaleB.invert(lambda2),
scaleC.invert(lambda3),
];
};
function aFn(fn) {
return fn ? ((a = fn), barycentric) : a;
}
barycentric.a = aFn;
function bFn(fn) {
return fn ? ((b = fn), barycentric) : b;
}
barycentric.b = bFn;
function cFn(fn) {
return fn ? ((c = fn), barycentric) : c;
}
barycentric.c = cFn;
function rotationFn(_) {
if (!arguments.length)
return rotation;
rotation = _ ?? 0;
return barycentric;
}
barycentric.rotation = rotationFn;
function domainsFn(domains) {
if (!domains) {
return [scaleA.domain(), scaleB.domain(), scaleC.domain()];
}
const lengths = getDomainLengths(domains);
if (lengths.size !== 1) {
throw new Error("All domains must have the same length");
}
scaleA.domain(domains[0]);
scaleB.domain(domains[1]);
scaleC.domain(domains[2]);
return barycentric;
}
barycentric.domains = domainsFn;
/**
* Returns the scales for the three axes
*/
barycentric.scales = function () {
return [scaleA, scaleB, scaleC];
};
return barycentric;
}
function getDomainLengths(domains) {
return new Set(domains.map((domain) => {
// round differences
// https://stackoverflow.com/questions/11832914/round-to-at-most-2-decimal-places-only-if-necessary
const d0 = Math.round((domain[0] + Number.EPSILON) * 100) / 100;
const d1 = Math.round((domain[1] + Number.EPSILON) * 100) / 100;
const difference = Math.abs(d1 - d0);
return Math.round((difference + Number.EPSILON) * 100) / 100;
}));
}
/**
* Computes the domain ranges for each axis given a transform object from d3.zoom.
* The transform object contains scale (k) and translation (x,y) values.
*
* This function ensures the domains stay within valid bounds and maintains
* equal domain lengths across all axes.
*
* @param transform - Object containing zoom transform parameters:
* - k: scale factor (zoom level)
* - x: x-translation
* - y: y-translation
* @returns Tuple of three [start,end] domain ranges for axes A, B, and C
* @throws Error if zoom level or translation would create invalid domains
*/
function domainsFromTransform(transform) {
const { k, x, y } = transform;
const domainLength = 1 / k;
if (domainLength > 1) {
throw new Error(`Invalid zoom level: ${k}. Cannot zoom out beyond the original triangle.`);
}
const untranslatedDomainStart = (k - 1) / (k * 3);
const radian = Math.PI / 180;
const [vA, vB, vC] = [-90, 150, 30].map((d) => [
Math.cos(d * radian),
Math.sin(d * radian),
]);
// Solve system of equations to find shifts for each axis
const det = vA[0] * (vB[1] - vC[1]) + vB[0] * (vC[1] - vA[1]) + vC[0] * (vA[1] - vB[1]);
const shiftA = ((x / k) * (vB[1] - vC[1]) + (y / k) * (vC[0] - vB[0])) / det;
const shiftB = ((x / k) * (vC[1] - vA[1]) + (y / k) * (vA[0] - vC[0])) / det;
const shiftC = ((x / k) * (vA[1] - vB[1]) + (y / k) * (vB[0] - vA[0])) / det;
// Calculate initial domain starts
const domainAStart = untranslatedDomainStart - shiftA;
const domainBStart = untranslatedDomainStart - shiftB;
const domainCStart = untranslatedDomainStart - shiftC;
const domainAEnd = domainAStart + domainLength;
const domainBEnd = domainBStart + domainLength;
const domainCEnd = domainCStart + domainLength;
// Check if any domain goes outside [0,1] range with some tolerance
const epsilon = 10e-6;
const min = 0 - epsilon;
const max = 1 + epsilon;
if (domainAStart < min || domainAEnd > max) {
throw new Error(`New domain A exceeds bounds ${[domainAStart, domainAEnd]}`);
}
if (domainBStart < min || domainBEnd > max) {
throw new Error(`New domain B exceeds bounds ${[domainBStart, domainBEnd]}`);
}
if (domainCStart < min || domainCEnd > max) {
throw new Error(`New domain C exceeds bounds ${[domainCStart, domainCEnd]}`);
}
return [
[domainAStart, domainAEnd],
[domainBStart, domainBEnd],
[domainCStart, domainCEnd],
];
}
/**
* Computes the d3.zoom transform parameters that would create the given domain ranges.
* This is the inverse of domainsFromTransform().
*
* Used to sync the zoom and pan state to match specified domain ranges.
* The returned transform can be passed to d3.zoom.transform() to update the view.
*
* @param domains - Array of [start,end] domain ranges for axes A, B, and C
* @returns Object with zoom transform parameters:
* - k: scale factor (zoom level)
* - x: x-translation (unscaled by radius)
* - y: y-translation (unscaled by radius)
*/
function transformFromDomains(domains) {
const [domainA, domainB, domainC] = domains;
const domainLengths = getDomainLengths(domains);
const domainLength = [...domainLengths][0];
const k = 1 / domainLength;
const untranslatedDomainStart = (k - 1) / (k * 3); // find start value of centered, untranslated domain for this scale
const shiftA = untranslatedDomainStart - domainA[0];
const shiftB = untranslatedDomainStart - domainB[0];
const shiftC = untranslatedDomainStart - domainC[0];
const radian = Math.PI / 180;
const [vA, vB, vC] = [-90, 150, 30].map((d) => [
Math.cos(d * radian),
Math.sin(d * radian),
]);
const tx = (vA[0] * shiftA + vB[0] * shiftB + vC[0] * shiftC) * k;
const ty = (vA[1] * shiftA + vB[1] * shiftB + vC[1] * shiftC) * k;
return { k, x: tx, y: ty };
}
export { barycentric, domainsFromTransform, ternaryPlot, transformFromDomains };
//# sourceMappingURL=d3-ternary.js.map