diff --git a/lib/charts/comparison.test.ts b/lib/charts/comparison.test.ts new file mode 100644 index 0000000..60e50d4 --- /dev/null +++ b/lib/charts/comparison.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; + +import { prepareComparisonChart } from "./comparison"; + +const sourceId = "1100db26-5efc-459b-b208-3b4a33cc0d16"; +const config = { + dataSourceId: sourceId, + categoryField: "name", + series: [ + { field: "damage", label: "ดาเมจ", color: "chart-1" }, + { field: "support", label: "สนับสนุน", color: "chart-2" }, + ], + presentation: "grouped-bar", + normalization: "raw", + sort: "authored", + showValues: true, + showLegend: true, + tableFallback: true, +} as const; + +function source(rows: Array>) { + return { + id: sourceId, + name: "เปรียบเทียบ", + version: 1, + fields: [ + { key: "name", label: "ชื่อ", type: "text" as const }, + { key: "damage", label: "ดาเมจ", type: "number" as const }, + { key: "support", label: "สนับสนุน", type: "number" as const }, + ], + rows: rows.map((values, index) => ({ + id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`, + values, + })), + }; +} + +describe("comparison chart preparation", () => { + it("preserves exact raw values and authored order", () => { + const result = prepareComparisonChart( + config, + source([ + { name: "A", damage: "9007199254740991.25", support: "2" }, + { name: "B", damage: "-3", support: "4" }, + ]), + ); + expect(result.status).toBe("ready"); + if (result.status === "ready") { + expect(result.rows.map((row) => row.category)).toEqual(["A", "B"]); + expect(result.rows[0].exactValues.damage).toBe("9007199254740991.25"); + expect(result.rows[1].values.damage).toBe(-3); + } + }); + + it("sorts by the first authored series", () => { + const result = prepareComparisonChart( + { ...config, sort: "descending" }, + source([ + { name: "ต่ำ", damage: "1", support: "20" }, + { name: "สูง", damage: "10", support: "2" }, + ]), + ); + expect(result.status === "ready" && result.rows.map((row) => row.category)).toEqual([ + "สูง", + "ต่ำ", + ]); + }); + + it("normalizes percent totals and baseline 100 deterministically", () => { + const percent = prepareComparisonChart( + { ...config, normalization: "percent-of-total" }, + source([{ name: "A", damage: "1", support: "3" }]), + ); + expect(percent.status === "ready" && percent.rows[0].normalizedValues).toEqual({ + damage: "25", + support: "75", + }); + + const baseline = prepareComparisonChart( + { ...config, normalization: "baseline-100" }, + source([ + { name: "ฐาน", damage: "4", support: "10" }, + { name: "ใหม่", damage: "6", support: "5" }, + ]), + ); + expect(baseline.status === "ready" && baseline.rows[1].normalizedValues).toEqual({ + damage: "150", + support: "50", + }); + }); + + it("returns typed errors for negative percentages, formula failures, and crowded data", () => { + expect( + prepareComparisonChart( + { ...config, normalization: "percent-of-total" }, + source([{ name: "A", damage: "-1", support: "3" }]), + ), + ).toMatchObject({ status: "error", code: "incompatible-normalization" }); + + expect( + prepareComparisonChart( + config, + source([{ name: "A", damage: "", support: "3" }]), + ), + ).toMatchObject({ status: "error", code: "invalid-value", field: "damage" }); + + expect( + prepareComparisonChart( + config, + source( + Array.from({ length: 21 }, (_, index) => ({ + name: String(index), + damage: "1", + support: "1", + })), + ), + ), + ).toMatchObject({ status: "error", code: "too-many-categories" }); + }); + + it("returns useful empty and missing-field states", () => { + expect(prepareComparisonChart(config, undefined)).toMatchObject({ status: "empty" }); + expect(prepareComparisonChart(config, source([]))).toMatchObject({ status: "empty" }); + expect( + prepareComparisonChart( + { ...config, categoryField: "missing" }, + source([{ name: "A", damage: "1", support: "2" }]), + ), + ).toMatchObject({ status: "error", code: "missing-field", field: "missing" }); + }); +}); diff --git a/lib/charts/comparison.ts b/lib/charts/comparison.ts new file mode 100644 index 0000000..8073873 --- /dev/null +++ b/lib/charts/comparison.ts @@ -0,0 +1,250 @@ +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 }; +}