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; exactValues: Record; normalizedValues: Record; } 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 { 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; 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; }> = []; 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 = {}; 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 = {}; 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 = {}; 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 }; }