feat(charts): prepare validated comparison data
This commit is contained in:
@@ -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<Record<string, string | null>>) {
|
||||||
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user