d3-ternary
Version:
Generate ternary plots
123 lines (104 loc) • 4.1 kB
text/typescript
import { getDomainLengths } from "barycentric";
/**
* 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
*/
export function domainsFromTransform(transform: {
k: number;
x: number;
y: number;
}): [[number, number], [number, number], [number, number]] {
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)
*/
export function transformFromDomains(
domains: [[number, number], [number, number], [number, number]],
) {
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 };
}