← Files DataARCHIVED FILE
templates/data-app/base/src/charting/table-data.js
3.12 KB · Sep 30, 2026 · 23:19 UTC
export function numericValue(value) {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (typeof value !== "string" || !value.trim()) return null;
const text = value.trim().replace(/^[−–]/u, "-");
const match = text.match(/^([+-]?)\s*[$€£]?\s*((?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|\.\d+)\s*(%)?$/u);
const number = match ? Number(`${match[1]}${match[2].replaceAll(",", "")}`) / (match[3] ? 100 : 1) : NaN;
return Number.isFinite(number) ? number : null;
}
/** Compare numeric values before formatting; keep missing values last in either direction. */
export function compareTableValues(left, right, descending = false) {
const missing = (value) => value == null || value === "" || typeof value === "number" && !Number.isFinite(value);
if (missing(left) || missing(right)) return Number(missing(left)) - Number(missing(right));
const a = numericValue(left);
const b = numericValue(right);
const comparison = a !== null && b !== null ? a - b
: String(left).localeCompare(String(right), undefined, { numeric: true });
return comparison * (descending ? -1 : 1);
}
// Color is an authored interpretation, separate from a value's numeric sign.
// An invalid explicit interpretation must not fall back to sign-based coloring.
export function resolveDeltaTone(tone, value, row, fallback) {
if (tone === undefined) return fallback;
try {
const resolved = typeof tone === "function" ? tone(value, row) : tone;
return ["positive", "negative", "neutral"].includes(resolved) ? resolved : "neutral";
} catch {
return "neutral";
}
}
export function statusTone(value) {
const status = String(value ?? "").toLowerCase();
if (/\b(?:low|no)\s+risk\b|\brisk[ -]free\b/u.test(status)) return "positive";
if (/\b(?:moderate|medium|warning|slipping)\b/u.test(status) || /^(?:due soon|investigating|mitigating)$/u.test(status)) return "warning";
if (/\b(?:elevated|high|critical|risk|lost|churned|failed|cancelled|canceled|overdue)\b/u.test(status)) return "negative";
if (/^(?:new|pending|monitoring)$/u.test(status)) return "info";
return /^(?:low|retained|active|paid|healthy|complete|completed|success|stable|on track|using|used|resolved)$/u.test(status) ? "positive" : "neutral";
}
export function distributionBuckets(rows, field, maximum = 100) {
const counts = Array(12).fill(0);
for (const row of rows) {
const value = numericValue(row[field]);
if (value === null || value < 0 || value > maximum || maximum <= 0) continue;
counts[Math.min(11, Math.floor(value / maximum * 12))] += 1;
}
const peak = Math.max(1, ...counts);
return counts.map((count) => Math.max(10, count / peak * 100));
}
/** Right-inclusive percentile ranks, keyed by the original finite reviewed cell value. */
export function distributionPercentiles(rows, field) {
const values = rows.map((row) => [row[field], numericValue(row[field])])
.filter(([, value]) => value !== null).sort((a, b) => a[1] - b[1]);
const ranks = new Map(values.map(([, value], index) =>
[value, Math.round((index + 1) / values.length * 100)]));
return new Map(values.map(([original, value]) => [original, ranks.get(value)]));
}
SHA-256: 2e61118fccdba5c565e1995fab213da44750817d9a7da85593776d5793234ef2