← Files DataARCHIVED FILE
templates/data-app/base/src/charting/chart-transforms.js
16 KB · Sep 30, 2026 · 23:19 UTC
export function pivot(rows, x, group, value) {
const grouped = new Map();
rows.forEach((row) => {
if (!grouped.has(row[x])) grouped.set(row[x], { [x]: row[x] });
grouped.get(row[x])[row[group]] = row[value];
});
return [...grouped.values()];
}
/** Points with no adjacent finite observation form a one-point line segment. */
export function isolatedPointIndexes(rows, field) {
const present = (index) => Number.isFinite(rows[index]?.[field]);
const indexes = [];
for (let index = 0; index < rows.length; index += 1) {
if (present(index) && !present(index - 1) && !present(index + 1)) indexes.push(index);
}
return indexes;
}
export function stackedMarkBounds(payload, fields, field, { x, y, width, height, horizontal = false }) {
const current = Number(payload?.[field]);
if (!Number.isFinite(current) || current === 0 || !width || !height) return null;
const values = fields.map((name) => {
const value = Number(payload?.[name]);
return Number.isFinite(value) && Math.sign(value) === Math.sign(current) ? Math.abs(value) : 0;
});
const index = fields.indexOf(field);
if (index < 0) return null;
const scale = Math.abs(horizontal ? width : height) / Math.abs(current);
const total = values.reduce((sum, value) => sum + value, 0);
const before = values.slice(0, index).reduce((sum, value) => sum + value, 0);
const after = values.slice(index + 1).reduce((sum, value) => sum + value, 0);
return {
x: horizontal ? (current < 0 ? x + width - after * scale : x - before * scale) : x,
y: horizontal ? y : current < 0 ? y + height - before * scale : y - after * scale,
width: horizontal ? total * scale : Math.abs(width),
height: horizontal ? Math.abs(height) : total * scale,
};
}
export function histogram(rows, field) {
const values = rows.map((row) => Number(row[field])).filter(Number.isFinite);
if (!values.length) return [];
const minimum = Math.min(...values);
const maximum = Math.max(...values);
const targetCount = Math.max(1, Math.min(16, Math.ceil(Math.sqrt(values.length))));
const range = maximum - minimum || Math.max(Math.abs(maximum), 1);
const rawWidth = range / targetCount;
const magnitude = 10 ** Math.floor(Math.log10(rawWidth));
const normalizedWidth = rawWidth / magnitude;
const niceWidth = normalizedWidth <= 1 ? 1 : normalizedWidth <= 2 ? 2 : normalizedWidth <= 5 ? 5 : 10;
const width = niceWidth * magnitude;
const first = Math.floor(minimum / width) * width;
const bucketCount = Math.max(1, Math.ceil((maximum - first) / width));
const buckets = Array.from({ length: bucketCount }, (_, index) => ({
start: first + index * width,
count: 0,
end: first + (index + 1) * width,
}));
values.forEach((value) => {
const index = Math.min(bucketCount - 1, Math.max(0, Math.floor((value - first) / width)));
buckets[index].count += 1;
});
return buckets;
}
const waterfallRoleFields = ["totalType", "waterfallRole", "role", "type"];
const waterfallTotalFields = ["isTotal", "total"];
const waterfallCategoryFields = ["label", "stage", "name", "driver"];
const firstDefinedField = (row, fields) => fields.find((field) => row[field] != null);
const firstDefinedValue = (row, fields) => {
const field = firstDefinedField(row, fields);
return field === undefined ? undefined : row[field];
};
/** Raw metadata that can affect this waterfall's running balances. */
export function waterfallRowFields(rows, categoryField) {
const fields = new Set();
for (const row of rows) {
const roleField = firstDefinedField(row, waterfallRoleFields);
if (roleField) fields.add(roleField);
for (const field of waterfallTotalFields) if (row[field] === true) fields.add(field);
if (!categoryField) {
const category = firstDefinedField(row, waterfallCategoryFields);
if (category) fields.add(category);
}
}
return [...fields];
}
function waterfallTotalKind(row, categoryField) {
const role = String(firstDefinedValue(row, waterfallRoleFields) ?? "")
.trim()
.toLocaleLowerCase();
const category = String(categoryField ? row[categoryField] : firstDefinedValue(row, waterfallCategoryFields) ?? "")
.trim()
.toLocaleLowerCase();
if (/^(?:before|beginning|opening|starting|initial)(?:\s+(?:balance|total|value))?$/u.test(role || category)) {
return "beginning";
}
if (/^(?:after|ending|closing|final)(?:\s+(?:balance|total|value))?$|^(?:end|net)\s+total$/u.test(role || category))
return "ending";
if (waterfallTotalFields.some((field) => row[field] === true) || role === "total") return "total";
return "";
}
export function waterfall(rows, field, options = {}) {
const { categoryField, beginning, ending, includeEnding = false } = options;
const source = [...rows];
const hasBeginningRow = source.some((row) => waterfallTotalKind(row, categoryField) === "beginning");
if (Number.isFinite(beginning) && !hasBeginningRow) {
source.unshift({
...(categoryField ? { [categoryField]: "Beginning" } : {}),
[field]: beginning,
totalType: "beginning",
isTotal: true,
});
}
if (source.length < 2) return [];
const endpointValues = source.map((row) => Number(row[field]));
const movementTotal = endpointValues.slice(1, -1).reduce((total, value) => total + value, 0);
const inferredEndpoints =
source.length >= 3 &&
!source.some((row) => waterfallTotalKind(row, categoryField)) &&
endpointValues.every(Number.isFinite) &&
Math.abs(endpointValues[0] + movementTotal - endpointValues.at(-1)) <=
Number.EPSILON * Math.max(1, ...endpointValues.map(Math.abs)) * 16;
let balance = 0;
const result = source.map((row, index) => {
const numeric = Number(row[field]);
const value = Number.isFinite(numeric) ? numeric : 0;
const totalType =
waterfallTotalKind(row, categoryField) ||
(inferredEndpoints && index === 0
? "beginning"
: inferredEndpoints && index === source.length - 1
? "ending"
: "");
const isTotal = Boolean(totalType);
const previous = isTotal ? 0 : balance;
if (isTotal) balance = value;
else balance += value;
const change = isTotal ? null : value;
const waterfallRole =
totalType === "beginning" ? "before" : totalType === "ending" ? "after" : totalType || "change";
return {
...row,
baseline: Math.min(previous, balance),
magnitude: Math.abs(balance - previous),
range: [previous, balance],
change,
balance,
runningTotal: balance,
isTotal,
totalType: totalType || undefined,
waterfallRole,
};
});
const hasEndingRow = result.some((row) => row.totalType === "ending");
const canDeriveEnding = includeEnding && result.some((row) => row.totalType === "beginning");
if (!hasEndingRow && (Number.isFinite(ending) || canDeriveEnding)) {
const value = Number.isFinite(ending) ? ending : balance;
result.push({
...(categoryField ? { [categoryField]: "Ending" } : {}),
[field]: value,
baseline: Math.min(0, value),
magnitude: Math.abs(value),
range: [0, value],
change: null,
balance: value,
runningTotal: value,
isTotal: true,
totalType: "ending",
waterfallRole: "after",
});
}
return result;
}
export function waterfallValueDomain(rows) {
const values = rows.flatMap((row) => (row.isTotal ? [row.runningTotal] : row.range ?? [])).filter(Number.isFinite);
if (!values.length) return undefined;
const minimum = Math.min(...values);
const maximum = Math.max(...values);
const padding = Math.max((maximum - minimum) * 0.12, Math.max(Math.abs(minimum), Math.abs(maximum), 1) * 0.01);
return [minimum - padding, maximum + padding];
}
export function funnelLayout(width, count) {
if (!count || width == null || width >= count * 144) return "horizontal";
return width >= count * 120 ? "compact" : "vertical";
}
export function funnelStages(rows, category, field) {
const numericValue = (value) => {
if (!(typeof value === "number" || typeof value === "string" && value.trim())) return undefined;
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : undefined;
};
const baseline = numericValue(rows[0]?.[field]);
return rows.map((row, index) => {
const value = numericValue(row[field]);
const previous = index ? numericValue(rows[index - 1]?.[field]) : undefined;
const conversion =
index && Number.isFinite(value) && value >= 0 && Number.isFinite(previous) && previous > 0
? value / previous
: undefined;
return {
...row,
__funnelStage: String(row[category] ?? ""),
__funnelValue: Number.isFinite(value) ? value : undefined,
__funnelConversion: conversion,
__funnelChange: Number.isFinite(conversion) ? conversion - 1 : undefined,
__funnelShare: Number.isFinite(value) && baseline > 0 ? value / baseline : undefined,
__funnelDropoff: Number.isFinite(value) && Number.isFinite(previous) ? previous - value : undefined,
};
});
}
// Each stage is measured at its column center. Smooth joins stay between the
// endpoint values; missing stages break the ribbon instead of inventing zeroes.
export function funnelRibbonSegments(stages) {
const maximum = Math.max(0, ...stages.map((stage) => stage.__funnelValue ?? 0));
const segments = [];
let points = [];
const finish = () => {
if (!points.length) return;
const first = points[0];
const last = points.at(-1);
const edge = (point, side) => 100 + side * point.height / 2;
let path = `M ${first.x - 50} ${edge(first, -1)} L ${first.x} ${edge(first, -1)}`;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const point = points[index];
const middle = (previous.x + point.x) / 2;
path += ` C ${middle} ${edge(previous, -1)} ${middle} ${edge(point, -1)} ${point.x} ${edge(point, -1)}`;
}
path += ` L ${last.x + 50} ${edge(last, -1)} L ${last.x + 50} ${edge(last, 1)} L ${last.x} ${edge(last, 1)}`;
for (let index = points.length - 2; index >= 0; index -= 1) {
const previous = points[index + 1];
const point = points[index];
const middle = (previous.x + point.x) / 2;
path += ` C ${middle} ${edge(previous, 1)} ${middle} ${edge(point, 1)} ${point.x} ${edge(point, 1)}`;
}
path += ` L ${first.x - 50} ${edge(first, 1)} Z`;
segments.push({ path, points });
points = [];
};
stages.forEach((stage, index) => {
if (!Number.isFinite(stage.__funnelValue)) { finish(); return; }
points.push({ x: index * 100 + 50, height: maximum > 0 ? stage.__funnelValue / maximum * 180 : 0 });
});
finish();
return segments;
}
export function normalizeZoomRange(rows, field, first, second) {
if (first == null || second == null || String(first) === String(second)) return null;
const [start, end] = [String(first), String(second)].sort();
const matching = rows.filter((row) => String(row[field]) >= start && String(row[field]) <= end);
return matching.length > 1 ? { start, end } : null;
}
export function orderTooltipEntries(entries, { stacked = false, vertical = false } = {}) {
if (stacked) return [...entries].reverse();
if (!vertical) return [...entries];
return [...entries].sort((left, right) => Number(right.value) - Number(left.value));
}
function quantile(values, fraction) {
const index = (values.length - 1) * fraction;
const lower = Math.floor(index);
return values[lower] + (values[Math.ceil(index)] - values[lower]) * (index - lower);
}
export const boxPlotSummaryFields = Object.freeze(["minimum", "lowerQuartile", "median", "upperQuartile", "maximum"]);
export function hasReviewedBoxPlotSummary(rows) {
return rows.length > 0 && rows.every((row) => boxPlotSummaryFields.every((field) => Number.isFinite(row[field])));
}
export function boxPlots(rows, category, value) {
if (!rows.length) return [];
if (hasReviewedBoxPlotSummary(rows)) {
return rows.map((row) => ({ ...row, spread: row.upperQuartile - row.lowerQuartile }));
}
const groups = new Map();
rows.forEach((row) => {
const observation = typeof row[value] === "number" ? row[value] : Number(row[value]);
if (row[value] == null || !Number.isFinite(observation)) return;
if (!groups.has(row[category])) groups.set(row[category], []);
groups.get(row[category]).push(observation);
});
return [...groups].map(([name, observations]) => {
const ordered = observations.filter(Number.isFinite).sort((left, right) => left - right);
const lowerQuartile = quantile(ordered, 0.25);
const upperQuartile = quantile(ordered, 0.75);
return {
[category]: name,
minimum: ordered[0],
lowerQuartile,
median: quantile(ordered, 0.5),
upperQuartile,
maximum: ordered.at(-1),
spread: upperQuartile - lowerQuartile,
};
});
}
export function heatmap(rows, x, y, value, { domain, startAtZero, xOrder, yOrder, missingValues } = {}) {
const orderedValues = (field, preferred) => {
const observed = [...new Set(rows.map((row) => row[field]))];
if (!Array.isArray(preferred)) return observed;
const available = new Set(observed);
const ordered = preferred.filter((item) => available.has(item));
return [...ordered, ...observed.filter((item) => !ordered.includes(item))];
};
const xValues = orderedValues(x, xOrder);
const yValues = orderedValues(y, yOrder);
const values = rows.filter(row => missingValues !== "gap" || row[value] != null).map((row) => Number(row[value])).filter(Number.isFinite);
const observedMinimum = Math.min(...values, 0);
const observedMaximum = Math.max(...values, 0);
const actualMinimum = values.length ? Math.min(...values) : 0;
const compressedRange = actualMinimum > 0 && observedMaximum > actualMinimum && actualMinimum / observedMaximum > 0.7;
const minimum = Number.isFinite(domain?.[0])
? domain[0]
: startAtZero === false || (startAtZero !== true && compressedRange)
? actualMinimum
: observedMinimum;
const maximum = Number.isFinite(domain?.[1]) ? domain[1] : observedMaximum;
const range = Math.max(Number.EPSILON, maximum - minimum);
const observed = new Map(rows.map((row) => [JSON.stringify([row[x], row[y]]), row]));
return {
rows: xValues.flatMap((column, xIndex) =>
yValues.map((group, yIndex) => {
const source = observed.get(JSON.stringify([column, group]));
const unknown = missingValues === "gap" && (!source || source[value] == null);
const row = source ?? { [x]: column, [y]: group, [value]: unknown ? null : 0, __missing: true };
return {
...row,
...(unknown ? { __unknown: true } : {}),
xIndex,
yIndex,
intensity: source ? Math.max(0, Math.min(1, ((Number(row[value]) || 0) - minimum) / range)) : 0,
};
}),
),
xValues,
yValues,
minimum,
maximum,
};
}
export function sankeyGraph(rows, stages, value) {
const fields = stages.filter(Boolean);
if (fields.length < 2) return { nodes: [], links: [] };
const nodes = [];
const identities = new Map();
const links = new Map();
for (const row of rows) {
const amount = Number(row[value]);
if (!Number.isFinite(amount) || amount <= 0) continue;
const path = fields.flatMap((field, stage) => {
const name = row[field];
return name == null || name === "" ? [] : [{ stage, name }];
});
for (let index = 0; index < path.length - 1; index += 1) {
const sourceValue = path[index];
const targetValue = path[index + 1];
const node = (stage, name) => {
const identity = JSON.stringify([stage, name]);
if (!identities.has(identity)) {
identities.set(identity, nodes.length);
nodes.push({ name: String(name), stage, field: fields[stage] });
}
return identities.get(identity);
};
const source = node(sourceValue.stage, sourceValue.name);
const target = node(targetValue.stage, targetValue.name);
const identity = `${source}:${target}`;
const current = links.get(identity);
if (current) current.value += amount;
else links.set(identity, { source, target, value: amount });
}
}
return { nodes, links: [...links.values()] };
}
SHA-256: 782d55023ec8dddfb99e7c5584c39cc23e102317aa3ca1e4668043c8814c3e86