UNPKG

@tanstack/charts

Version:

A chart grammar for TypeScript and JavaScript. Marks consume your data directly, channels describe visual encodings, and the engine compiles them into a renderer-neutral keyed scene. TanStack's compact scales cover common numeric and categorical mappings.

150 lines (149 loc) 5.7 kB
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 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 = style.anchor === "middle" ? -width / 2 : style.anchor === "end" ? -width : 0; 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); }); return { top, right, bottom, 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 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, measureSceneLabelBounds, resolveGuideMargins, withChartTextTypography };