UNPKG

victory-native

Version:

A charting library for React Native with a focus on performance and customization.

792 lines (711 loc) 21.5 kB
import { describe, it, expect } from "vitest"; import type { SkFont } from "@shopify/react-native-skia"; import type { InputDatum, AxisLabelRenderer, ValueOf, XAxisPropsWithDefaults, YAxisPropsWithDefaults, } from "../../types"; import { transformInputData } from "./transformInputData"; const DATA = [ { x: 0, y: 3, z: 0 }, { x: 1, y: 7, z: 4 }, { x: 2, y: 5, z: 10 }, ]; const OUTPUT_WINDOW = { yMin: 0, yMax: 300, xMin: 0, xMax: 500, }; const font = { getSize: () => 10, getGlyphIDs: (text: string) => Array.from(text).map((_, index) => index), getGlyphWidths: (glyphs: number[]) => glyphs.map(() => 4), } as unknown as SkFont; const axes = { xAxis: { lineColor: "hsla(0, 0%, 0%, 0.25)", lineWidth: 0.5, tickCount: 5, labelOffset: 2, axisSide: "bottom", yAxisSide: "left", labelPosition: "outset", formatXLabel: (label: ValueOf<InputDatum>) => String(label), labelColor: "#000000", } satisfies XAxisPropsWithDefaults<never, never>, yAxes: [ { lineColor: "hsla(0, 0%, 0%, 0.25)", lineWidth: 0.5, tickCount: 5, labelOffset: 0, axisSide: "left", labelPosition: "outset", formatYLabel: (label: ValueOf<InputDatum>) => String(label), labelColor: "#000000", yKeys: ["y", "z"], domain: null, } satisfies YAxisPropsWithDefaults<(typeof DATA)[number], "y" | "z">, ], }; describe("transformInputData", () => { it("transforms data into internal data structure based on x/y keys", () => { const { ix, ox, y } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y", "z"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes, }); expect(ix).toEqual([0, 1, 2]); expect(ox).toEqual([0, 250, 500]); expect(y.y).toEqual({ i: [3, 7, 5], o: [210, 90.00000000000001, 150], }); expect(y.z).toEqual({ i: [0, 4, 10], o: [300, 180, 0], }); }); it("should generate scales based on output window", () => { const { xScale, yAxes } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y", "z"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes, }); const yScale = yAxes[0].yScale; expect(xScale(0)).toEqual(0); expect(xScale(2)).toEqual(500); expect(yScale(0)).toEqual(300); expect(yScale(10)).toEqual(0); }); it("should handle viewport", () => { const { xScale, yAxes } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y", "z"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes, viewport: { // Test both x and y viewport handling x: [0.5, 1.5], y: [2, 8], }, }); const yScale = yAxes[0].yScale; expect(xScale(0.5)).toEqual(0); expect(xScale(1.5)).toEqual(500); expect(yScale(2)).toEqual(300); // min maps to bottom expect(yScale(8)).toEqual(0); // max maps to top }); it("applies sided domain padding to the x scale", () => { const { xScale } = transformInputData<(typeof DATA)[number], "x", "y">({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), domainPadding: { left: 50, right: 100 }, }); expect(xScale.domain()).toEqual([-0.2, 2.4]); expect(xScale(0)).toBeCloseTo(38.4615); expect(xScale(2)).toBeCloseTo(423.0769); }); it("keeps the full x scale domain when domain padding is used with a viewport", () => { const { xScale } = transformInputData<(typeof DATA)[number], "x", "y">({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), domainPadding: { left: 50, right: 100 }, viewport: { x: [0.5, 1.5], }, }); expect(xScale.domain()).toEqual([0, 2]); expect(xScale(0.5)).toBeCloseTo(50); expect(xScale(1)).toBeCloseTo(225); expect(xScale(1.5)).toBeCloseTo(400); expect(xScale(0)).toBeCloseTo(-125); expect(xScale(2)).toBeCloseTo(575); }); it("applies sided domain padding to the y scale", () => { const { yAxes } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y", "z"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes, domainPadding: { top: 30, bottom: 60 }, }); const yScale = yAxes[0].yScale; expect(yScale.domain()).toEqual([11, -2]); expect(yScale(10)).toBeCloseTo(23.0769); expect(yScale(0)).toBeCloseTo(253.8461); }); it("uses explicit x and y domains for scales", () => { const { xScale, yAxes } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), domain: { x: [-1, 3], y: [0, 10] }, }); expect(xScale.domain()).toEqual([-1, 3]); expect(xScale(0)).toBe(125); expect(xScale(2)).toBe(375); const yScale = yAxes[0].yScale; expect(yScale.domain()).toEqual([10, 0]); expect(yScale(10)).toBe(0); expect(yScale(0)).toBe(300); }); it("uses explicit tick values to derive x and y domains", () => { const { xScale, yAxes, xTicksNormalized } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: { ...axes.xAxis, tickValues: [-2, 4], }, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"], tickValues: [0, 12], })), }); expect(xScale.domain()).toEqual([-2, 4]); expect(xScale(0)).toBeCloseTo(166.6667); expect(xTicksNormalized).toEqual([-2, 4]); const yScale = yAxes[0].yScale; expect(yScale.domain()).toEqual([12, 0]); expect(yScale(12)).toBe(0); expect(yAxes[0].yTicksNormalized).toEqual([0, 12]); }); it("sorts data by xKey", () => { const { ix, y } = transformInputData({ data: [ { x: 2, y: 3 }, { x: 0, y: 7 }, { x: 1, y: 5 }, ], xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), }); expect(ix).toEqual([0, 1, 2]); expect(y.y.i).toEqual([7, 5, 3]); }); it("preserves input order for ordinal x values", () => { const { ix, ox, xTicksNormalized, isNumericalData } = transformInputData({ data: [ { x: "beta", y: 3 }, { x: "alpha", y: 7 }, { x: "gamma", y: 5 }, ], xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), }); expect(isNumericalData).toBe(false); expect(ix).toEqual(["beta", "alpha", "gamma"]); expect(ox).toEqual([0, 250, 500]); expect(xTicksNormalized).toEqual([0, 1, 2]); }); it("downsamples ordinal x ticks to tickCount", () => { const { xTicksNormalized } = transformInputData({ data: [ { x: "alpha", y: 3 }, { x: "beta", y: 7 }, { x: "gamma", y: 5 }, { x: "delta", y: 4 }, { x: "epsilon", y: 9 }, ], xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: { ...axes.xAxis, tickCount: 3, }, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), }); expect(xTicksNormalized).toEqual([0, 2, 4]); }); it("centers a single datum by expanding equal x and y domains", () => { const { ox, y } = transformInputData({ data: [{ x: 5, y: 10 }], xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), }); expect(ox).toEqual([250]); expect(y.y.o[0]).toBeCloseTo(150); }); it("keeps missing y values in the transformed output without using them for scale bounds", () => { const { y, yAxes } = transformInputData({ data: [ { x: 0, y: 3 }, { x: 1, y: null }, { x: 2, y: 7 }, ], xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), }); expect(y.y.i).toEqual([3, null, 7]); expect(y.y.o[1]).toBeNull(); expect(yAxes[0].yScale.domain()).toEqual([7, 3]); }); it("keeps y scales finite when every y value is missing", () => { const { ox, y, yAxes } = transformInputData({ data: [ { x: 0, y: null }, { x: 1, y: null }, { x: 2, y: null }, ], xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), }); expect(y.y.i).toEqual([null, null, null]); expect(y.y.o).toEqual([null, null, null]); expect(ox.every(Number.isFinite)).toBe(true); expect(yAxes[0].yScale.domain()).toEqual([1, -1]); expect(yAxes[0].yScale.range().every(Number.isFinite)).toBe(true); }); it("keeps log y scales finite when y values cannot define a positive domain", () => { const { y, yAxes } = transformInputData({ data: [ { x: 0, y: null }, { x: 1, y: 0 }, { x: 2, y: -2 }, ], xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), axisScales: { yAxisScale: "log" }, }); const yScale = yAxes[0].yScale; expect(yScale.domain()).toEqual([10, 1]); expect(yScale(10)).toBeCloseTo(0); expect(yScale(1)).toBeCloseTo(300); expect(y.y.o).toEqual([null, null, null]); expect(yAxes[0].yData.y!.o).toEqual([null, null, null]); }); it("treats non-positive log y values as missing points", () => { const { y, yAxes } = transformInputData({ data: [ { x: 0, y: 10 }, { x: 1, y: 0 }, { x: 2, y: -2 }, { x: 3, y: null }, { x: 4, y: 1 }, ], xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), axisScales: { yAxisScale: "log" }, }); expect(y.y.i).toEqual([10, 0, -2, null, 1]); expect(Number.isFinite(y.y.o[0] as number)).toBe(true); expect(y.y.o.slice(1, 4)).toEqual([null, null, null]); expect(Number.isFinite(y.y.o[4] as number)).toBe(true); expect(yAxes[0].yData.y!.o).toEqual(y.y.o); }); it("keeps single-value log y scales positive and finite", () => { const { y, yAxes } = transformInputData({ data: [{ x: 0, y: 1 }], xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), axisScales: { yAxisScale: "log" }, }); const yScale = yAxes[0].yScale; expect(yScale.domain()).toEqual([10, 0.1]); expect(Number.isFinite(y.y.o[0])).toBe(true); }); it("builds separate y scales for multiple y-axis configurations", () => { const baseYAxis = axes.yAxes[0]!; const { yAxes, y } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y", "z"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: [ { ...baseYAxis, yKeys: ["y"], domain: [0, 10] }, { ...baseYAxis, yKeys: ["z"], domain: [0, 100] }, ], }); expect(yAxes[0].yScale(10)).toBeCloseTo(0); expect(yAxes[1]!.yScale(100)).toBeCloseTo(0); expect(y.y.o).toEqual([210, 90.00000000000001, 150]); expect(y.z.o).toEqual([300, 288, 270]); }); it("keeps rotated x label layout finite when no x ticks are rendered", () => { const { ox, y, yAxes } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: { ...axes.xAxis, tickCount: 0, tickValues: [], labelRotate: 45, }, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), labelRotate: 45, }); expect(ox.every(Number.isFinite)).toBe(true); expect(y.y.o.every((value) => Number.isFinite(value as number))).toBe(true); expect(yAxes[0].yScale.range().every(Number.isFinite)).toBe(true); }); it("maps y values with the final y scale when x labels are rotated", () => { const { y, yAxes } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: { ...axes.xAxis, font, tickValues: [0, 1, 2], labelRotate: 45, }, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), labelRotate: 45, }); const yScale = yAxes[0].yScale; expect(y.y.o).toEqual(DATA.map((datum) => yScale(datum.y))); }); it("renders no x ticks when tickCount is zero with explicit tick values", () => { const { xTicksNormalized } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: { ...axes.xAxis, tickCount: 0, tickValues: [0, 1, 2], }, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), }); expect(xTicksNormalized).toEqual([]); }); it("renders no y ticks when tickCount is zero with explicit tick values", () => { const { yAxes } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"], tickCount: 0, tickValues: [3, 5, 7], })), }); expect(yAxes[0].yTicksNormalized).toEqual([]); }); it("downsamples explicit x tick values to tickCount", () => { const { xTicksNormalized } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: { ...axes.xAxis, tickCount: 3, tickValues: [0, 1, 2, 3, 4], }, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), }); expect(xTicksNormalized).toEqual([0, 2, 4]); }); it("reserves vertical space for multiline x labels", () => { const { yAxes } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: { ...axes.xAxis, font, formatXLabel: () => "Day\n1", tickValues: [0, 1, 2], }, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), }); expect(yAxes[0].yScale.range()[1]).toBe(276); }); it("measures ordinal x labels using their formatted x values", () => { const { yAxes } = transformInputData({ data: [ { x: "first", y: 3 }, { x: "second", y: 7 }, { x: "third", y: 5 }, ], xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: { ...axes.xAxis, font, formatXLabel: (label) => (label === "second" ? "Day\nTwo\nPeak" : ""), }, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), }); expect(yAxes[0].yScale.range()[1]).toBe(266); }); it("measures ordinal x label layout from rendered ticks only", () => { const { yAxes } = transformInputData({ data: [ { x: "first", y: 3 }, { x: "middle", y: 7 }, { x: "last", y: 5 }, ], xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: { ...axes.xAxis, font, tickCount: 2, formatXLabel: (label) => label === "middle" ? "Long\nMiddle\nLabel" : String(label), }, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), }); expect(yAxes[0].yScale.range()[1]).toBe(286); }); it("uses x-axis labelOffset when reserving x label space", () => { const { yAxes } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: { ...axes.xAxis, font, labelOffset: 6, }, yAxes: axes.yAxes.map((axis) => ({ ...axis, font, yKeys: ["y"], labelOffset: 20, })), }); expect(yAxes[0].yScale.range()[1]).toBe(278); }); it("does not reserve x label space when x ticks are disabled", () => { const { yAxes } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: { ...axes.xAxis, font, tickCount: 0, tickValues: [], }, yAxes: axes.yAxes.map((axis) => ({ ...axis, font, yKeys: ["y"], tickCount: 3, })), }); expect(yAxes[0].yScale.range()[1]).toBe(300); }); it("does not reserve x label space for empty formatted labels", () => { const { yAxes } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: { ...axes.xAxis, font, formatXLabel: () => "", }, yAxes: axes.yAxes.map((axis) => ({ ...axis, font, yKeys: ["y"], tickCount: 3, })), }); expect(yAxes[0].yScale.range()[1]).toBe(300); }); it("reserves x label space using custom renderer measurement", () => { const measured: { value: number; text: string; index: number }[] = []; const labelRenderer = { measure: ({ value, text, index }) => { measured.push({ value, text, index }); return { width: 12, height: 24 }; }, render: () => null, } satisfies AxisLabelRenderer<number>; const { yAxes } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: { ...axes.xAxis, tickValues: [0, 1], labelRenderer, formatXLabel: (value) => `Day ${value}`, }, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), }); expect(yAxes[0].yScale.range()[1]).toBe(272); expect(measured).toEqual([ { value: 0, text: "Day 0", index: 0 }, { value: 1, text: "Day 1", index: 1 }, ]); }); it("reserves vertical space for an x-axis title", () => { const { yAxes } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: { ...axes.xAxis, tickCount: 0, title: { text: "Months", font, offset: 4, }, }, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"] })), }); expect(yAxes[0].yScale.range()[1]).toBe(286); }); it("reserves horizontal space by the widest line of multiline y labels", () => { const { xScale } = transformInputData<(typeof DATA)[number], "x", "y">({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, font, yKeys: ["y"], formatYLabel: () => "Long\nY", })), }); expect(xScale(0)).toBe(16); }); it("does not reserve y label space for empty formatted labels", () => { const { xScale } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, font, yKeys: ["y"], labelOffset: 10, tickCount: 3, tickValues: [3, 5, 7], formatYLabel: () => "", })), }); expect(xScale(0)).toBe(0); }); it("reserves y label space using custom renderer measurement", () => { const measured: { value: number; text: string; index: number }[] = []; const labelRenderer = { measure: ({ value, text, index }) => { measured.push({ value, text, index }); return { width: 30, height: 12 }; }, render: () => null, } satisfies AxisLabelRenderer<number>; const { xScale } = transformInputData<(typeof DATA)[number], "x", "y">({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"], tickValues: [3, 7], labelOffset: 5, labelRenderer, formatYLabel: (value) => `$${value}`, })), }); expect(xScale(0)).toBe(35); expect(measured).toEqual([ { value: 3, text: "$3", index: 0 }, { value: 7, text: "$7", index: 1 }, ]); }); it("reserves horizontal space for a y-axis title", () => { const { xScale } = transformInputData({ data: DATA, xKey: "x", yKeys: ["y"], outputWindow: OUTPUT_WINDOW, xAxis: axes.xAxis, yAxes: axes.yAxes.map((axis) => ({ ...axis, yKeys: ["y"], tickCount: 0, title: { text: "Revenue", font, offset: 6, }, })), }); expect(xScale(0)).toBe(16); }); // TODO: Some day, test the gridOptions code. });