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.

407 lines (406 loc) 14.7 kB
import { areaY } from "./area.js"; import { areaX } from "./area-x.js"; import { lineX, lineY } from "./line.js"; import { channelValues, createMark, isChartKey, isFiniteNumber } from "./mark.js"; import { initializeCompositeMark } from "./mark-composite-internal.js"; import { valueKey } from "./scales.js"; import { groupedIndexes, toArray, transformValues } from "./transform-internal.js"; const interactiveRegressionChildren = /* @__PURE__ */ new Set(["line"]); function linearRegressionRowsY(source, options) { const data = toArray(source); const normalized = normalizeRegressionOptions( options, "linearRegressionRowsY" ); return regressionRowsYFromValues( data, transformValues(data, options.x), transformValues(data, options.y), options.z === void 0 ? data.map(() => null) : transformValues(data, options.z), normalized, "linearRegressionRowsY" ); } function linearRegressionRowsX(source, options) { const data = toArray(source); const normalized = normalizeRegressionOptions( options, "linearRegressionRowsX" ); return regressionRowsXFromValues( data, transformValues(data, options.y), transformValues(data, options.x), options.z === void 0 ? data.map(() => null) : transformValues(data, options.z), normalized, "linearRegressionRowsX" ); } function linearRegressionY(source, options) { const data = Array.isArray(source) ? source : Array.from(source); return createMark(({ markIndex }) => { const id = options.id ?? `linear-regression-y-${markIndex}`; const normalized = normalizeRegressionOptions(options, "linearRegressionY"); const independentValues = channelValues(data, options.x, () => void 0); const dependentValues = channelValues(data, options.y, () => void 0); const groups = channelValues(data, options.z, () => null); const semanticRows = linearRegressionRowsY(data, { x: (_datum, { index }) => independentValues[index], y: (_datum, { index }) => dependentValues[index], z: (_datum, { index }) => groups[index], ...normalized }); const rows = withRegressionMarkKeys(semanticRows); const children = [ ...normalized.ci === 0 ? [] : [ areaY(rows, { id: "band", x: "x", y: "y", y1: "y1", y2: "y2", z: "group", key: "markKey", fill: options.fill ?? options.stroke, fillOpacity: options.fillOpacity ?? 0.1 }) ], lineY(rows, { id: "line", x: "x", y: "y", z: "group", key: "markKey", stroke: options.stroke, strokeOpacity: options.strokeOpacity, strokeWidth: options.strokeWidth ?? 1.5, strokeDasharray: options.strokeDasharray }) ]; return initializeCompositeMark(id, children, { motion: options.motion, interactiveChildren: interactiveRegressionChildren }); }); } function linearRegressionX(source, options) { const data = Array.isArray(source) ? source : Array.from(source); return createMark(({ markIndex }) => { const id = options.id ?? `linear-regression-x-${markIndex}`; const normalized = normalizeRegressionOptions(options, "linearRegressionX"); const independentValues = channelValues(data, options.y, () => void 0); const dependentValues = channelValues(data, options.x, () => void 0); const groups = channelValues(data, options.z, () => null); const semanticRows = linearRegressionRowsX(data, { x: (_datum, { index }) => dependentValues[index], y: (_datum, { index }) => independentValues[index], z: (_datum, { index }) => groups[index], ...normalized }); const rows = withRegressionMarkKeys(semanticRows); const children = [ ...normalized.ci === 0 ? [] : [ areaX(rows, { id: "band", x: "x", x1: "x1", x2: "x2", y: "y", z: "group", key: "markKey", fill: options.fill ?? options.stroke, fillOpacity: options.fillOpacity ?? 0.1 }) ], lineX(rows, { id: "line", x: "x", y: "y", z: "group", key: "markKey", stroke: options.stroke, strokeOpacity: options.strokeOpacity, strokeWidth: options.strokeWidth ?? 1.5, strokeDasharray: options.strokeDasharray }) ]; return initializeCompositeMark(id, children, { motion: options.motion, interactiveChildren: interactiveRegressionChildren }); }); } function normalizeRegressionOptions(options, owner) { const ci = options.ci ?? 0.95; const samples = options.samples ?? 64; if (!Number.isFinite(ci) || ci < 0 || ci >= 1) { throw new TypeError(`${owner}: ci must be a finite number in [0, 1)`); } if (!Number.isInteger(samples) || samples < 2) { throw new TypeError(`${owner}: samples must be an integer of at least 2`); } return { ci, samples }; } function regressionRowsYFromValues(data, independentValues, dependentValues, groups, options, owner) { return regressionSamples( data, independentValues, dependentValues, groups, options, owner ).map((sample) => ({ x: sample.independent, y: sample.predicted, ...sample.lower === void 0 ? {} : { y1: sample.lower }, ...sample.upper === void 0 ? {} : { y2: sample.upper }, group: sample.group, source: sample.source, sourceIndexes: sample.sourceIndexes })); } function regressionRowsXFromValues(data, independentValues, dependentValues, groups, options, owner) { return regressionSamples( data, independentValues, dependentValues, groups, options, owner ).map((sample) => ({ x: sample.predicted, ...sample.lower === void 0 ? {} : { x1: sample.lower }, ...sample.upper === void 0 ? {} : { x2: sample.upper }, y: sample.independent, group: sample.group, source: sample.source, sourceIndexes: sample.sourceIndexes })); } function withRegressionMarkKeys(rows) { const groupIndexes = /* @__PURE__ */ new Map(); return rows.map((row) => { const groupKey = valueKey(row.group); const sampleIndex = groupIndexes.get(groupKey) ?? 0; groupIndexes.set(groupKey, sampleIndex + 1); return { ...row, markKey: `${groupKey}:${sampleIndex}` }; }); } function regressionSamples(data, independentValues, dependentValues, rawGroups, options, owner) { const groups = rawGroups.map((group) => isChartKey(group) ? group : null); const independentKind = validateIndependentKind( independentValues, dependentValues, owner ); return groupedIndexes(groups).flatMap(({ key: group, indexes }) => { const observations = indexes.flatMap((sourceIndex) => { const independent = numericIndependent(independentValues[sourceIndex]); const dependent = dependentValues[sourceIndex]; return independent !== void 0 && isFiniteNumber(dependent) ? [{ sourceIndex, independent, dependent }] : []; }); if (observations.length < 2) return []; const fit = fitRegression(observations, options.ci); if (fit === void 0) return []; let minimum = Number.POSITIVE_INFINITY; let maximum = Number.NEGATIVE_INFINITY; observations.forEach(({ independent }) => { minimum = Math.min(minimum, independent); maximum = Math.max(maximum, independent); }); const sourceIndexes = observations.map(({ sourceIndex }) => sourceIndex); const lineageSource = sourceIndexes.map((index) => data[index]); return Array.from({ length: options.samples }, (_value, sampleIndex) => { const independent = sampleIndex === 0 ? minimum : sampleIndex === options.samples - 1 ? maximum : minimum + (maximum - minimum) * sampleIndex / (options.samples - 1); const predicted = predictRegression(fit, independent); if (!Number.isFinite(predicted)) { throw new TypeError(`${owner}: fitted values must be finite`); } const halfWidth = confidenceHalfWidth(fit, independent); const lower = halfWidth === void 0 ? void 0 : predicted - halfWidth; const upper = halfWidth === void 0 ? void 0 : predicted + halfWidth; if (lower !== void 0 && !Number.isFinite(lower) || upper !== void 0 && !Number.isFinite(upper)) { throw new TypeError(`${owner}: confidence values must be finite`); } return { independent: independentKind === "date" ? new Date(independent) : independent, predicted, ...lower === void 0 ? {} : { lower }, ...upper === void 0 ? {} : { upper }, group, source: lineageSource, sourceIndexes }; }); }); } function validateIndependentKind(independentValues, dependentValues, owner) { let kind; independentValues.forEach((value, index) => { if (numericIndependent(value) === void 0) return; if (!isFiniteNumber(dependentValues[index])) return; const next = value instanceof Date ? "date" : "number"; if (kind !== void 0 && kind !== next) { throw new TypeError( `${owner}: independent values must be uniformly numbers or Dates` ); } kind = next; }); return kind ?? "number"; } function numericIndependent(value) { if (isFiniteNumber(value)) return value; if (value instanceof Date && Number.isFinite(value.getTime())) { return value.getTime(); } return void 0; } function fitRegression(observations, ci) { let meanIndependent = 0; let meanDependent = 0; let sumIndependentSquares = 0; let sumProducts = 0; observations.forEach(({ independent, dependent }, index) => { const count = index + 1; const independentDelta = independent - meanIndependent; const dependentDelta = dependent - meanDependent; meanIndependent += independentDelta / count; meanDependent += dependentDelta / count; sumIndependentSquares += independentDelta * (independent - meanIndependent); sumProducts += independentDelta * (dependent - meanDependent); }); if (!Number.isFinite(sumIndependentSquares) || sumIndependentSquares <= 0) { return void 0; } const slope = sumProducts / sumIndependentSquares; if (!Number.isFinite(slope)) return void 0; const residualDegrees = observations.length - 2; if (ci === 0 || residualDegrees <= 0) { return { count: observations.length, meanIndependent, meanDependent, sumIndependentSquares, slope }; } let residualSquares = 0; observations.forEach(({ independent, dependent }) => { const residual = dependent - (meanDependent + slope * (independent - meanIndependent)); residualSquares += residual * residual; }); const residualStandardError = Math.sqrt( Math.max(0, residualSquares) / residualDegrees ); const criticalValue = inverseStudentT((1 + ci) / 2, residualDegrees); if (!Number.isFinite(residualStandardError) || !Number.isFinite(criticalValue)) { return void 0; } return { count: observations.length, meanIndependent, meanDependent, sumIndependentSquares, slope, residualStandardError, criticalValue }; } function predictRegression(fit, independent) { return fit.meanDependent + fit.slope * (independent - fit.meanIndependent); } function confidenceHalfWidth(fit, independent) { if (fit.residualStandardError === void 0 || fit.criticalValue === void 0) { return void 0; } const centered = independent - fit.meanIndependent; const standardError = fit.residualStandardError * Math.sqrt(1 / fit.count + centered * centered / fit.sumIndependentSquares); return fit.criticalValue * standardError; } function inverseStudentT(probability, degreesOfFreedom) { if (probability === 0.5) return 0; const sign = probability < 0.5 ? -1 : 1; const target = probability < 0.5 ? 1 - probability : probability; let low = 0; let high = 1; while (studentTCdf(high, degreesOfFreedom) < target) high *= 2; for (let iteration = 0; iteration < 64; iteration += 1) { const middle = (low + high) / 2; if (studentTCdf(middle, degreesOfFreedom) < target) low = middle; else high = middle; } return sign * ((low + high) / 2); } function studentTCdf(value, degreesOfFreedom) { const ratio = degreesOfFreedom / (degreesOfFreedom + value * value); const tail = regularizedIncompleteBeta(ratio, degreesOfFreedom / 2, 0.5) / 2; return value >= 0 ? 1 - tail : tail; } function regularizedIncompleteBeta(value, alpha, beta) { if (value <= 0) return 0; if (value >= 1) return 1; const factor = Math.exp( logGamma(alpha + beta) - logGamma(alpha) - logGamma(beta) + alpha * Math.log(value) + beta * Math.log1p(-value) ); return value < (alpha + 1) / (alpha + beta + 2) ? factor * betaContinuedFraction(value, alpha, beta) / alpha : 1 - factor * betaContinuedFraction(1 - value, beta, alpha) / beta; } function betaContinuedFraction(value, alpha, beta) { const floor = 1e-30; const sum = alpha + beta; const alphaPlus = alpha + 1; const alphaMinus = alpha - 1; let c = 1; let d = 1 - sum * value / alphaPlus; if (Math.abs(d) < floor) d = floor; d = 1 / d; let result = d; for (let iteration = 1; iteration <= 200; iteration += 1) { const doubled = iteration * 2; let numerator = iteration * (beta - iteration) * value / ((alphaMinus + doubled) * (alpha + doubled)); d = 1 + numerator * d; if (Math.abs(d) < floor) d = floor; c = 1 + numerator / c; if (Math.abs(c) < floor) c = floor; d = 1 / d; result *= d * c; numerator = -(alpha + iteration) * (sum + iteration) * value / ((alpha + doubled) * (alphaPlus + doubled)); d = 1 + numerator * d; if (Math.abs(d) < floor) d = floor; c = 1 + numerator / c; if (Math.abs(c) < floor) c = floor; d = 1 / d; const change = d * c; result *= change; if (Math.abs(change - 1) < 3e-12) break; } return result; } function logGamma(value) { const coefficients = [ 676.5203681218851, -1259.1392167224028, 771.3234287776531, -176.6150291621406, 12.507343278686905, -0.13857109526572012, 9984369578019572e-21, 15056327351493116e-23 ]; if (value < 0.5) { return Math.log(Math.PI) - Math.log(Math.sin(Math.PI * value)) - logGamma(1 - value); } const shifted = value - 1; let series = 0.9999999999998099; coefficients.forEach((coefficient, index) => { series += coefficient / (shifted + index + 1); }); const total = shifted + coefficients.length - 0.5; return 0.5 * Math.log(2 * Math.PI) + (shifted + 0.5) * Math.log(total) - total + Math.log(series); } export { linearRegressionRowsX, linearRegressionRowsY, linearRegressionX, linearRegressionY };