|
|
@@ -1,8 +1,40 @@
|
|
|
-import { tryParseCells, tryParseNumber, VALID_SPREADSHEET } from "./charts";
|
|
|
+import { FONT_FAMILY } from "@excalidraw/common";
|
|
|
+import {
|
|
|
+ DEFAULT_CHART_COLOR_INDEX,
|
|
|
+ getAllColorsSpecificShade,
|
|
|
+} from "@excalidraw/common";
|
|
|
+
|
|
|
+import type {
|
|
|
+ ExcalidrawLineElement,
|
|
|
+ ExcalidrawTextElement,
|
|
|
+} from "@excalidraw/element/types";
|
|
|
+
|
|
|
+import {
|
|
|
+ isSpreadsheetValidForChartType,
|
|
|
+ renderSpreadsheet,
|
|
|
+ tryParseCells,
|
|
|
+ tryParseNumber,
|
|
|
+} from "./charts";
|
|
|
|
|
|
import type { Spreadsheet } from "./charts";
|
|
|
|
|
|
describe("charts", () => {
|
|
|
+ const getRotatedBounds = (element: ExcalidrawTextElement) => {
|
|
|
+ const cos = Math.abs(Math.cos(element.angle));
|
|
|
+ const sin = Math.abs(Math.sin(element.angle));
|
|
|
+ const rotatedWidth = element.width * cos + element.height * sin;
|
|
|
+ const rotatedHeight = element.width * sin + element.height * cos;
|
|
|
+ const centerX = element.x + element.width / 2;
|
|
|
+ const centerY = element.y + element.height / 2;
|
|
|
+ return {
|
|
|
+ left: centerX - rotatedWidth / 2,
|
|
|
+ right: centerX + rotatedWidth / 2,
|
|
|
+ top: centerY - rotatedHeight / 2,
|
|
|
+ bottom: centerY + rotatedHeight / 2,
|
|
|
+ centerX,
|
|
|
+ };
|
|
|
+ };
|
|
|
+
|
|
|
describe("tryParseNumber", () => {
|
|
|
it.each<[string, number]>([
|
|
|
["1", 1],
|
|
|
@@ -42,11 +74,11 @@ describe("charts", () => {
|
|
|
|
|
|
const result = tryParseCells(spreadsheet);
|
|
|
|
|
|
- expect(result.type).toBe(VALID_SPREADSHEET);
|
|
|
+ expect(result.ok).toBe(true);
|
|
|
|
|
|
- const { title, labels, values } = (
|
|
|
- result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
|
|
- ).spreadsheet;
|
|
|
+ const { title, labels, series } = (
|
|
|
+ result as { ok: true; data: Spreadsheet }
|
|
|
+ ).data;
|
|
|
|
|
|
expect(title).toEqual("value");
|
|
|
expect(labels).toEqual([
|
|
|
@@ -57,7 +89,9 @@ describe("charts", () => {
|
|
|
"05:00",
|
|
|
"06:00",
|
|
|
]);
|
|
|
- expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
|
|
+ expect(series).toEqual([
|
|
|
+ { title: "value", values: [61, -60, 85, -67, 54, 95] },
|
|
|
+ ]);
|
|
|
});
|
|
|
|
|
|
it("Uses the second column as the label if it is not a number", () => {
|
|
|
@@ -73,11 +107,11 @@ describe("charts", () => {
|
|
|
|
|
|
const result = tryParseCells(spreadsheet);
|
|
|
|
|
|
- expect(result.type).toBe(VALID_SPREADSHEET);
|
|
|
+ expect(result.ok).toBe(true);
|
|
|
|
|
|
- const { title, labels, values } = (
|
|
|
- result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
|
|
- ).spreadsheet;
|
|
|
+ const { title, labels, series } = (
|
|
|
+ result as { ok: true; data: Spreadsheet }
|
|
|
+ ).data;
|
|
|
|
|
|
expect(title).toEqual("value");
|
|
|
expect(labels).toEqual([
|
|
|
@@ -88,7 +122,9 @@ describe("charts", () => {
|
|
|
"05:00",
|
|
|
"06:00",
|
|
|
]);
|
|
|
- expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
|
|
+ expect(series).toEqual([
|
|
|
+ { title: "value", values: [61, -60, 85, -67, 54, 95] },
|
|
|
+ ]);
|
|
|
});
|
|
|
|
|
|
it("treats the first column as labels if both columns are numbers", () => {
|
|
|
@@ -104,15 +140,1026 @@ describe("charts", () => {
|
|
|
|
|
|
const result = tryParseCells(spreadsheet);
|
|
|
|
|
|
- expect(result.type).toBe(VALID_SPREADSHEET);
|
|
|
+ expect(result.ok).toBe(true);
|
|
|
|
|
|
- const { title, labels, values } = (
|
|
|
- result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
|
|
- ).spreadsheet;
|
|
|
+ const { title, labels, series } = (
|
|
|
+ result as { ok: true; data: Spreadsheet }
|
|
|
+ ).data;
|
|
|
|
|
|
expect(title).toEqual("value");
|
|
|
expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]);
|
|
|
- expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
|
|
+ expect(series).toEqual([
|
|
|
+ { title: "value", values: [61, -60, 85, -67, 54, 95] },
|
|
|
+ ]);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("parses multi-series cells for radar charts", () => {
|
|
|
+ const spreadsheet = [
|
|
|
+ ["Metric", "Player A", "Player B", "Player C"],
|
|
|
+ ["Speed", "80", "60", "75"],
|
|
|
+ ["Strength", "65", "85", "70"],
|
|
|
+ ["Agility", "90", "70", "88"],
|
|
|
+ ["Intelligence", "70", "88", "92"],
|
|
|
+ ["Stamina", "85", "75", "80"],
|
|
|
+ ];
|
|
|
+
|
|
|
+ const result = tryParseCells(spreadsheet);
|
|
|
+
|
|
|
+ expect(result.ok).toBe(true);
|
|
|
+
|
|
|
+ const parsed = (result as { ok: true; data: Spreadsheet }).data;
|
|
|
+
|
|
|
+ expect(parsed.title).toEqual("Metric");
|
|
|
+ expect(parsed.labels).toEqual([
|
|
|
+ "Speed",
|
|
|
+ "Strength",
|
|
|
+ "Agility",
|
|
|
+ "Intelligence",
|
|
|
+ "Stamina",
|
|
|
+ ]);
|
|
|
+ expect(parsed.series).toEqual([
|
|
|
+ { title: "Player A", values: [80, 65, 90, 70, 85] },
|
|
|
+ { title: "Player B", values: [60, 85, 70, 88, 75] },
|
|
|
+ { title: "Player C", values: [75, 70, 88, 92, 80] },
|
|
|
+ ]);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("treats first row as title+series headers only when all cells are non-numeric", () => {
|
|
|
+ const spreadsheet = [
|
|
|
+ ["Trait", "10", "20"],
|
|
|
+ ["Physical Strength", "4", "8"],
|
|
|
+ ["Strategy", "6", "9"],
|
|
|
+ ["Charisma", "7", "5"],
|
|
|
+ ];
|
|
|
+
|
|
|
+ const result = tryParseCells(spreadsheet);
|
|
|
+ expect(result.ok).toBe(true);
|
|
|
+
|
|
|
+ const parsed = (result as { ok: true; data: Spreadsheet }).data;
|
|
|
+
|
|
|
+ expect(parsed.title).toBeNull();
|
|
|
+ expect(parsed.labels?.[0]).toEqual("Trait");
|
|
|
+ expect(parsed.series[0].title).toEqual("Series 1");
|
|
|
+ expect(parsed.series[1].title).toEqual("Series 2");
|
|
|
+ });
|
|
|
+
|
|
|
+ it("supports header row with series labels but no chart title", () => {
|
|
|
+ const spreadsheet = [
|
|
|
+ ["", "Dunk", "Egg"],
|
|
|
+ ["Physical Strength", "10", "2"],
|
|
|
+ ["Swordsmanship", "8", "1"],
|
|
|
+ ["Political Instinct", "3", "9"],
|
|
|
+ ];
|
|
|
+
|
|
|
+ const result = tryParseCells(spreadsheet);
|
|
|
+ expect(result.ok).toBe(true);
|
|
|
+
|
|
|
+ const parsed = (result as { ok: true; data: Spreadsheet }).data;
|
|
|
+
|
|
|
+ expect(parsed.title).toBeNull();
|
|
|
+ expect(parsed.labels).toEqual([
|
|
|
+ "Physical Strength",
|
|
|
+ "Swordsmanship",
|
|
|
+ "Political Instinct",
|
|
|
+ ]);
|
|
|
+ expect(parsed.series).toEqual([
|
|
|
+ { title: "Dunk", values: [10, 8, 3] },
|
|
|
+ { title: "Egg", values: [2, 1, 9] },
|
|
|
+ ]);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("parses 2-row multi-series data with header row", () => {
|
|
|
+ const spreadsheet = [
|
|
|
+ ["trait", "Dunk", "Egg"],
|
|
|
+ ["Physical Strength", "10", "2"],
|
|
|
+ ["Swordsmanship skill", "8", "1"],
|
|
|
+ ];
|
|
|
+
|
|
|
+ const result = tryParseCells(spreadsheet);
|
|
|
+ expect(result.ok).toBe(true);
|
|
|
+
|
|
|
+ const parsed = (result as { ok: true; data: Spreadsheet }).data;
|
|
|
+
|
|
|
+ expect(parsed.title).toEqual("trait");
|
|
|
+ expect(parsed.labels).toEqual([
|
|
|
+ "Physical Strength",
|
|
|
+ "Swordsmanship skill",
|
|
|
+ ]);
|
|
|
+ expect(parsed.series).toEqual([
|
|
|
+ { title: "Dunk", values: [10, 8] },
|
|
|
+ { title: "Egg", values: [2, 1] },
|
|
|
+ ]);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("parses 2-row multi-series data without header and keeps first column as labels", () => {
|
|
|
+ const spreadsheet = [
|
|
|
+ ["Physical Strength", "10", "2"],
|
|
|
+ ["Swordsmanship skill", "8", "1"],
|
|
|
+ ];
|
|
|
+
|
|
|
+ const result = tryParseCells(spreadsheet);
|
|
|
+ expect(result.ok).toBe(true);
|
|
|
+
|
|
|
+ const parsed = (result as { ok: true; data: Spreadsheet }).data;
|
|
|
+
|
|
|
+ expect(parsed.title).toBeNull();
|
|
|
+ expect(parsed.labels).toEqual([
|
|
|
+ "Physical Strength",
|
|
|
+ "Swordsmanship skill",
|
|
|
+ ]);
|
|
|
+ expect(parsed.series).toEqual([
|
|
|
+ { title: "Series 1", values: [10, 8] },
|
|
|
+ { title: "Series 2", values: [2, 1] },
|
|
|
+ ]);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("always interprets 2-column data as label in first column and numeric value in second", () => {
|
|
|
+ const spreadsheet = [
|
|
|
+ ["10", "2"],
|
|
|
+ ["8", "Swordsmanship skill"],
|
|
|
+ ["6", "3"],
|
|
|
+ ];
|
|
|
+
|
|
|
+ const result = tryParseCells(spreadsheet);
|
|
|
+ expect(result).toEqual({
|
|
|
+ ok: false,
|
|
|
+ reason: "Value is not numeric",
|
|
|
+ });
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe("isSpreadsheetValidForChartType", () => {
|
|
|
+ it("rejects radar charts with only 2 dimensions", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "trait",
|
|
|
+ labels: ["Physical Strength", "Swordsmanship skill"],
|
|
|
+ series: [
|
|
|
+ { title: "Dunk", values: [10, 8] },
|
|
|
+ { title: "Egg", values: [2, 1] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ expect(isSpreadsheetValidForChartType(spreadsheet, "radar")).toBe(false);
|
|
|
+ expect(isSpreadsheetValidForChartType(spreadsheet, "bar")).toBe(true);
|
|
|
+ expect(isSpreadsheetValidForChartType(spreadsheet, "line")).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("accepts radar charts with 3 or more dimensions", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "trait",
|
|
|
+ labels: [
|
|
|
+ "Physical Strength",
|
|
|
+ "Swordsmanship skill",
|
|
|
+ "Political Instinct",
|
|
|
+ ],
|
|
|
+ series: [
|
|
|
+ { title: "Dunk", values: [10, 8, 3] },
|
|
|
+ { title: "Egg", values: [2, 1, 9] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ expect(isSpreadsheetValidForChartType(spreadsheet, "radar")).toBe(true);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe("renderSpreadsheet", () => {
|
|
|
+ it("renders grouped bars and legend for multi-series bar charts", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Trait",
|
|
|
+ labels: ["A", "B", "C", "D", "E"],
|
|
|
+ series: [
|
|
|
+ { title: "Dunk", values: [10, 8, 3, 2.5, 5] },
|
|
|
+ { title: "Egg", values: [2, 1, 9, 8, 9] },
|
|
|
+ { title: "Aerion", values: [7, 8, 7, 4, 5] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const elements = renderSpreadsheet("bar", spreadsheet, 0, 0);
|
|
|
+ const bars = elements!.filter(
|
|
|
+ (element) =>
|
|
|
+ element.type === "rectangle" &&
|
|
|
+ element.strokeWidth === 1 &&
|
|
|
+ element.opacity === 100 &&
|
|
|
+ !element.roundness,
|
|
|
+ );
|
|
|
+ const textElements = elements!.filter(
|
|
|
+ (element) => element.type === "text",
|
|
|
+ );
|
|
|
+ const axisLabels = textElements.filter((element) =>
|
|
|
+ spreadsheet.labels?.includes(element.originalText || ""),
|
|
|
+ );
|
|
|
+ const legendLabels = textElements.filter((element) =>
|
|
|
+ spreadsheet.series.some(
|
|
|
+ (series) => series.title === element.originalText,
|
|
|
+ ),
|
|
|
+ );
|
|
|
+
|
|
|
+ const axisBottomY = Math.max(
|
|
|
+ ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height),
|
|
|
+ );
|
|
|
+ const legendTopY = Math.min(
|
|
|
+ ...legendLabels.map((legendLabel) => legendLabel.y),
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(bars).toHaveLength(
|
|
|
+ spreadsheet.series.length * spreadsheet.series[0].values.length,
|
|
|
+ );
|
|
|
+ expect(legendLabels).toHaveLength(spreadsheet.series.length);
|
|
|
+ expect(legendTopY).toBeGreaterThan(axisBottomY + 2);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("spreads grouped bar series colors across palette", () => {
|
|
|
+ const palette = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX);
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Trait",
|
|
|
+ labels: ["A", "B", "C", "D", "E"],
|
|
|
+ series: [
|
|
|
+ { title: "S1", values: [1, 2, 3, 4, 5] },
|
|
|
+ { title: "S2", values: [2, 3, 4, 5, 1] },
|
|
|
+ { title: "S3", values: [3, 4, 5, 1, 2] },
|
|
|
+ { title: "S4", values: [4, 5, 1, 2, 3] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
|
|
|
+ const elements = renderSpreadsheet("bar", spreadsheet, 0, 0);
|
|
|
+ randomSpy.mockRestore();
|
|
|
+
|
|
|
+ const bars = elements!.filter(
|
|
|
+ (element) =>
|
|
|
+ element.type === "rectangle" &&
|
|
|
+ element.strokeWidth === 1 &&
|
|
|
+ element.opacity === 100 &&
|
|
|
+ !element.roundness,
|
|
|
+ );
|
|
|
+ const uniqueColors = Array.from(
|
|
|
+ new Set(bars.map((bar) => bar.backgroundColor)),
|
|
|
+ );
|
|
|
+ const colorIndices = uniqueColors.map((color) =>
|
|
|
+ palette.findIndex((paletteColor) => paletteColor === color),
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(uniqueColors).toHaveLength(spreadsheet.series.length);
|
|
|
+ expect(colorIndices.every((index) => index >= 0)).toBe(true);
|
|
|
+
|
|
|
+ const circularDistance = (first: number, second: number) => {
|
|
|
+ const absoluteDistance = Math.abs(first - second);
|
|
|
+ return Math.min(absoluteDistance, palette.length - absoluteDistance);
|
|
|
+ };
|
|
|
+ const minDistance = Math.min(
|
|
|
+ ...colorIndices.flatMap((index, i) =>
|
|
|
+ colorIndices
|
|
|
+ .slice(i + 1)
|
|
|
+ .map((other) => circularDistance(index, other)),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ expect(minDistance).toBeGreaterThan(1);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("renders grouped bars for parsed multi-series cells without header row", () => {
|
|
|
+ const cells = [
|
|
|
+ ["Physical Strength", "10", "2", "7"],
|
|
|
+ ["Swordsmanship", "8", "1", "8"],
|
|
|
+ ["Political Instinct", "3", "9", "7"],
|
|
|
+ ["Book Knowledge", "2.5", "8", "4"],
|
|
|
+ ];
|
|
|
+ const parsedResult = tryParseCells(cells);
|
|
|
+ expect(parsedResult.ok).toBe(true);
|
|
|
+ const parsedSpreadsheet = (
|
|
|
+ parsedResult as {
|
|
|
+ ok: true;
|
|
|
+ data: Spreadsheet;
|
|
|
+ }
|
|
|
+ ).data;
|
|
|
+
|
|
|
+ const elements = renderSpreadsheet("bar", parsedSpreadsheet, 0, 0);
|
|
|
+ const bars = elements!.filter(
|
|
|
+ (element) =>
|
|
|
+ element.type === "rectangle" &&
|
|
|
+ element.strokeWidth === 1 &&
|
|
|
+ element.opacity === 100 &&
|
|
|
+ !element.roundness,
|
|
|
+ );
|
|
|
+ const textElements = elements!.filter(
|
|
|
+ (element) => element.type === "text",
|
|
|
+ );
|
|
|
+ const legendLabels = textElements
|
|
|
+ .map((element) => element.originalText)
|
|
|
+ .filter((text): text is string => typeof text === "string");
|
|
|
+
|
|
|
+ expect(bars).toHaveLength(
|
|
|
+ parsedSpreadsheet.series[0].values.length *
|
|
|
+ parsedSpreadsheet.series.length,
|
|
|
+ );
|
|
|
+ expect(legendLabels).toContain("Series 1");
|
|
|
+ expect(legendLabels).toContain("Series 2");
|
|
|
+ expect(legendLabels).toContain("Series 3");
|
|
|
+ });
|
|
|
+
|
|
|
+ it("makes multi-series bar charts wider than single-series bar charts", () => {
|
|
|
+ const singleSeries: Spreadsheet = {
|
|
|
+ title: "Trait",
|
|
|
+ labels: ["A", "B", "C", "D"],
|
|
|
+ series: [{ title: "Trait", values: [10, 8, 3, 2.5] }],
|
|
|
+ };
|
|
|
+ const multiSeries: Spreadsheet = {
|
|
|
+ title: "Trait",
|
|
|
+ labels: ["A", "B", "C", "D"],
|
|
|
+ series: [
|
|
|
+ { title: "Dunk", values: [10, 8, 3, 2.5] },
|
|
|
+ { title: "Egg", values: [2, 1, 9, 8] },
|
|
|
+ { title: "Aerion", values: [7, 8, 7, 4] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const singleElements = renderSpreadsheet("bar", singleSeries, 0, 0);
|
|
|
+ const multiElements = renderSpreadsheet("bar", multiSeries, 0, 0);
|
|
|
+ const getXAxisWidth = (elements: ReturnType<typeof renderSpreadsheet>) =>
|
|
|
+ elements!.find(
|
|
|
+ (element): element is ExcalidrawLineElement =>
|
|
|
+ element.type === "line" &&
|
|
|
+ element.strokeStyle === "solid" &&
|
|
|
+ element.points[0][1] === 0 &&
|
|
|
+ element.points[1][1] === 0 &&
|
|
|
+ element.points[1][0] > 0,
|
|
|
+ )?.width || 0;
|
|
|
+
|
|
|
+ expect(getXAxisWidth(multiElements)).toBeGreaterThan(
|
|
|
+ getXAxisWidth(singleElements),
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ it("makes multi-series line charts wider than single-series line charts", () => {
|
|
|
+ const singleSeries: Spreadsheet = {
|
|
|
+ title: "Trait",
|
|
|
+ labels: ["A", "B", "C", "D"],
|
|
|
+ series: [{ title: "Trait", values: [10, 8, 3, 2.5] }],
|
|
|
+ };
|
|
|
+ const multiSeries: Spreadsheet = {
|
|
|
+ title: "Trait",
|
|
|
+ labels: ["A", "B", "C", "D"],
|
|
|
+ series: [
|
|
|
+ { title: "Dunk", values: [10, 8, 3, 2.5] },
|
|
|
+ { title: "Egg", values: [2, 1, 9, 8] },
|
|
|
+ { title: "Aerion", values: [7, 8, 7, 4] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const singleElements = renderSpreadsheet("line", singleSeries, 0, 0);
|
|
|
+ const multiElements = renderSpreadsheet("line", multiSeries, 0, 0);
|
|
|
+ const getXAxisWidth = (elements: ReturnType<typeof renderSpreadsheet>) =>
|
|
|
+ elements!.find(
|
|
|
+ (element): element is ExcalidrawLineElement =>
|
|
|
+ element.type === "line" &&
|
|
|
+ element.strokeStyle === "solid" &&
|
|
|
+ element.points[0][1] === 0 &&
|
|
|
+ element.points[1][1] === 0 &&
|
|
|
+ element.points[1][0] > 0,
|
|
|
+ )?.width || 0;
|
|
|
+
|
|
|
+ expect(getXAxisWidth(multiElements)).toBeGreaterThan(
|
|
|
+ getXAxisWidth(singleElements),
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ it("wraps grouped bar labels with spaces and still ellipsifies long single words", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Trait",
|
|
|
+ labels: [
|
|
|
+ "Supercalifragilisticexpialidocious",
|
|
|
+ "Data Flow",
|
|
|
+ "Logic Layer",
|
|
|
+ ],
|
|
|
+ series: [
|
|
|
+ { title: "Dunk", values: [8, 3, 2.5] },
|
|
|
+ { title: "Egg", values: [1, 9, 8] },
|
|
|
+ { title: "Aerion", values: [8, 7, 4] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const elements = renderSpreadsheet("bar", spreadsheet, 0, 0);
|
|
|
+ const longWordLabel = elements!.find(
|
|
|
+ (element): element is ExcalidrawTextElement =>
|
|
|
+ element.type === "text" &&
|
|
|
+ Math.abs(element.angle) > 0 &&
|
|
|
+ element.text.includes("..."),
|
|
|
+ );
|
|
|
+ const spacedLabels = elements!.filter(
|
|
|
+ (element): element is ExcalidrawTextElement =>
|
|
|
+ element.type === "text" &&
|
|
|
+ (element.originalText === "Data Flow" ||
|
|
|
+ element.originalText === "Logic Layer"),
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(longWordLabel).toBeDefined();
|
|
|
+ expect(longWordLabel?.text).toContain("...");
|
|
|
+ expect(longWordLabel?.originalText).toBe(longWordLabel?.text);
|
|
|
+ expect(
|
|
|
+ (longWordLabel?.text || "").replace("...", "").length,
|
|
|
+ ).toBeGreaterThan(0);
|
|
|
+ expect(spacedLabels.some((label) => label.text.includes("\n"))).toBe(
|
|
|
+ true,
|
|
|
+ );
|
|
|
+ expect(
|
|
|
+ spacedLabels.every(
|
|
|
+ (label) => !!label.originalText && !label.originalText.includes("\n"),
|
|
|
+ ),
|
|
|
+ ).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("keeps single-series bar x-axis labels below axis and avoids neighbor overlap", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Dunk",
|
|
|
+ labels: [
|
|
|
+ "Physical Strength",
|
|
|
+ "Swordsmanship",
|
|
|
+ "Political Instinct",
|
|
|
+ "Book Knowledge",
|
|
|
+ "Strategic Thinking",
|
|
|
+ "charisma",
|
|
|
+ "courage",
|
|
|
+ "Stubbornness",
|
|
|
+ "Empathy",
|
|
|
+ "Practical Survival Skills",
|
|
|
+ ],
|
|
|
+ series: [{ title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }],
|
|
|
+ };
|
|
|
+
|
|
|
+ const elements = renderSpreadsheet("bar", spreadsheet, 0, 0);
|
|
|
+ const axisLabels = elements!.filter(
|
|
|
+ (element): element is ExcalidrawTextElement =>
|
|
|
+ element.type === "text" && Math.abs(element.angle) > 0,
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(axisLabels).toHaveLength(spreadsheet.labels!.length);
|
|
|
+
|
|
|
+ const bounds = axisLabels.map(getRotatedBounds);
|
|
|
+ for (const bound of bounds) {
|
|
|
+ expect(bound.top).toBeGreaterThan(0);
|
|
|
+ }
|
|
|
+
|
|
|
+ const sortedBounds = bounds.sort(
|
|
|
+ (left, right) => left.centerX - right.centerX,
|
|
|
+ );
|
|
|
+ for (let index = 1; index < sortedBounds.length; index++) {
|
|
|
+ expect(sortedBounds[index - 1].right).toBeLessThanOrEqual(
|
|
|
+ sortedBounds[index].left + 2,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ it("renders one line per series and one dot per data point for multi-series line charts", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Scores",
|
|
|
+ labels: ["alpha", "beta", "gamma", "delta", "epsilon"],
|
|
|
+ series: [
|
|
|
+ { title: "Team A", values: [42150, 8300, 95400, 7820, 310500] },
|
|
|
+ { title: "Team B", values: [63400, 3150, 51200, 4670, 125800] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const elements = renderSpreadsheet("line", spreadsheet, 0, 0);
|
|
|
+ const seriesLines = elements!.filter(
|
|
|
+ (element): element is ExcalidrawLineElement =>
|
|
|
+ element.type === "line" && element.strokeWidth === 2,
|
|
|
+ );
|
|
|
+ const dots = elements!.filter(
|
|
|
+ (element) => element.type === "ellipse" && element.strokeWidth === 2,
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(seriesLines).toHaveLength(spreadsheet.series.length);
|
|
|
+ expect(dots).toHaveLength(
|
|
|
+ spreadsheet.series.length * spreadsheet.series[0].values.length,
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ it("spreads line series colors across palette to avoid similar adjacent colors", () => {
|
|
|
+ const palette = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX);
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Trait",
|
|
|
+ labels: ["A", "B", "C", "D", "E"],
|
|
|
+ series: [
|
|
|
+ { title: "S1", values: [1, 2, 3, 4, 5] },
|
|
|
+ { title: "S2", values: [2, 3, 4, 5, 1] },
|
|
|
+ { title: "S3", values: [3, 4, 5, 1, 2] },
|
|
|
+ { title: "S4", values: [4, 5, 1, 2, 3] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
|
|
|
+ const elements = renderSpreadsheet("line", spreadsheet, 0, 0);
|
|
|
+ randomSpy.mockRestore();
|
|
|
+
|
|
|
+ const seriesLines = elements!.filter(
|
|
|
+ (element) => element.type === "line" && element.strokeWidth === 2,
|
|
|
+ );
|
|
|
+ const colorIndices = seriesLines.map((line) =>
|
|
|
+ palette.findIndex((color) => color === line.strokeColor),
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(colorIndices.every((index) => index >= 0)).toBe(true);
|
|
|
+
|
|
|
+ const circularDistance = (first: number, second: number) => {
|
|
|
+ const absoluteDistance = Math.abs(first - second);
|
|
|
+ return Math.min(absoluteDistance, palette.length - absoluteDistance);
|
|
|
+ };
|
|
|
+ const minDistance = Math.min(
|
|
|
+ ...colorIndices.flatMap((index, i) =>
|
|
|
+ colorIndices
|
|
|
+ .slice(i + 1)
|
|
|
+ .map((other) => circularDistance(index, other)),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(minDistance).toBeGreaterThan(1);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("uses colorSeed to deterministically pick chart colors", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Trait",
|
|
|
+ labels: ["A", "B", "C", "D"],
|
|
|
+ series: [
|
|
|
+ { title: "S1", values: [1, 2, 3, 4] },
|
|
|
+ { title: "S2", values: [4, 3, 2, 1] },
|
|
|
+ { title: "S3", values: [2, 3, 4, 1] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const getSeriesLineColors = (seed: number) => {
|
|
|
+ const elements = renderSpreadsheet("line", spreadsheet, 0, 0, seed);
|
|
|
+ return elements!
|
|
|
+ .filter(
|
|
|
+ (element): element is ExcalidrawLineElement =>
|
|
|
+ element.type === "line" && element.strokeWidth === 2,
|
|
|
+ )
|
|
|
+ .map((line) => line.strokeColor);
|
|
|
+ };
|
|
|
+
|
|
|
+ expect(getSeriesLineColors(0.125)).toEqual(getSeriesLineColors(0.125));
|
|
|
+ expect(getSeriesLineColors(0.125)).not.toEqual(
|
|
|
+ getSeriesLineColors(0.875),
|
|
|
+ );
|
|
|
+ });
|
|
|
+
|
|
|
+ it("renders multi-series line legend below axis labels with clearance", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Scores",
|
|
|
+ labels: ["alpha", "beta", "gamma", "delta", "epsilon"],
|
|
|
+ series: [
|
|
|
+ { title: "Team A", values: [42150, 8300, 95400, 12600, 310500] },
|
|
|
+ { title: "Team B", values: [63400, 3150, 51200, 9200, 125800] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const elements = renderSpreadsheet("line", spreadsheet, 0, 0);
|
|
|
+ const textElements = elements!.filter(
|
|
|
+ (element) => element.type === "text",
|
|
|
+ );
|
|
|
+ const axisLabels = textElements.filter((element) =>
|
|
|
+ spreadsheet.labels?.includes(element.originalText || ""),
|
|
|
+ );
|
|
|
+ const legendLabels = textElements.filter((element) =>
|
|
|
+ spreadsheet.series.some(
|
|
|
+ (series) => series.title === element.originalText,
|
|
|
+ ),
|
|
|
+ );
|
|
|
+
|
|
|
+ const axisBottomY = Math.max(
|
|
|
+ ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height),
|
|
|
+ );
|
|
|
+ const legendTopY = Math.min(
|
|
|
+ ...legendLabels.map((legendLabel) => legendLabel.y),
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(axisLabels.length).toBeGreaterThan(0);
|
|
|
+ expect(legendLabels.length).toBe(2);
|
|
|
+ expect(legendTopY).toBeGreaterThan(axisBottomY + 2);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("keeps multi-series line x-axis labels below axis and avoids neighbor overlap", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "trait",
|
|
|
+ labels: [
|
|
|
+ "Physical Strength",
|
|
|
+ "Swordsmanship",
|
|
|
+ "Political Instinct",
|
|
|
+ "Book Knowledge",
|
|
|
+ "Strategic Thinking",
|
|
|
+ "charisma",
|
|
|
+ "courage",
|
|
|
+ "Stubbornness",
|
|
|
+ "Empathy",
|
|
|
+ "Practical Survival Skills",
|
|
|
+ ],
|
|
|
+ series: [
|
|
|
+ { title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] },
|
|
|
+ { title: "Egg", values: [2, 1, 9, 8, 9, 8, 7, 9, 8, 4] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const elements = renderSpreadsheet("line", spreadsheet, 0, 0);
|
|
|
+ const axisLabels = elements!.filter(
|
|
|
+ (element): element is ExcalidrawTextElement =>
|
|
|
+ element.type === "text" && Math.abs(element.angle) > 0,
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(axisLabels).toHaveLength(spreadsheet.labels!.length);
|
|
|
+
|
|
|
+ const bounds = axisLabels.map(getRotatedBounds);
|
|
|
+ for (const bound of bounds) {
|
|
|
+ expect(bound.top).toBeGreaterThan(0);
|
|
|
+ }
|
|
|
+
|
|
|
+ const sortedBounds = bounds.sort(
|
|
|
+ (left, right) => left.centerX - right.centerX,
|
|
|
+ );
|
|
|
+ for (let index = 1; index < sortedBounds.length; index++) {
|
|
|
+ expect(sortedBounds[index - 1].right).toBeLessThanOrEqual(
|
|
|
+ sortedBounds[index].left + 2,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ it("renders one closed polygon line per radar series", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Metric",
|
|
|
+ labels: ["Speed", "Strength", "Agility", "Intelligence", "Stamina"],
|
|
|
+ series: [
|
|
|
+ { title: "Player A", values: [80, 65, 90, 70, 85] },
|
|
|
+ { title: "Player B", values: [60, 85, 70, 88, 75] },
|
|
|
+ { title: "Player C", values: [75, 70, 88, 92, 80] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const elements = renderSpreadsheet("radar", spreadsheet, 0, 0);
|
|
|
+ const seriesPolygons = elements!.filter(
|
|
|
+ (element): element is ExcalidrawLineElement =>
|
|
|
+ element.type === "line" &&
|
|
|
+ "polygon" in element &&
|
|
|
+ element.polygon === true &&
|
|
|
+ element.strokeWidth === 2,
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(seriesPolygons).toHaveLength(3);
|
|
|
+ for (const polygon of seriesPolygons) {
|
|
|
+ expect(polygon.points[0]).toEqual(
|
|
|
+ polygon.points[polygon.points.length - 1],
|
|
|
+ );
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ it("normalizes multi-series radar values with global scale", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Scores",
|
|
|
+ labels: ["alpha", "beta", "gamma", "delta", "epsilon"],
|
|
|
+ series: [
|
|
|
+ { title: "Series 1", values: [40000, 8300, 95400, 7820, 5000000] },
|
|
|
+ { title: "Series 2", values: [76000, 3150, 51200, 4670, 60000] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const elements = renderSpreadsheet("radar", spreadsheet, 0, 0);
|
|
|
+ const seriesPolygons = elements!.filter(
|
|
|
+ (element): element is ExcalidrawLineElement =>
|
|
|
+ element.type === "line" &&
|
|
|
+ "polygon" in element &&
|
|
|
+ element.polygon === true &&
|
|
|
+ element.strokeWidth === 2,
|
|
|
+ );
|
|
|
+
|
|
|
+ const series1 = seriesPolygons[0];
|
|
|
+ const series2 = seriesPolygons[1];
|
|
|
+ const getRadius = (point: readonly [number, number]) =>
|
|
|
+ Math.hypot(point[0], point[1]);
|
|
|
+
|
|
|
+ // On alpha axis, second series is about ~1.9x first series.
|
|
|
+ const alphaRatio =
|
|
|
+ getRadius(series2.points[0]!) / getRadius(series1.points[0]!);
|
|
|
+ expect(alphaRatio).toBeCloseTo(76000 / 40000, 1);
|
|
|
+
|
|
|
+ // On epsilon axis, first series should dominate strongly.
|
|
|
+ const epsilonRatio =
|
|
|
+ getRadius(series1.points[4]!) / getRadius(series2.points[4]!);
|
|
|
+ expect(epsilonRatio).toBeGreaterThan(50);
|
|
|
+ });
|
|
|
+
|
|
|
+ // it("always renders radar step rings regardless of axis scale ratio", () => {
|
|
|
+ // const spreadsheet: Spreadsheet = {
|
|
|
+ // title: "Scores",
|
|
|
+ // labels: ["alpha", "beta", "gamma", "delta", "epsilon"],
|
|
|
+ // series: [
|
|
|
+ // { title: "Series 1", values: [40000, 8300, 95400, 7820, 5000000] },
|
|
|
+ // { title: "Series 2", values: [76000, 3150, 51200, 4670, 60000] },
|
|
|
+ // ],
|
|
|
+ // };
|
|
|
+
|
|
|
+ // const elements = renderSpreadsheet("radar", spreadsheet, 0, 0);
|
|
|
+ // const stepRings = elements!.filter(
|
|
|
+ // (element) =>
|
|
|
+ // element.type === "line" &&
|
|
|
+ // "polygon" in element &&
|
|
|
+ // element.polygon &&
|
|
|
+ // element.strokeStyle === "solid" &&
|
|
|
+ // element.strokeWidth === 1,
|
|
|
+ // );
|
|
|
+
|
|
|
+ // expect(stepRings).toHaveLength(4);
|
|
|
+ // });
|
|
|
+
|
|
|
+ it("uses log normalization for highly skewed single-series radar data", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Scores",
|
|
|
+ labels: ["alpha", "beta", "gamma", "delta", "epsilon"],
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ title: "Scores",
|
|
|
+ values: [40000, 8300, 95400, 7820, 5000000],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const elements = renderSpreadsheet("radar", spreadsheet, 0, 0);
|
|
|
+ const seriesPolygons = elements!.filter(
|
|
|
+ (element): element is ExcalidrawLineElement =>
|
|
|
+ element.type === "line" &&
|
|
|
+ "polygon" in element &&
|
|
|
+ element.polygon === true &&
|
|
|
+ element.strokeWidth === 2,
|
|
|
+ );
|
|
|
+
|
|
|
+ const polygon = seriesPolygons[0];
|
|
|
+ const getRadius = (point: readonly [number, number]) =>
|
|
|
+ Math.hypot(point[0], point[1]);
|
|
|
+
|
|
|
+ const alphaRadius = getRadius(polygon.points[0]!);
|
|
|
+ const epsilonRadius = getRadius(polygon.points[4]!);
|
|
|
+
|
|
|
+ // With linear scaling this would collapse near 0; log keeps it visible.
|
|
|
+ expect(alphaRadius).toBeGreaterThan(40);
|
|
|
+ expect(epsilonRadius).toBeGreaterThan(alphaRadius);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("does not render 0/max value labels for radar charts", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Scores",
|
|
|
+ labels: ["alpha", "beta", "gamma", "delta", "epsilon"],
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ title: "Scores",
|
|
|
+ values: [40000, 8300, 95400, 7820, 5000000],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const elements = renderSpreadsheet("radar", spreadsheet, 0, 0);
|
|
|
+ const textElements = elements!.filter(
|
|
|
+ (element) => element.type === "text",
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(textElements.some((element) => element.text === "0")).toBe(false);
|
|
|
+ expect(
|
|
|
+ textElements.some(
|
|
|
+ (element) =>
|
|
|
+ element.text ===
|
|
|
+ Math.max(...spreadsheet.series[0].values).toLocaleString(),
|
|
|
+ ),
|
|
|
+ ).toBe(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("wraps long radar axis labels instead of ellipsifying", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Trait",
|
|
|
+ labels: [
|
|
|
+ "Physical Strength",
|
|
|
+ "Swordsmanship",
|
|
|
+ "Political Instinct",
|
|
|
+ "Book Knowledge",
|
|
|
+ "Strategic Thinking",
|
|
|
+ "Charisma",
|
|
|
+ "Courage",
|
|
|
+ "Stubbornness",
|
|
|
+ "Empathy",
|
|
|
+ "Practical Survival Skills",
|
|
|
+ ],
|
|
|
+ series: [
|
|
|
+ { title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] },
|
|
|
+ { title: "Egg", values: [2, 1, 9, 8, 9, 8, 7, 9, 8, 4] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const elements = renderSpreadsheet("radar", spreadsheet, 0, 0);
|
|
|
+ const textElements = elements!.filter(
|
|
|
+ (element) => element.type === "text",
|
|
|
+ );
|
|
|
+ const wrappedAxisLabels = textElements.filter(
|
|
|
+ (element) =>
|
|
|
+ element.text.includes("\n") &&
|
|
|
+ element.text !== "Trait" &&
|
|
|
+ element.text !== "Dunk" &&
|
|
|
+ element.text !== "Egg",
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(wrappedAxisLabels.length).toBeGreaterThan(0);
|
|
|
+ expect(
|
|
|
+ wrappedAxisLabels.every(
|
|
|
+ (element) =>
|
|
|
+ typeof element.originalText === "string" &&
|
|
|
+ !element.originalText.includes("\n"),
|
|
|
+ ),
|
|
|
+ ).toBe(true);
|
|
|
+ expect(
|
|
|
+ textElements.some(
|
|
|
+ (element) => element.text.includes("...") && element.text !== "Dunk",
|
|
|
+ ),
|
|
|
+ ).toBe(false);
|
|
|
+ expect(
|
|
|
+ textElements.some(
|
|
|
+ (element) =>
|
|
|
+ element.originalText === "Stubbornness" &&
|
|
|
+ !element.text.includes("\n") &&
|
|
|
+ element.text === "Stubbornness",
|
|
|
+ ),
|
|
|
+ ).toBe(true);
|
|
|
+ expect(
|
|
|
+ textElements.some(
|
|
|
+ (element) =>
|
|
|
+ element.originalText === "Physical Strength" &&
|
|
|
+ element.text.includes("Physical\nStrength"),
|
|
|
+ ),
|
|
|
+ ).toBe(true);
|
|
|
+
|
|
|
+ const topLabel = textElements.find(
|
|
|
+ (element) => element.originalText === "Physical Strength",
|
|
|
+ );
|
|
|
+ const topSpokeY = Math.min(
|
|
|
+ ...elements!
|
|
|
+ .filter(
|
|
|
+ (element): element is ExcalidrawLineElement =>
|
|
|
+ element.type === "line" &&
|
|
|
+ "polygon" in element &&
|
|
|
+ !element.polygon &&
|
|
|
+ element.strokeStyle === "solid" &&
|
|
|
+ element.strokeWidth === 1,
|
|
|
+ )
|
|
|
+ .map((element) => element.y + element.points[1][1]),
|
|
|
+ );
|
|
|
+ expect(topLabel).toBeDefined();
|
|
|
+ expect(topLabel!.y + topLabel!.height).toBeLessThan(topSpokeY - 2);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("renders radar title and series legend labels in Lilita One", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Trait",
|
|
|
+ labels: ["Physical Strength", "Swordsmanship", "Strategy", "Charisma"],
|
|
|
+ series: [
|
|
|
+ { title: "Dunk", values: [10, 8, 5, 7] },
|
|
|
+ { title: "Egg", values: [2, 1, 9, 8] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const elements = renderSpreadsheet("radar", spreadsheet, 0, 0);
|
|
|
+ const textElements = elements!.filter(
|
|
|
+ (element) => element.type === "text",
|
|
|
+ );
|
|
|
+ const title = textElements.find((element) =>
|
|
|
+ element.text.includes("Trait"),
|
|
|
+ );
|
|
|
+ const dunkLabel = textElements.find((element) => element.text === "Dunk");
|
|
|
+ const eggLabel = textElements.find((element) => element.text === "Egg");
|
|
|
+
|
|
|
+ expect(title?.fontFamily).toBe(FONT_FAMILY["Lilita One"]);
|
|
|
+ expect(title?.originalText).toBe("Trait");
|
|
|
+ expect(dunkLabel?.fontFamily).toBe(FONT_FAMILY["Lilita One"]);
|
|
|
+ expect(eggLabel?.fontFamily).toBe(FONT_FAMILY["Lilita One"]);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("positions radar title with vertical clearance above axis labels", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Trait",
|
|
|
+ labels: [
|
|
|
+ "Physical Strength",
|
|
|
+ "Swordsmanship",
|
|
|
+ "Political Instinct",
|
|
|
+ "Book Knowledge",
|
|
|
+ "Strategic Thinking",
|
|
|
+ "Charisma",
|
|
|
+ "Courage",
|
|
|
+ "Stubbornness",
|
|
|
+ "Empathy",
|
|
|
+ "Practical Survival Skills",
|
|
|
+ ],
|
|
|
+ series: [
|
|
|
+ { title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] },
|
|
|
+ { title: "Egg", values: [2, 1, 9, 8, 9, 8, 7, 9, 8, 4] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const elements = renderSpreadsheet("radar", spreadsheet, 0, 0);
|
|
|
+ const textElements = elements!.filter(
|
|
|
+ (element) => element.type === "text",
|
|
|
+ );
|
|
|
+ const title = textElements.find(
|
|
|
+ (element) => element.fontFamily === FONT_FAMILY["Lilita One"],
|
|
|
+ );
|
|
|
+ const axisLabels = textElements.filter(
|
|
|
+ (element) =>
|
|
|
+ element.fontFamily === FONT_FAMILY.Excalifont &&
|
|
|
+ element.text !== "Dunk" &&
|
|
|
+ element.text !== "Egg",
|
|
|
+ );
|
|
|
+ const topAxisLabelY = Math.min(...axisLabels.map((element) => element.y));
|
|
|
+
|
|
|
+ expect(title).toBeDefined();
|
|
|
+ expect(title!.y + title!.height).toBeLessThan(topAxisLabelY - 4);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("spreads radar series colors across palette to avoid similar adjacent colors", () => {
|
|
|
+ const palette = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX);
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Trait",
|
|
|
+ labels: ["A", "B", "C", "D", "E"],
|
|
|
+ series: [
|
|
|
+ { title: "S1", values: [1, 2, 3, 4, 5] },
|
|
|
+ { title: "S2", values: [2, 3, 4, 5, 1] },
|
|
|
+ { title: "S3", values: [3, 4, 5, 1, 2] },
|
|
|
+ { title: "S4", values: [4, 5, 1, 2, 3] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
|
|
|
+ const elements = renderSpreadsheet("radar", spreadsheet, 0, 0);
|
|
|
+ randomSpy.mockRestore();
|
|
|
+
|
|
|
+ const seriesPolygons = elements!.filter(
|
|
|
+ (element) =>
|
|
|
+ element.type === "line" &&
|
|
|
+ "polygon" in element &&
|
|
|
+ element.polygon === true &&
|
|
|
+ element.strokeWidth === 2,
|
|
|
+ );
|
|
|
+ const colorIndices = seriesPolygons.map((polygon) =>
|
|
|
+ palette.findIndex((color) => color === polygon.strokeColor),
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(colorIndices.every((index) => index >= 0)).toBe(true);
|
|
|
+
|
|
|
+ const circularDistance = (first: number, second: number) => {
|
|
|
+ const absoluteDistance = Math.abs(first - second);
|
|
|
+ return Math.min(absoluteDistance, palette.length - absoluteDistance);
|
|
|
+ };
|
|
|
+ const minDistance = Math.min(
|
|
|
+ ...colorIndices.flatMap((index, i) =>
|
|
|
+ colorIndices
|
|
|
+ .slice(i + 1)
|
|
|
+ .map((other) => circularDistance(index, other)),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(minDistance).toBeGreaterThan(1);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("positions series legend below the lowest axis label with clearance", () => {
|
|
|
+ const spreadsheet: Spreadsheet = {
|
|
|
+ title: "Trait",
|
|
|
+ labels: [
|
|
|
+ "Psychological Warfare",
|
|
|
+ "Divine Favor",
|
|
|
+ "Confidence",
|
|
|
+ "Morale",
|
|
|
+ "Armor Protection long wrapped label from above",
|
|
|
+ "Accuracy",
|
|
|
+ "Agility",
|
|
|
+ "Weapon Reach",
|
|
|
+ ],
|
|
|
+ series: [
|
|
|
+ { title: "David", values: [6, 7, 8, 9, 7, 8, 6, 9] },
|
|
|
+ { title: "Goliath", values: [9, 3, 2, 6, 10, 2, 8, 1] },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ const elements = renderSpreadsheet("radar", spreadsheet, 0, 0);
|
|
|
+ const textElements = elements!.filter(
|
|
|
+ (element) => element.type === "text",
|
|
|
+ );
|
|
|
+ const axisLabels = textElements.filter((element) =>
|
|
|
+ spreadsheet.labels?.includes(element.originalText),
|
|
|
+ );
|
|
|
+ const legendLabels = textElements.filter((element) =>
|
|
|
+ spreadsheet.series.some(
|
|
|
+ (series) => series.title === element.originalText,
|
|
|
+ ),
|
|
|
+ );
|
|
|
+
|
|
|
+ const axisBottomY = Math.max(
|
|
|
+ ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height),
|
|
|
+ );
|
|
|
+ const legendTopY = Math.min(
|
|
|
+ ...legendLabels.map((legendLabel) => legendLabel.y),
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(axisLabels.length).toBeGreaterThan(0);
|
|
|
+ expect(legendLabels.length).toBeGreaterThan(0);
|
|
|
+ expect(legendTopY).toBeGreaterThan(axisBottomY + 2);
|
|
|
});
|
|
|
});
|
|
|
});
|