@tanstack/charts
Version:
<div align="center"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://tanstack.com/api/readme/charts.png?theme=dark" /> <source media="(prefers-color-scheme: light)" srcset="https://tanstack.com/
208 lines (207 loc) • 8.21 kB
JavaScript
const defaultFontSize = 16;
const defaultFontWeight = 400;
const defaultOuterInset = 4;
const defaultTypography = {
fontFamily: "sans-serif",
fontStyle: "normal",
fontStretch: "normal",
letterSpacing: 0,
direction: "inherit",
fontScale: 1
};
function physicalTextAnchor(side, direction) {
if (side === "middle") return "middle";
const startsAtLeft = direction !== "rtl";
if (side === "left") return startsAtLeft ? "start" : "end";
return startsAtLeft ? "end" : "start";
}
function logicalTextAnchorOffset(width, anchor, direction) {
if (anchor === "middle") return -width / 2;
const startsAtLeft = direction !== "rtl";
return anchor === "start" === startsAtLeft ? 0 : -width;
}
function estimateSceneText(text, style) {
const fontScale = finitePositive(style.fontScale, 1);
const fontSize = finiteNonNegative(style.fontSize, defaultFontSize) * fontScale;
const fontWeight = finiteNonNegative(style.fontWeight, defaultFontWeight);
const letterSpacing = finiteNumber(style.letterSpacing, 0) * fontScale;
if (!text || fontSize === 0) {
return { x: 0, y: 0, width: 0, height: 0 };
}
let emWidth = 0;
for (const character of text) {
emWidth += estimateCharacterWidth(character);
}
const clampedWeight = Math.min(900, Math.max(100, fontWeight));
const weightFactor = 1 + (clampedWeight - 400) / 12500;
const width = Math.max(
0,
emWidth * fontSize * weightFactor + Math.max(0, Array.from(text).length - 1) * letterSpacing
);
const height = fontSize;
const x = logicalTextAnchorOffset(width, style.anchor, style.direction);
const y = style.baseline === "middle" ? -height / 2 : style.baseline === "hanging" ? 0 : -fontSize * 0.8;
return { x, y, width, height };
}
function measureSceneLabelBounds(label, measureText = estimateSceneText) {
const fontSize = finiteNonNegative(label.fontSize, defaultFontSize);
const anchor = label.anchor ?? "start";
const baseline = label.baseline ?? "auto";
const measured = label.text.length === 0 ? { x: 0, y: 0, width: 0, height: 0 } : measureText(label.text, {
fontSize,
fontWeight: label.fontWeight,
...defaultTypography,
anchor,
baseline
});
const x = finiteNumber(measured.x, 0);
const y = finiteNumber(measured.y, 0);
const width = finiteNonNegative(measured.width, 0);
const height = finiteNonNegative(measured.height, 0);
const bounds = {
x: label.x + x,
y: label.y + y,
width,
height
};
if (!label.rotate) {
return bounds;
}
return rotateBounds(bounds, label.x, label.y, label.rotate);
}
function withChartTextTypography(measureText = estimateSceneText, typography = {}) {
const resolved = {
...defaultTypography,
...typography,
fontFamily: typography.fontFamily || defaultTypography.fontFamily,
fontStyle: typography.fontStyle || defaultTypography.fontStyle,
fontStretch: typography.fontStretch || defaultTypography.fontStretch,
letterSpacing: finiteNumber(typography.letterSpacing, 0),
fontScale: finitePositive(typography.fontScale, 1)
};
return (text, options) => measureText(text, { ...options, ...resolved });
}
function resolveGuideMargins(axes, plot, options = {}) {
const inset = finiteNonNegative(options.inset, defaultOuterInset);
const measureText = options.measureText ?? estimateSceneText;
let top = inset;
let right = inset;
let bottom = inset;
let left = inset;
visitLabels(axes, 0, 0, (label, translateX, translateY) => {
if (!label.text) return;
const bounds = measureSceneLabelBounds(label, measureText);
const boundsLeft = bounds.x + translateX;
const boundsTop = bounds.y + translateY;
const boundsRight = boundsLeft + bounds.width;
const boundsBottom = boundsTop + bounds.height;
const plotRight = plot.x + plot.width;
const plotBottom = plot.y + plot.height;
top = Math.max(top, plot.y - boundsTop + inset);
right = Math.max(right, boundsRight - plotRight + inset);
bottom = Math.max(bottom, boundsBottom - plotBottom + inset);
left = Math.max(left, plot.x - boundsLeft + inset);
});
const strokeMargin = { top, right, bottom, left };
includeGuideStrokeMargins(strokeMargin, axes, plot);
return strokeMargin;
}
function includeGuideStrokeMargins(margin, guides, plot) {
visitRules(guides, 0, 0, (rule, translateX, translateY) => {
const style = rule.style;
const extendsGeometry = style?.strokeWidth !== void 0 || style?.lineCap !== void 0 && style.lineCap !== "butt";
if (!extendsGeometry || style?.stroke === "none") return;
const strokeWidth = style?.strokeWidth ?? 1;
if (!Number.isFinite(strokeWidth) || strokeWidth <= 0) return;
const halfWidth = strokeWidth / 2;
const x1 = rule.x1 + translateX;
const x2 = rule.x2 + translateX;
const y1 = rule.y1 + translateY;
const y2 = rule.y2 + translateY;
const dx = x2 - x1;
const dy = y2 - y1;
const length = Math.hypot(dx, dy);
const unitX = length > 0 ? dx / length : 0;
const unitY = length > 0 ? dy / length : 0;
const cap = style?.lineCap && style.lineCap !== "butt" ? halfWidth : 0;
const extendX = Math.abs(unitY) * halfWidth + Math.abs(unitX) * cap;
const extendY = Math.abs(unitX) * halfWidth + Math.abs(unitY) * cap;
const left = Math.min(x1, x2) - extendX;
const right = Math.max(x1, x2) + extendX;
const top = Math.min(y1, y2) - extendY;
const bottom = Math.max(y1, y2) + extendY;
margin.top = Math.max(margin.top, plot.y - top);
margin.right = Math.max(margin.right, right - plot.x - plot.width);
margin.bottom = Math.max(margin.bottom, bottom - plot.y - plot.height);
margin.left = Math.max(margin.left, plot.x - left);
});
}
function visitLabels(node, translateX, translateY, visit) {
if (node.kind === "label") {
visit(node, translateX, translateY);
return;
}
if (node.kind !== "group") return;
const childTranslateX = translateX + (node.translateX ?? 0);
const childTranslateY = translateY + (node.translateY ?? 0);
for (const child of node.children) {
visitLabels(child, childTranslateX, childTranslateY, visit);
}
}
function visitRules(node, translateX, translateY, visit) {
if (node.kind === "rule") {
visit(node, translateX, translateY);
return;
}
if (node.kind !== "group") return;
const childTranslateX = translateX + (node.translateX ?? 0);
const childTranslateY = translateY + (node.translateY ?? 0);
for (const child of node.children) {
visitRules(child, childTranslateX, childTranslateY, visit);
}
}
function rotateBounds(bounds, originX, originY, degrees) {
const radians = degrees * Math.PI / 180;
const cosine = Math.cos(radians);
const sine = Math.sin(radians);
const centerX = bounds.x + bounds.width / 2 - originX;
const centerY = bounds.y + bounds.height / 2 - originY;
const width = Math.abs(bounds.width * cosine) + Math.abs(bounds.height * sine);
const height = Math.abs(bounds.width * sine) + Math.abs(bounds.height * cosine);
const rotatedCenterX = centerX * cosine - centerY * sine + originX;
const rotatedCenterY = centerX * sine + centerY * cosine + originY;
return {
x: rotatedCenterX - width / 2,
y: rotatedCenterY - height / 2,
width,
height
};
}
function estimateCharacterWidth(character) {
if (/\s/u.test(character)) return 0.33;
if (/[\u0300-\u036f]/u.test(character)) return 0;
if (/[ilI1|!.,:;'`]/u.test(character)) return 0.28;
if (/[mwMW@#%&]/u.test(character)) return 0.9;
if (/[A-Z]/u.test(character)) return 0.64;
if (/[0-9]/u.test(character)) return 0.56;
if (character.codePointAt(0) > 127) return 1;
return 0.54;
}
function finiteNonNegative(value, fallback) {
return value !== void 0 && Number.isFinite(value) && value >= 0 ? value : fallback;
}
function finiteNumber(value, fallback) {
return value !== void 0 && Number.isFinite(value) ? value : fallback;
}
function finitePositive(value, fallback) {
return value !== void 0 && Number.isFinite(value) && value > 0 ? value : fallback;
}
export {
estimateSceneText,
includeGuideStrokeMargins,
logicalTextAnchorOffset,
measureSceneLabelBounds,
physicalTextAnchor,
resolveGuideMargins,
withChartTextTypography
};