UNPKG

d3-ternary

Version:
518 lines (513 loc) 19.3 kB
// 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