Files
buzz-sheet/lib/charts/comparison.ts
T

251 lines
7.8 KiB
TypeScript

import Decimal from "decimal.js";
import {
comparisonChartSchema,
type ComparisonChartConfig,
} from "@/lib/blocks";
import {
evaluateDataSource,
type DataSourceSnapshot,
} from "@/lib/content/data-source";
const ChartDecimal = Decimal.clone({
precision: 100,
rounding: Decimal.ROUND_HALF_UP,
toExpNeg: -1_000,
toExpPos: 1_000,
});
export type ComparisonChartErrorCode =
| "invalid-config"
| "missing-field"
| "non-numeric-series"
| "too-many-categories"
| "invalid-category"
| "invalid-value"
| "incompatible-normalization"
| "unrenderable-number";
export interface PreparedComparisonRow {
id: string;
category: string;
values: Record<string, number>;
exactValues: Record<string, string>;
normalizedValues: Record<string, string>;
}
export interface PreparedComparisonChart {
status: "ready";
config: ComparisonChartConfig;
rows: PreparedComparisonRow[];
}
export interface EmptyComparisonChart {
status: "empty";
message: string;
}
export interface InvalidComparisonChart {
status: "error";
code: ComparisonChartErrorCode;
message: string;
field?: string;
rowId?: string;
}
export type ComparisonChartPreparation =
| PreparedComparisonChart
| EmptyComparisonChart
| InvalidComparisonChart;
function error(
code: ComparisonChartErrorCode,
message: string,
detail: Pick<InvalidComparisonChart, "field" | "rowId"> = {},
): InvalidComparisonChart {
return { status: "error", code, message, ...detail };
}
function normalizedDisplay(value: Decimal): string {
return value.toDecimalPlaces(2, Decimal.ROUND_HALF_UP).toString();
}
export function prepareComparisonChart(
inputConfig: unknown,
source: DataSourceSnapshot | undefined,
): ComparisonChartPreparation {
const configResult = comparisonChartSchema.safeParse(inputConfig);
if (!configResult.success) {
return error("invalid-config", "การตั้งค่ากราฟไม่ถูกต้อง");
}
const config = configResult.data;
if (!source) return { status: "empty", message: "ยังไม่ได้เชื่อมข้อมูลสำหรับกราฟนี้" };
let evaluated: ReturnType<typeof evaluateDataSource>;
try {
evaluated = evaluateDataSource(source);
} catch {
return error("invalid-config", "โครงสร้างแหล่งข้อมูลไม่ถูกต้อง");
}
const fields = new Map(source.fields.map((field) => [field.key, field]));
if (!fields.has(config.categoryField)) {
return error("missing-field", "ไม่พบฟิลด์หมวดหมู่ที่เลือก", {
field: config.categoryField,
});
}
for (const series of config.series) {
const field = fields.get(series.field);
if (!field) {
return error("missing-field", "ไม่พบฟิลด์ข้อมูลที่เลือก", {
field: series.field,
});
}
if (field.type !== "number" && !field.formula) {
return error("non-numeric-series", "กราฟรองรับเฉพาะฟิลด์ตัวเลข", {
field: series.field,
});
}
}
const visibleRows = evaluated.rows.filter((row) => !row.hidden);
if (visibleRows.length === 0) {
return { status: "empty", message: "ยังไม่มีข้อมูลที่แสดงในกราฟนี้" };
}
if (visibleRows.length > 20) {
return error(
"too-many-categories",
"กราฟแสดงได้สูงสุด 20 หมวดหมู่ โปรดซ่อนหรือแบ่งข้อมูล",
);
}
const decimalRows: Array<{
id: string;
category: string;
raw: Record<string, Decimal>;
}> = [];
for (const row of visibleRows) {
const category = row.cells[config.categoryField]?.display?.trim();
if (!category) {
return error("invalid-category", "ชื่อหมวดหมู่ต้องไม่ว่าง", {
field: config.categoryField,
rowId: row.id,
});
}
const raw: Record<string, Decimal> = {};
for (const series of config.series) {
const cell = row.cells[series.field];
if (cell?.error || cell?.canonical == null) {
return error("invalid-value", "มีค่าที่คำนวณไม่ได้ในข้อมูลกราฟ", {
field: series.field,
rowId: row.id,
});
}
try {
raw[series.field] = new ChartDecimal(cell.canonical);
} catch {
return error("invalid-value", "มีค่าตัวเลขที่ไม่ถูกต้องในข้อมูลกราฟ", {
field: series.field,
rowId: row.id,
});
}
}
decimalRows.push({ id: row.id, category, raw });
}
const baselines = Object.fromEntries(
config.series.map((series) => [
series.field,
decimalRows[0].raw[series.field],
]),
);
const prepared: PreparedComparisonRow[] = [];
for (const row of decimalRows) {
const normalized: Record<string, Decimal> = {};
if (config.normalization === "raw") {
Object.assign(normalized, row.raw);
} else if (config.normalization === "percent-of-total") {
const values = config.series.map((series) => row.raw[series.field]);
if (values.some((value) => value.isNegative())) {
return error(
"incompatible-normalization",
"เปอร์เซ็นต์ของผลรวมใช้ร่วมกับค่าติดลบไม่ได้",
{ rowId: row.id },
);
}
const total = values.reduce(
(sum, value) => sum.plus(value),
new ChartDecimal(0),
);
if (total.isZero()) {
return error(
"incompatible-normalization",
"ไม่สามารถคิดเปอร์เซ็นต์จากผลรวมศูนย์ได้",
{ rowId: row.id },
);
}
for (const series of config.series) {
normalized[series.field] = row.raw[series.field]
.dividedBy(total)
.times(100);
}
} else {
for (const series of config.series) {
const baseline = baselines[series.field];
if (baseline.isZero()) {
return error(
"incompatible-normalization",
"ค่าฐานของ baseline-100 ต้องไม่เป็นศูนย์",
{ field: series.field, rowId: decimalRows[0].id },
);
}
normalized[series.field] = row.raw[series.field]
.dividedBy(baseline)
.times(100);
}
}
const values: Record<string, number> = {};
for (const series of config.series) {
const value = normalized[series.field].toNumber();
if (!Number.isFinite(value)) {
return error(
"unrenderable-number",
"ค่ามีขนาดใหญ่เกินกว่าจะแสดงเป็นกราฟได้",
{ field: series.field, rowId: row.id },
);
}
values[series.field] = value;
}
prepared.push({
id: row.id,
category: row.category,
values,
exactValues: Object.fromEntries(
config.series.map((series) => [
series.field,
row.raw[series.field].toString(),
]),
),
normalizedValues: Object.fromEntries(
config.series.map((series) => [
series.field,
normalizedDisplay(normalized[series.field]),
]),
),
});
}
if (config.sort !== "authored") {
const primarySeries = config.series[0].field;
const direction = config.sort === "ascending" ? 1 : -1;
prepared.sort(
(left, right) =>
direction * (left.values[primarySeries] - right.values[primarySeries]),
);
}
return { status: "ready", config, rows: prepared };
}