Browse Source

feat(editor): support radar chart and multiple series for other chart types

dwelle 3 days ago
parent
commit
ce50bb1001
29 changed files with 2984 additions and 1305 deletions
  1. 15 16
      packages/common/src/colors.ts
  2. 1 1
      packages/element/src/types.ts
  3. 0 1
      packages/excalidraw/actions/actionCanvas.tsx
  4. 0 4
      packages/excalidraw/appState.ts
  5. 1063 16
      packages/excalidraw/charts.test.ts
  6. 0 481
      packages/excalidraw/charts.ts
  7. 103 0
      packages/excalidraw/charts/charts.bar.ts
  8. 63 0
      packages/excalidraw/charts/charts.constants.ts
  9. 865 0
      packages/excalidraw/charts/charts.helpers.ts
  10. 130 0
      packages/excalidraw/charts/charts.line.ts
  11. 142 0
      packages/excalidraw/charts/charts.parse.ts
  12. 199 0
      packages/excalidraw/charts/charts.radar.ts
  13. 18 0
      packages/excalidraw/charts/charts.types.ts
  14. 38 0
      packages/excalidraw/charts/index.ts
  15. 0 63
      packages/excalidraw/clipboard.test.ts
  16. 0 28
      packages/excalidraw/clipboard.ts
  17. 15 8
      packages/excalidraw/components/App.tsx
  18. 4 4
      packages/excalidraw/components/LayerUI.tsx
  19. 75 15
      packages/excalidraw/components/PasteChartDialog.scss
  20. 165 34
      packages/excalidraw/components/PasteChartDialog.tsx
  21. 6 0
      packages/excalidraw/index.tsx
  22. 4 0
      packages/excalidraw/locales/en.json
  23. 12 7
      packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap
  24. 0 85
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  25. 0 265
      packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
  26. 0 260
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  27. 64 0
      packages/excalidraw/tests/charts.test.tsx
  28. 2 12
      packages/excalidraw/types.ts
  29. 0 5
      packages/utils/tests/__snapshots__/export.test.ts.snap

+ 15 - 16
packages/common/src/colors.ts

@@ -240,22 +240,21 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
 // -----------------------------------------------------------------------------
 
 // !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!!
-export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
-  [
-    // 2nd row
-    COLOR_PALETTE.cyan[index],
-    COLOR_PALETTE.blue[index],
-    COLOR_PALETTE.violet[index],
-    COLOR_PALETTE.grape[index],
-    COLOR_PALETTE.pink[index],
-
-    // 3rd row
-    COLOR_PALETTE.green[index],
-    COLOR_PALETTE.teal[index],
-    COLOR_PALETTE.yellow[index],
-    COLOR_PALETTE.orange[index],
-    COLOR_PALETTE.red[index],
-  ] as const;
+export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) => [
+  // 2nd row
+  COLOR_PALETTE.cyan[index],
+  COLOR_PALETTE.blue[index],
+  COLOR_PALETTE.violet[index],
+  COLOR_PALETTE.grape[index],
+  COLOR_PALETTE.pink[index],
+
+  // 3rd row
+  COLOR_PALETTE.green[index],
+  COLOR_PALETTE.teal[index],
+  COLOR_PALETTE.yellow[index],
+  COLOR_PALETTE.orange[index],
+  COLOR_PALETTE.red[index],
+];
 
 // -----------------------------------------------------------------------------
 // other helpers

+ 1 - 1
packages/element/src/types.ts

@@ -15,7 +15,7 @@ import type {
   ValueOf,
 } from "@excalidraw/common/utility-types";
 
-export type ChartType = "bar" | "line";
+export type ChartType = "bar" | "line" | "radar";
 export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
 export type FontFamilyKeys = keyof typeof FONT_FAMILY;
 export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys];

+ 0 - 1
packages/excalidraw/actions/actionCanvas.tsx

@@ -118,7 +118,6 @@ export const actionClearCanvas = register({
         gridStep: appState.gridStep,
         gridModeEnabled: appState.gridModeEnabled,
         stats: appState.stats,
-        pasteDialog: appState.pasteDialog,
         activeTool:
           appState.activeTool.type === "image"
             ? {

+ 0 - 4
packages/excalidraw/appState.ts

@@ -27,7 +27,6 @@ export const getDefaultAppState = (): Omit<
     showWelcomeScreen: false,
     theme: THEME.LIGHT,
     collaborators: new Map(),
-    currentChartType: "bar",
     currentItemBackgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor,
     currentItemEndArrowhead: "arrow",
     currentItemFillStyle: DEFAULT_ELEMENT_PROPS.fillStyle,
@@ -83,7 +82,6 @@ export const getDefaultAppState = (): Omit<
     openPopup: null,
     openSidebar: null,
     openDialog: null,
-    pasteDialog: { shown: false, data: null },
     previousSelectedElementIds: {},
     resizingElement: null,
     scrolledOutside: false,
@@ -150,7 +148,6 @@ const APP_STATE_STORAGE_CONF = (<
   showWelcomeScreen: { browser: true, export: false, server: false },
   theme: { browser: true, export: false, server: false },
   collaborators: { browser: false, export: false, server: false },
-  currentChartType: { browser: true, export: false, server: false },
   currentItemBackgroundColor: { browser: true, export: false, server: false },
   currentItemEndArrowhead: { browser: true, export: false, server: false },
   currentItemFillStyle: { browser: true, export: false, server: false },
@@ -212,7 +209,6 @@ const APP_STATE_STORAGE_CONF = (<
   openPopup: { browser: false, export: false, server: false },
   openSidebar: { browser: true, export: false, server: false },
   openDialog: { browser: false, export: false, server: false },
-  pasteDialog: { browser: false, export: false, server: false },
   previousSelectedElementIds: { browser: true, export: false, server: false },
   resizingElement: { browser: false, export: false, server: false },
   scrolledOutside: { browser: true, export: false, server: false },

+ 1063 - 16
packages/excalidraw/charts.test.ts

@@ -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);
     });
   });
 });

+ 0 - 481
packages/excalidraw/charts.ts

@@ -1,481 +0,0 @@
-import { pointFrom } from "@excalidraw/math";
-
-import {
-  COLOR_PALETTE,
-  DEFAULT_CHART_COLOR_INDEX,
-  getAllColorsSpecificShade,
-  DEFAULT_FONT_FAMILY,
-  DEFAULT_FONT_SIZE,
-  VERTICAL_ALIGN,
-  randomId,
-  isDevEnv,
-  FONT_SIZES,
-} from "@excalidraw/common";
-
-import {
-  newTextElement,
-  newLinearElement,
-  newElement,
-} from "@excalidraw/element";
-
-import type { Radians } from "@excalidraw/math";
-
-import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
-
-export type ChartElements = readonly NonDeletedExcalidrawElement[];
-
-const BAR_WIDTH = 32;
-const BAR_GAP = 12;
-const BAR_HEIGHT = 256;
-const GRID_OPACITY = 50;
-
-export interface Spreadsheet {
-  title: string | null;
-  labels: string[] | null;
-  values: number[];
-}
-
-export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
-export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
-
-type ParseSpreadsheetResult =
-  | { type: typeof NOT_SPREADSHEET; reason: string }
-  | { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
-
-/**
- * @private exported for testing
- */
-export const tryParseNumber = (s: string): number | null => {
-  const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
-  if (!match) {
-    return null;
-  }
-  return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
-};
-
-const isNumericColumn = (lines: string[][], columnIndex: number) =>
-  lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
-
-/**
- * @private exported for testing
- */
-export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
-  const numCols = cells[0].length;
-
-  if (numCols > 2) {
-    return { type: NOT_SPREADSHEET, reason: "More than 2 columns" };
-  }
-
-  if (numCols === 1) {
-    if (!isNumericColumn(cells, 0)) {
-      return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
-    }
-
-    const hasHeader = tryParseNumber(cells[0][0]) === null;
-    const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
-      tryParseNumber(line[0]),
-    );
-
-    if (values.length < 2) {
-      return { type: NOT_SPREADSHEET, reason: "Less than two rows" };
-    }
-
-    return {
-      type: VALID_SPREADSHEET,
-      spreadsheet: {
-        title: hasHeader ? cells[0][0] : null,
-        labels: null,
-        values: values as number[],
-      },
-    };
-  }
-
-  const labelColumnNumeric = isNumericColumn(cells, 0);
-  const valueColumnNumeric = isNumericColumn(cells, 1);
-
-  if (!labelColumnNumeric && !valueColumnNumeric) {
-    return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
-  }
-
-  const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
-    ? [0, 1]
-    : [1, 0];
-  const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
-  const rows = hasHeader ? cells.slice(1) : cells;
-
-  if (rows.length < 2) {
-    return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" };
-  }
-
-  return {
-    type: VALID_SPREADSHEET,
-    spreadsheet: {
-      title: hasHeader ? cells[0][valueColumnIndex] : null,
-      labels: rows.map((row) => row[labelColumnIndex]),
-      values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
-    },
-  };
-};
-
-const transposeCells = (cells: string[][]) => {
-  const nextCells: string[][] = [];
-  for (let col = 0; col < cells[0].length; col++) {
-    const nextCellRow: string[] = [];
-    for (let row = 0; row < cells.length; row++) {
-      nextCellRow.push(cells[row][col]);
-    }
-    nextCells.push(nextCellRow);
-  }
-  return nextCells;
-};
-
-export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
-  // Copy/paste from excel, spreadsheets, tsv, csv.
-  // For now we only accept 2 columns with an optional header
-
-  // Check for tab separated values
-  let lines = text
-    .trim()
-    .split("\n")
-    .map((line) => line.trim().split("\t"));
-
-  // Check for comma separated files
-  if (lines.length && lines[0].length !== 2) {
-    lines = text
-      .trim()
-      .split("\n")
-      .map((line) => line.trim().split(","));
-  }
-
-  if (lines.length === 0) {
-    return { type: NOT_SPREADSHEET, reason: "No values" };
-  }
-
-  const numColsFirstLine = lines[0].length;
-  const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
-
-  if (!isSpreadsheet) {
-    return {
-      type: NOT_SPREADSHEET,
-      reason: "All rows don't have same number of columns",
-    };
-  }
-
-  const result = tryParseCells(lines);
-  if (result.type !== VALID_SPREADSHEET) {
-    const transposedResults = tryParseCells(transposeCells(lines));
-    if (transposedResults.type === VALID_SPREADSHEET) {
-      return transposedResults;
-    }
-  }
-  return result;
-};
-
-const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX);
-
-// Put all the common properties here so when the whole chart is selected
-// the properties dialog shows the correct selected values
-const commonProps = {
-  fillStyle: "hachure",
-  fontFamily: DEFAULT_FONT_FAMILY,
-  fontSize: DEFAULT_FONT_SIZE,
-  opacity: 100,
-  roughness: 1,
-  strokeColor: COLOR_PALETTE.black,
-  roundness: null,
-  strokeStyle: "solid",
-  strokeWidth: 1,
-  verticalAlign: VERTICAL_ALIGN.MIDDLE,
-  locked: false,
-} as const;
-
-const getChartDimensions = (spreadsheet: Spreadsheet) => {
-  const chartWidth =
-    (BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
-  const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
-  return { chartWidth, chartHeight };
-};
-
-const chartXLabels = (
-  spreadsheet: Spreadsheet,
-  x: number,
-  y: number,
-  groupId: string,
-  backgroundColor: string,
-): ChartElements => {
-  return (
-    spreadsheet.labels?.map((label, index) => {
-      return newTextElement({
-        groupIds: [groupId],
-        backgroundColor,
-        ...commonProps,
-        text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
-        x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
-        y: y + BAR_GAP / 2,
-        width: BAR_WIDTH,
-        angle: 5.87 as Radians,
-        fontSize: FONT_SIZES.sm,
-        textAlign: "center",
-        verticalAlign: "top",
-      });
-    }) || []
-  );
-};
-
-const chartYLabels = (
-  spreadsheet: Spreadsheet,
-  x: number,
-  y: number,
-  groupId: string,
-  backgroundColor: string,
-): ChartElements => {
-  const minYLabel = newTextElement({
-    groupIds: [groupId],
-    backgroundColor,
-    ...commonProps,
-    x: x - BAR_GAP,
-    y: y - BAR_GAP,
-    text: "0",
-    textAlign: "right",
-  });
-
-  const maxYLabel = newTextElement({
-    groupIds: [groupId],
-    backgroundColor,
-    ...commonProps,
-    x: x - BAR_GAP,
-    y: y - BAR_HEIGHT - minYLabel.height / 2,
-    text: Math.max(...spreadsheet.values).toLocaleString(),
-    textAlign: "right",
-  });
-
-  return [minYLabel, maxYLabel];
-};
-
-const chartLines = (
-  spreadsheet: Spreadsheet,
-  x: number,
-  y: number,
-  groupId: string,
-  backgroundColor: string,
-): ChartElements => {
-  const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
-  const xLine = newLinearElement({
-    backgroundColor,
-    groupIds: [groupId],
-    ...commonProps,
-    type: "line",
-    x,
-    y,
-    width: chartWidth,
-    points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
-  });
-
-  const yLine = newLinearElement({
-    backgroundColor,
-    groupIds: [groupId],
-    ...commonProps,
-    type: "line",
-    x,
-    y,
-    height: chartHeight,
-    points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
-  });
-
-  const maxLine = newLinearElement({
-    backgroundColor,
-    groupIds: [groupId],
-    ...commonProps,
-    type: "line",
-    x,
-    y: y - BAR_HEIGHT - BAR_GAP,
-    strokeStyle: "dotted",
-    width: chartWidth,
-    opacity: GRID_OPACITY,
-    points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
-  });
-
-  return [xLine, yLine, maxLine];
-};
-
-// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
-const chartBaseElements = (
-  spreadsheet: Spreadsheet,
-  x: number,
-  y: number,
-  groupId: string,
-  backgroundColor: string,
-  debug?: boolean,
-): ChartElements => {
-  const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
-
-  const title = spreadsheet.title
-    ? newTextElement({
-        backgroundColor,
-        groupIds: [groupId],
-        ...commonProps,
-        text: spreadsheet.title,
-        x: x + chartWidth / 2,
-        y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
-        roundness: null,
-        textAlign: "center",
-      })
-    : null;
-
-  const debugRect = debug
-    ? newElement({
-        backgroundColor,
-        groupIds: [groupId],
-        ...commonProps,
-        type: "rectangle",
-        x,
-        y: y - chartHeight,
-        width: chartWidth,
-        height: chartHeight,
-        strokeColor: COLOR_PALETTE.black,
-        fillStyle: "solid",
-        opacity: 6,
-      })
-    : null;
-
-  return [
-    ...(debugRect ? [debugRect] : []),
-    ...(title ? [title] : []),
-    ...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
-    ...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
-    ...chartLines(spreadsheet, x, y, groupId, backgroundColor),
-  ];
-};
-
-const chartTypeBar = (
-  spreadsheet: Spreadsheet,
-  x: number,
-  y: number,
-): ChartElements => {
-  const max = Math.max(...spreadsheet.values);
-  const groupId = randomId();
-  const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
-
-  const bars = spreadsheet.values.map((value, index) => {
-    const barHeight = (value / max) * BAR_HEIGHT;
-    return newElement({
-      backgroundColor,
-      groupIds: [groupId],
-      ...commonProps,
-      type: "rectangle",
-      x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
-      y: y - barHeight - BAR_GAP,
-      width: BAR_WIDTH,
-      height: barHeight,
-    });
-  });
-
-  return [
-    ...bars,
-    ...chartBaseElements(
-      spreadsheet,
-      x,
-      y,
-      groupId,
-      backgroundColor,
-      isDevEnv(),
-    ),
-  ];
-};
-
-const chartTypeLine = (
-  spreadsheet: Spreadsheet,
-  x: number,
-  y: number,
-): ChartElements => {
-  const max = Math.max(...spreadsheet.values);
-  const groupId = randomId();
-  const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
-
-  let index = 0;
-  const points = [];
-  for (const value of spreadsheet.values) {
-    const cx = index * (BAR_WIDTH + BAR_GAP);
-    const cy = -(value / max) * BAR_HEIGHT;
-    points.push([cx, cy]);
-    index++;
-  }
-
-  const maxX = Math.max(...points.map((element) => element[0]));
-  const maxY = Math.max(...points.map((element) => element[1]));
-  const minX = Math.min(...points.map((element) => element[0]));
-  const minY = Math.min(...points.map((element) => element[1]));
-
-  const line = newLinearElement({
-    backgroundColor,
-    groupIds: [groupId],
-    ...commonProps,
-    type: "line",
-    x: x + BAR_GAP + BAR_WIDTH / 2,
-    y: y - BAR_GAP,
-    height: maxY - minY,
-    width: maxX - minX,
-    strokeWidth: 2,
-    points: points as any,
-  });
-
-  const dots = spreadsheet.values.map((value, index) => {
-    const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
-    const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2;
-    return newElement({
-      backgroundColor,
-      groupIds: [groupId],
-      ...commonProps,
-      fillStyle: "solid",
-      strokeWidth: 2,
-      type: "ellipse",
-      x: x + cx + BAR_WIDTH / 2,
-      y: y + cy - BAR_GAP * 2,
-      width: BAR_GAP,
-      height: BAR_GAP,
-    });
-  });
-
-  const lines = spreadsheet.values.map((value, index) => {
-    const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
-    const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP;
-    return newLinearElement({
-      backgroundColor,
-      groupIds: [groupId],
-      ...commonProps,
-      type: "line",
-      x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
-      y: y - cy,
-      height: cy,
-      strokeStyle: "dotted",
-      opacity: GRID_OPACITY,
-      points: [pointFrom(0, 0), pointFrom(0, cy)],
-    });
-  });
-
-  return [
-    ...chartBaseElements(
-      spreadsheet,
-      x,
-      y,
-      groupId,
-      backgroundColor,
-      isDevEnv(),
-    ),
-    line,
-    ...lines,
-    ...dots,
-  ];
-};
-
-export const renderSpreadsheet = (
-  chartType: string,
-  spreadsheet: Spreadsheet,
-  x: number,
-  y: number,
-): ChartElements => {
-  if (chartType === "line") {
-    return chartTypeLine(spreadsheet, x, y);
-  }
-  return chartTypeBar(spreadsheet, x, y);
-};

+ 103 - 0
packages/excalidraw/charts/charts.bar.ts

@@ -0,0 +1,103 @@
+import { isDevEnv } from "@excalidraw/common";
+
+import { newElement } from "@excalidraw/element";
+
+import { commonProps } from "./charts.constants";
+import {
+  chartBaseElements,
+  chartXLabels,
+  createSeriesLegend,
+  getBackgroundColor,
+  getCartesianChartLayout,
+  getChartDimensions,
+  getColorOffset,
+  getRotatedTextElementBottom,
+  getSeriesColors,
+} from "./charts.helpers";
+
+import type { ChartElements, Spreadsheet } from "./charts.types";
+
+export const renderBarChart = (
+  spreadsheet: Spreadsheet,
+  x: number,
+  y: number,
+  colorSeed?: number,
+): ChartElements => {
+  const series = spreadsheet.series;
+  const layout = getCartesianChartLayout("bar", series.length);
+  const max = Math.max(
+    1,
+    ...series.flatMap((seriesData) =>
+      seriesData.values.map((value) => Math.max(0, value)),
+    ),
+  );
+  const colorOffset = getColorOffset(colorSeed);
+  const backgroundColor = getBackgroundColor(colorOffset);
+  const seriesColors = getSeriesColors(series.length, colorOffset);
+  const interBarGap =
+    series.length > 1
+      ? Math.max(1, Math.floor(layout.gap / (series.length + 1)))
+      : 0;
+  const barWidth =
+    series.length > 1
+      ? Math.max(
+          2,
+          (layout.slotWidth - interBarGap * (series.length - 1)) /
+            series.length,
+        )
+      : layout.slotWidth;
+  const clusterWidth =
+    series.length * barWidth + interBarGap * (series.length - 1);
+  const clusterOffset = (layout.slotWidth - clusterWidth) / 2;
+
+  const bars = series[0].values.flatMap((_, categoryIndex) =>
+    series.map((seriesData, seriesIndex) => {
+      const value = Math.max(0, seriesData.values[categoryIndex] ?? 0);
+      const barHeight = (value / max) * layout.chartHeight;
+      const barColor =
+        series.length > 1 ? seriesColors[seriesIndex] : backgroundColor;
+      return newElement({
+        backgroundColor: barColor,
+        ...commonProps,
+        type: "rectangle",
+        fillStyle: series.length > 1 ? "solid" : commonProps.fillStyle,
+        strokeColor: series.length > 1 ? barColor : commonProps.strokeColor,
+        x:
+          x +
+          categoryIndex * (layout.slotWidth + layout.gap) +
+          layout.gap +
+          clusterOffset +
+          seriesIndex * (barWidth + interBarGap),
+        y: y - barHeight - layout.gap,
+        width: barWidth,
+        height: barHeight,
+      });
+    }),
+  );
+
+  const baseElements = chartBaseElements(
+    spreadsheet,
+    x,
+    y,
+    backgroundColor,
+    layout,
+    max,
+    isDevEnv(),
+  );
+  const xLabels = chartXLabels(spreadsheet, x, y, backgroundColor, layout);
+  const xLabelsBottomY = Math.max(
+    y + layout.gap / 2,
+    ...xLabels.map((label) => getRotatedTextElementBottom(label)),
+  );
+  const { chartWidth } = getChartDimensions(spreadsheet, layout);
+  const seriesLegend = createSeriesLegend(
+    series,
+    seriesColors,
+    x + chartWidth / 2,
+    xLabelsBottomY,
+    y + layout.gap * 5,
+    backgroundColor,
+  );
+
+  return [...baseElements, ...bars, ...seriesLegend];
+};

+ 63 - 0
packages/excalidraw/charts/charts.constants.ts

@@ -0,0 +1,63 @@
+import {
+  COLOR_PALETTE,
+  DEFAULT_FONT_FAMILY,
+  DEFAULT_FONT_SIZE,
+  VERTICAL_ALIGN,
+} from "@excalidraw/common";
+
+import type { Radians } from "@excalidraw/math";
+
+export const CARTESIAN_BASE_SLOT_WIDTH = 44;
+export const CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES = 22;
+export const CARTESIAN_BAR_SLOT_EXTRA_MAX = 66;
+export const CARTESIAN_LINE_SLOT_WIDTH = 48;
+export const CARTESIAN_GAP = 14;
+export const CARTESIAN_BAR_HEIGHT = 304;
+export const CARTESIAN_LINE_HEIGHT = 320;
+export const CARTESIAN_LABEL_ROTATION = 5.87 as Radians;
+export const CARTESIAN_LABEL_MIN_WIDTH = 28;
+export const CARTESIAN_LABEL_SLOT_PADDING = 4;
+export const CARTESIAN_LABEL_AXIS_CLEARANCE = 2;
+export const CARTESIAN_LABEL_MAX_WIDTH_BUFFER = 10;
+export const CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER = 10;
+export const CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER = 8;
+
+export const BAR_GAP = 12;
+export const BAR_HEIGHT = 256;
+export const GRID_OPACITY = 10;
+
+export const RADAR_GRID_LEVELS = 4;
+export const RADAR_LABEL_OFFSET = BAR_GAP * 2;
+export const RADAR_PADDING = BAR_GAP * 2;
+export const RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD = 100;
+export const RADAR_AXIS_LABEL_MAX_WIDTH = 140;
+export const RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD = 0.35;
+export const RADAR_AXIS_LABEL_CLEARANCE = BAR_GAP / 2;
+export const RADAR_LEGEND_SWATCH_SIZE = 20;
+export const RADAR_LEGEND_ITEM_GAP = BAR_GAP * 2;
+export const RADAR_LEGEND_TEXT_GAP = BAR_GAP;
+
+// Put all common chart element properties here so properties dialog
+// shows stable values when selecting chart groups.
+export const commonProps = {
+  fillStyle: "hachure",
+  fontFamily: DEFAULT_FONT_FAMILY,
+  fontSize: DEFAULT_FONT_SIZE,
+  opacity: 100,
+  roughness: 1,
+  strokeColor: COLOR_PALETTE.black,
+  roundness: null,
+  strokeStyle: "solid",
+  strokeWidth: 1,
+  verticalAlign: VERTICAL_ALIGN.MIDDLE,
+  locked: false,
+} as const;
+
+export type CartesianChartType = "bar" | "line";
+
+export type CartesianChartLayout = {
+  slotWidth: number;
+  gap: number;
+  chartHeight: number;
+  xLabelMaxWidth: number;
+};

+ 865 - 0
packages/excalidraw/charts/charts.helpers.ts

@@ -0,0 +1,865 @@
+import { pointFrom } from "@excalidraw/math";
+
+import {
+  COLOR_PALETTE,
+  DEFAULT_CHART_COLOR_INDEX,
+  FONT_FAMILY,
+  FONT_SIZES,
+  ROUNDNESS,
+  DEFAULT_FONT_SIZE,
+  getAllColorsSpecificShade,
+  getFontString,
+  getLineHeight,
+  ROUGHNESS,
+} from "@excalidraw/common";
+
+import {
+  getApproxMinLineWidth,
+  measureText,
+  newElement,
+  newLinearElement,
+  newTextElement,
+  wrapText,
+} from "@excalidraw/element";
+
+import type {
+  ChartType,
+  ExcalidrawTextElement,
+} from "@excalidraw/element/types";
+import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
+
+import {
+  BAR_GAP,
+  CARTESIAN_BAR_HEIGHT,
+  CARTESIAN_BASE_SLOT_WIDTH,
+  CARTESIAN_BAR_SLOT_EXTRA_MAX,
+  CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES,
+  CARTESIAN_GAP,
+  CARTESIAN_LABEL_AXIS_CLEARANCE,
+  CARTESIAN_LABEL_MAX_WIDTH_BUFFER,
+  CARTESIAN_LABEL_MIN_WIDTH,
+  CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER,
+  CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER,
+  CARTESIAN_LABEL_ROTATION,
+  CARTESIAN_LABEL_SLOT_PADDING,
+  CARTESIAN_LINE_HEIGHT,
+  CARTESIAN_LINE_SLOT_WIDTH,
+  GRID_OPACITY,
+  RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD,
+  RADAR_AXIS_LABEL_CLEARANCE,
+  RADAR_AXIS_LABEL_MAX_WIDTH,
+  RADAR_LABEL_OFFSET,
+  RADAR_LEGEND_ITEM_GAP,
+  RADAR_LEGEND_SWATCH_SIZE,
+  RADAR_LEGEND_TEXT_GAP,
+  RADAR_PADDING,
+  RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD,
+  BAR_HEIGHT,
+  commonProps,
+  type CartesianChartLayout,
+  type CartesianChartType,
+} from "./charts.constants";
+
+import type {
+  ChartElements,
+  Spreadsheet,
+  SpreadsheetSeries,
+} from "./charts.types";
+
+const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX);
+
+const getSpreadsheetDimensionCount = (spreadsheet: Spreadsheet) =>
+  spreadsheet.labels?.length ?? spreadsheet.series[0]?.values.length ?? 0;
+
+export const isSpreadsheetValidForChartType = (
+  spreadsheet: Spreadsheet | null,
+  chartType: ChartType,
+) => {
+  if (!spreadsheet) {
+    return false;
+  }
+
+  const dimensionCount = getSpreadsheetDimensionCount(spreadsheet);
+  if (dimensionCount < 2) {
+    return false;
+  }
+
+  if (chartType === "radar") {
+    return dimensionCount >= 3;
+  }
+
+  return true;
+};
+
+const getSeriesAwareSlotWidth = (
+  baseSlotWidth: number,
+  seriesCount: number,
+) => {
+  const extraSlotWidth =
+    seriesCount <= 1
+      ? 0
+      : Math.min(
+          CARTESIAN_BAR_SLOT_EXTRA_MAX,
+          (seriesCount - 1) * CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES,
+        );
+  return baseSlotWidth + extraSlotWidth;
+};
+
+export const getCartesianChartLayout = (
+  chartType: CartesianChartType,
+  seriesCount: number,
+): CartesianChartLayout => {
+  if (chartType === "line") {
+    const slotWidth = getSeriesAwareSlotWidth(
+      CARTESIAN_LINE_SLOT_WIDTH,
+      seriesCount,
+    );
+    return {
+      slotWidth,
+      gap: CARTESIAN_GAP,
+      chartHeight: CARTESIAN_LINE_HEIGHT,
+      xLabelMaxWidth:
+        slotWidth + CARTESIAN_GAP * 3 + CARTESIAN_LABEL_MAX_WIDTH_BUFFER,
+    };
+  }
+
+  const slotWidth = getSeriesAwareSlotWidth(
+    CARTESIAN_BASE_SLOT_WIDTH,
+    seriesCount,
+  );
+  return {
+    slotWidth,
+    gap: CARTESIAN_GAP,
+    chartHeight: CARTESIAN_BAR_HEIGHT,
+    xLabelMaxWidth:
+      slotWidth + CARTESIAN_GAP * 3 + CARTESIAN_LABEL_MAX_WIDTH_BUFFER,
+  };
+};
+
+export const getChartDimensions = (
+  spreadsheet: Spreadsheet,
+  layout: CartesianChartLayout,
+) => {
+  const chartWidth =
+    (layout.slotWidth + layout.gap) * spreadsheet.series[0].values.length +
+    layout.gap;
+  const chartHeight = layout.chartHeight + layout.gap * 2;
+  return { chartWidth, chartHeight };
+};
+
+export const getRadarDimensions = () => {
+  const chartWidth = BAR_HEIGHT + RADAR_PADDING * 2;
+  const chartHeight = BAR_HEIGHT + RADAR_PADDING * 2;
+  return { chartWidth, chartHeight };
+};
+
+const getCircularDistance = (
+  firstIndex: number,
+  secondIndex: number,
+  paletteSize: number,
+) => {
+  const absoluteDistance = Math.abs(firstIndex - secondIndex);
+  return Math.min(absoluteDistance, paletteSize - absoluteDistance);
+};
+
+export const getSeriesColors = (
+  seriesCount: number,
+  colorOffset: number,
+): readonly string[] => {
+  if (seriesCount <= 0 || bgColors.length === 0) {
+    return [];
+  }
+
+  const paletteSize = bgColors.length;
+  const startIndex = ((colorOffset % paletteSize) + paletteSize) % paletteSize;
+  const selectedIndices = [startIndex];
+  const maxUniqueColors = Math.min(seriesCount, paletteSize);
+  const availableIndices = new Set(
+    Array.from({ length: paletteSize }, (_, index) => index).filter(
+      (index) => index !== startIndex,
+    ),
+  );
+
+  while (selectedIndices.length < maxUniqueColors) {
+    let bestIndex = -1;
+    let bestMinDistance = -1;
+    let bestAverageDistance = -1;
+
+    for (const candidateIndex of availableIndices) {
+      const distances = selectedIndices.map((selectedIndex) =>
+        getCircularDistance(candidateIndex, selectedIndex, paletteSize),
+      );
+      const minDistance = Math.min(...distances);
+      const averageDistance =
+        distances.reduce((total, distance) => total + distance, 0) /
+        distances.length;
+
+      if (
+        minDistance > bestMinDistance ||
+        (minDistance === bestMinDistance &&
+          averageDistance > bestAverageDistance)
+      ) {
+        bestIndex = candidateIndex;
+        bestMinDistance = minDistance;
+        bestAverageDistance = averageDistance;
+      }
+    }
+
+    selectedIndices.push(bestIndex);
+    availableIndices.delete(bestIndex);
+  }
+
+  return Array.from(
+    { length: seriesCount },
+    (_, index) => bgColors[selectedIndices[index % selectedIndices.length]],
+  );
+};
+
+export const getColorOffset = (colorSeed?: number) => {
+  if (bgColors.length === 0) {
+    return 0;
+  }
+
+  if (typeof colorSeed !== "number" || !Number.isFinite(colorSeed)) {
+    return Math.floor(Math.random() * bgColors.length);
+  }
+
+  const seedText = colorSeed.toString();
+  let hash = 0;
+  for (let index = 0; index < seedText.length; index++) {
+    hash = (hash * 31 + seedText.charCodeAt(index)) | 0;
+  }
+  return Math.abs(hash) % bgColors.length;
+};
+
+export const getBackgroundColor = (colorOffset: number) =>
+  bgColors[colorOffset];
+
+export const getRadarValueScale = (
+  series: SpreadsheetSeries[],
+  _labelsLength: number,
+) => {
+  const allValues = series.flatMap((s) =>
+    s.values.map((value) => Math.max(0, value)),
+  );
+  const positiveValues = allValues.filter((value) => value > 0);
+  const max = Math.max(1, ...allValues);
+  const minPositive =
+    positiveValues.length > 0 ? Math.min(...positiveValues) : 1;
+  const useLogScale =
+    series.length === 1 &&
+    minPositive > 0 &&
+    max / minPositive >= RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD;
+
+  return {
+    renderSteps: false,
+    normalize: (value: number, _axisIndex: number) => {
+      const safeValue = Math.max(0, value);
+      return useLogScale
+        ? Math.log10(safeValue + 1) / Math.log10(max + 1)
+        : safeValue / max;
+    },
+  };
+};
+
+const shouldWrapRadarText = (text: string) => /\s/.test(text.trim());
+
+export const getRadarDisplayText = (
+  text: string,
+  fontString: ReturnType<typeof getFontString>,
+  maxWidth: number,
+) => {
+  return shouldWrapRadarText(text)
+    ? wrapText(text, fontString, maxWidth)
+    : text;
+};
+
+export const createRadarAxisLabels = (
+  labels: readonly string[],
+  angles: readonly number[],
+  centerX: number,
+  centerY: number,
+  radius: number,
+  backgroundColor: string,
+): {
+  axisLabels: ChartElements;
+  axisLabelTopY: number;
+  axisLabelBottomY: number;
+} => {
+  const fontFamily = FONT_FAMILY.Excalifont;
+  const fontSize = FONT_SIZES.sm;
+  const lineHeight = getLineHeight(fontFamily);
+  const fontString = getFontString({ fontFamily, fontSize });
+  const baseLabelWidth = Math.min(
+    RADAR_AXIS_LABEL_MAX_WIDTH,
+    radius * (labels.length > 8 ? 0.56 : 0.72),
+  );
+  const minLabelWidth = getApproxMinLineWidth(fontString, lineHeight);
+
+  const axisLabels = labels.map((label, index) => {
+    const angle = angles[index];
+    const longestWordWidth = Math.max(
+      0,
+      ...label
+        .trim()
+        .split(/\s+/)
+        .filter(Boolean)
+        .map((word) => measureText(word, fontString, lineHeight).width),
+    );
+    const maxLabelWidth = Math.max(
+      minLabelWidth,
+      baseLabelWidth,
+      longestWordWidth,
+    );
+    const displayLabel = getRadarDisplayText(label, fontString, maxLabelWidth);
+    const metrics = measureText(displayLabel, fontString, lineHeight);
+    const cos = Math.cos(angle);
+    const sin = Math.sin(angle);
+
+    const textAlign: "left" | "center" | "right" =
+      cos > RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
+        ? "left"
+        : cos < -RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
+        ? "right"
+        : "center";
+
+    // Keep labels outside the radar ring by projecting text extents
+    // onto the axis direction.
+    const centerAlignedXExtent = textAlign === "center" ? metrics.width / 2 : 0;
+    const projectedExtent =
+      Math.abs(cos) * centerAlignedXExtent +
+      Math.abs(sin) * (metrics.height / 2);
+    const radialOffset =
+      RADAR_LABEL_OFFSET + projectedExtent + RADAR_AXIS_LABEL_CLEARANCE;
+    const anchorX = centerX + cos * (radius + radialOffset);
+    const anchorY = centerY + sin * (radius + radialOffset);
+
+    const yNudge =
+      sin > RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
+        ? BAR_GAP / 3
+        : sin < -RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
+        ? -BAR_GAP / 3
+        : 0;
+
+    return newTextElement({
+      backgroundColor,
+      ...commonProps,
+      text: displayLabel,
+      originalText: label,
+      x: anchorX,
+      y: anchorY + yNudge,
+      fontFamily,
+      fontSize,
+      lineHeight,
+      textAlign,
+      verticalAlign: "middle",
+    });
+  });
+
+  const axisLabelTopY = Math.min(...axisLabels.map((axisLabel) => axisLabel.y));
+  const axisLabelBottomY = Math.max(
+    ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height),
+  );
+  return { axisLabels, axisLabelTopY, axisLabelBottomY };
+};
+
+export const createSeriesLegend = (
+  series: SpreadsheetSeries[],
+  seriesColors: readonly string[],
+  centerX: number,
+  minLegendTopY: number,
+  fallbackLegendY: number,
+  backgroundColor: string,
+): ChartElements => {
+  if (series.length <= 1) {
+    return [];
+  }
+
+  const fontFamily = FONT_FAMILY["Lilita One"];
+  const fontSize = FONT_SIZES.lg;
+  const lineHeight = getLineHeight(fontFamily);
+  const fontString = getFontString({ fontFamily, fontSize });
+  const legendItems = series.map((seriesItem, index) => {
+    const label = seriesItem.title?.trim() || `Series ${index + 1}`;
+    const displayLabel = getRadarDisplayText(label, fontString, BAR_HEIGHT);
+    const metrics = measureText(displayLabel, fontString, lineHeight);
+    const itemWidth =
+      RADAR_LEGEND_SWATCH_SIZE + RADAR_LEGEND_TEXT_GAP + metrics.width;
+    return {
+      label,
+      displayLabel,
+      color: seriesColors[index],
+      width: itemWidth,
+      height: metrics.height,
+    };
+  });
+  const maxLegendHalfHeight = Math.max(
+    RADAR_LEGEND_SWATCH_SIZE / 2,
+    ...legendItems.map((item) => item.height / 2),
+  );
+  const legendY = Math.max(
+    fallbackLegendY,
+    minLegendTopY + maxLegendHalfHeight + RADAR_LABEL_OFFSET,
+  );
+
+  const pillPaddingX = RADAR_LEGEND_ITEM_GAP;
+  const pillPaddingY = RADAR_LEGEND_SWATCH_SIZE * 0.6;
+  const totalLegendWidth =
+    legendItems.reduce((total, item) => total + item.width, 0) +
+    RADAR_LEGEND_ITEM_GAP * Math.max(0, legendItems.length - 1);
+  const pillWidth = totalLegendWidth + pillPaddingX * 2;
+  const pillHeight = maxLegendHalfHeight * 2 + pillPaddingY * 2;
+
+  const legendElements: NonDeletedExcalidrawElement[] = [];
+
+  // rounded pill background
+  legendElements.push(
+    newElement({
+      ...commonProps,
+      backgroundColor: "transparent",
+      type: "rectangle",
+      fillStyle: "solid",
+      strokeColor: COLOR_PALETTE.black,
+      x: centerX - pillWidth / 2,
+      y: legendY - pillHeight / 2,
+      width: pillWidth,
+      height: pillHeight,
+      roughness: ROUGHNESS.architect,
+      roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
+    }),
+  );
+
+  let cursorX = centerX - totalLegendWidth / 2;
+
+  legendItems.forEach((item) => {
+    // solid filled swatch
+    legendElements.push(
+      newElement({
+        ...commonProps,
+        backgroundColor: item.color,
+        type: "rectangle",
+        x: cursorX,
+        y: legendY - RADAR_LEGEND_SWATCH_SIZE / 2,
+        width: RADAR_LEGEND_SWATCH_SIZE,
+        height: RADAR_LEGEND_SWATCH_SIZE,
+        fillStyle: "solid",
+        strokeColor: item.color,
+        roughness: ROUGHNESS.architect,
+        roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
+      }),
+    );
+
+    // label in default (black) color
+    legendElements.push(
+      newTextElement({
+        ...commonProps,
+        text: item.displayLabel,
+        originalText: item.label,
+        autoResize: false,
+        x: cursorX + RADAR_LEGEND_SWATCH_SIZE + RADAR_LEGEND_TEXT_GAP,
+        y: legendY,
+        fontFamily,
+        fontSize,
+        lineHeight,
+        textAlign: "left",
+        verticalAlign: "middle",
+      }),
+    );
+
+    cursorX += item.width + RADAR_LEGEND_ITEM_GAP;
+  });
+
+  return legendElements;
+};
+
+const ellipsifyTextToWidth = (
+  text: string,
+  maxWidth: number,
+  fontString: ReturnType<typeof getFontString>,
+  lineHeight: ExcalidrawTextElement["lineHeight"],
+) => {
+  if (measureText(text, fontString, lineHeight).width <= maxWidth) {
+    return text;
+  }
+
+  let end = text.length;
+  while (end > 1) {
+    const candidate = `${text.slice(0, end)}...`;
+    if (measureText(candidate, fontString, lineHeight).width <= maxWidth) {
+      return candidate;
+    }
+    end--;
+  }
+
+  return text[0] ? `${text[0]}...` : text;
+};
+
+const wrapOrEllipsifyTextToWidth = (
+  text: string,
+  maxWidth: number,
+  fontString: ReturnType<typeof getFontString>,
+  lineHeight: ExcalidrawTextElement["lineHeight"],
+) => {
+  if (measureText(text, fontString, lineHeight).width <= maxWidth) {
+    return { wrapped: false, text };
+  }
+
+  const words = text.trim().split(/\s+/).filter(Boolean);
+  if (words.length > 1) {
+    const hasLongWord = words.some((word) => {
+      return measureText(word, fontString, lineHeight).width > maxWidth;
+    });
+    if (
+      !hasLongWord &&
+      maxWidth >= getApproxMinLineWidth(fontString, lineHeight)
+    ) {
+      return { wrapped: true, text: wrapText(text, fontString, maxWidth) };
+    }
+  }
+
+  return {
+    wrapped: false,
+    text: ellipsifyTextToWidth(text, maxWidth, fontString, lineHeight),
+  };
+};
+
+const getRotatedBoundingBox = (
+  width: number,
+  height: number,
+  angle: number,
+) => {
+  const cos = Math.abs(Math.cos(angle));
+  const sin = Math.abs(Math.sin(angle));
+  return {
+    width: width * cos + height * sin,
+    height: width * sin + height * cos,
+  };
+};
+
+type CartesianAxisLabelSpec = {
+  originalText: string;
+  text: string;
+  wrapped: boolean;
+  metrics: ReturnType<typeof measureText>;
+  rotatedWidth: number;
+  rotatedHeight: number;
+};
+
+const isEllipsifiedLabel = (text: string) => text.includes("...");
+
+const getCartesianAxisLabelSpec = (
+  label: string,
+  maxLabelWidth: number,
+  maxRotatedWidth: number,
+  fontString: ReturnType<typeof getFontString>,
+  lineHeight: ExcalidrawTextElement["lineHeight"],
+): CartesianAxisLabelSpec => {
+  const minWidth = Math.max(
+    CARTESIAN_LABEL_MIN_WIDTH,
+    Math.ceil(getApproxMinLineWidth(fontString, lineHeight)),
+  );
+  const maxWidth = Math.max(minWidth, Math.floor(maxLabelWidth));
+  const candidateWidths: number[] = [];
+  for (let width = maxWidth; width >= minWidth; width -= 4) {
+    candidateWidths.push(width);
+  }
+  if (candidateWidths[candidateWidths.length - 1] !== minWidth) {
+    candidateWidths.push(minWidth);
+  }
+
+  const getRank = (spec: CartesianAxisLabelSpec) => {
+    const ellipsified = isEllipsifiedLabel(spec.text);
+    const visibleChars = spec.text
+      .replace(/\.\.\./g, "")
+      .replace(/\n/g, "").length;
+    const lineCount = spec.text.split("\n").length;
+    return {
+      ellipsified,
+      visibleChars,
+      lineCount,
+    };
+  };
+
+  const shouldPrefer = (
+    candidate: CartesianAxisLabelSpec,
+    current: CartesianAxisLabelSpec,
+  ) => {
+    const candidateRank = getRank(candidate);
+    const currentRank = getRank(current);
+    if (candidateRank.ellipsified !== currentRank.ellipsified) {
+      return !candidateRank.ellipsified;
+    }
+    if (candidateRank.visibleChars !== currentRank.visibleChars) {
+      return candidateRank.visibleChars > currentRank.visibleChars;
+    }
+    if (candidateRank.lineCount !== currentRank.lineCount) {
+      return candidateRank.lineCount < currentRank.lineCount;
+    }
+    return candidate.rotatedHeight < current.rotatedHeight;
+  };
+
+  let bestFit: CartesianAxisLabelSpec | null = null;
+  let bestOverflowAny: {
+    overflow: number;
+    spec: CartesianAxisLabelSpec;
+  } | null = null;
+  let bestOverflowNonEllipsified: {
+    overflow: number;
+    spec: CartesianAxisLabelSpec;
+  } | null = null;
+
+  for (const width of candidateWidths) {
+    const { wrapped, text } = wrapOrEllipsifyTextToWidth(
+      label,
+      width,
+      fontString,
+      lineHeight,
+    );
+    const metrics = measureText(text, fontString, lineHeight);
+    const rotated = getRotatedBoundingBox(
+      metrics.width,
+      metrics.height,
+      CARTESIAN_LABEL_ROTATION,
+    );
+    const spec = {
+      originalText: label,
+      text,
+      metrics,
+      rotatedWidth: rotated.width,
+      rotatedHeight: rotated.height,
+      wrapped,
+    };
+    const overflow = rotated.width - maxRotatedWidth;
+    if (overflow <= 0) {
+      if (!bestFit || shouldPrefer(spec, bestFit)) {
+        bestFit = spec;
+      }
+      continue;
+    }
+    if (
+      !bestOverflowAny ||
+      overflow < bestOverflowAny.overflow ||
+      (overflow === bestOverflowAny.overflow &&
+        shouldPrefer(spec, bestOverflowAny.spec))
+    ) {
+      bestOverflowAny = { overflow, spec };
+    }
+    if (
+      !isEllipsifiedLabel(spec.text) &&
+      (!bestOverflowNonEllipsified ||
+        overflow < bestOverflowNonEllipsified.overflow ||
+        (overflow === bestOverflowNonEllipsified.overflow &&
+          shouldPrefer(spec, bestOverflowNonEllipsified.spec)))
+    ) {
+      bestOverflowNonEllipsified = { overflow, spec };
+    }
+  }
+
+  if (bestFit) {
+    return bestFit;
+  }
+
+  if (
+    bestOverflowNonEllipsified &&
+    bestOverflowAny &&
+    bestOverflowNonEllipsified.overflow <=
+      bestOverflowAny.overflow + CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER
+  ) {
+    return bestOverflowNonEllipsified.spec;
+  }
+
+  return bestOverflowAny!.spec;
+};
+
+export const getRotatedTextElementBottom = (
+  element: NonDeletedExcalidrawElement,
+) => {
+  if (element.type !== "text") {
+    return element.y + element.height;
+  }
+  const rotated = getRotatedBoundingBox(
+    element.width,
+    element.height,
+    element.angle,
+  );
+  return element.y + element.height / 2 + rotated.height / 2;
+};
+
+export const chartXLabels = (
+  spreadsheet: Spreadsheet,
+  x: number,
+  y: number,
+  backgroundColor: string,
+  layout: CartesianChartLayout,
+): ChartElements => {
+  const fontFamily = commonProps.fontFamily;
+  const fontSize = FONT_SIZES.sm;
+  const lineHeight = getLineHeight(fontFamily);
+  const fontString = getFontString({ fontFamily, fontSize });
+  const maxRotatedWidth = Math.max(
+    1,
+    layout.slotWidth +
+      layout.gap -
+      CARTESIAN_LABEL_SLOT_PADDING * 2 +
+      CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER,
+  );
+  const axisY = y;
+
+  return (
+    spreadsheet.labels?.map((label, index) => {
+      const labelSpec = getCartesianAxisLabelSpec(
+        label,
+        layout.xLabelMaxWidth,
+        maxRotatedWidth,
+        fontString,
+        lineHeight,
+      );
+      const centerX =
+        x +
+        index * (layout.slotWidth + layout.gap) +
+        layout.gap +
+        layout.slotWidth / 2;
+      const labelY =
+        axisY +
+        CARTESIAN_LABEL_AXIS_CLEARANCE +
+        (labelSpec.rotatedHeight - labelSpec.metrics.height) / 2;
+
+      return newTextElement({
+        backgroundColor,
+        ...commonProps,
+        text: labelSpec.text,
+        originalText: labelSpec.wrapped ? label : labelSpec.text,
+        autoResize: !labelSpec.wrapped,
+        x: centerX,
+        y: labelY,
+        angle: CARTESIAN_LABEL_ROTATION,
+        fontSize,
+        lineHeight,
+        textAlign: "center",
+        verticalAlign: "top",
+      });
+    }) || []
+  );
+};
+
+const chartYLabels = (
+  spreadsheet: Spreadsheet,
+  x: number,
+  y: number,
+  backgroundColor: string,
+  layout: CartesianChartLayout,
+  maxValue = Math.max(...spreadsheet.series[0].values),
+): ChartElements => {
+  const minYLabel = newTextElement({
+    backgroundColor,
+    ...commonProps,
+    x: x - layout.gap,
+    y: y - layout.gap,
+    text: "0",
+    textAlign: "right",
+  });
+
+  const maxYLabel = newTextElement({
+    backgroundColor,
+    ...commonProps,
+    x: x - layout.gap,
+    y: y - layout.chartHeight - minYLabel.height / 2,
+    text: maxValue.toLocaleString(),
+    textAlign: "right",
+  });
+
+  return [minYLabel, maxYLabel];
+};
+
+const chartLines = (
+  spreadsheet: Spreadsheet,
+  x: number,
+  y: number,
+  backgroundColor: string,
+  layout: CartesianChartLayout,
+): ChartElements => {
+  const { chartWidth, chartHeight } = getChartDimensions(spreadsheet, layout);
+  const xLine = newLinearElement({
+    backgroundColor,
+    ...commonProps,
+    type: "line",
+    x,
+    y,
+    width: chartWidth,
+    points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
+  });
+
+  const yLine = newLinearElement({
+    backgroundColor,
+    ...commonProps,
+    type: "line",
+    x,
+    y,
+    height: chartHeight,
+    points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
+  });
+
+  const maxLine = newLinearElement({
+    backgroundColor,
+    ...commonProps,
+    type: "line",
+    x,
+    y: y - layout.chartHeight - layout.gap,
+    strokeStyle: "dotted",
+    width: chartWidth,
+    opacity: GRID_OPACITY,
+    points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
+  });
+
+  return [xLine, yLine, maxLine];
+};
+
+// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
+export const chartBaseElements = (
+  spreadsheet: Spreadsheet,
+  x: number,
+  y: number,
+  backgroundColor: string,
+  layout: CartesianChartLayout,
+  maxValue = Math.max(...spreadsheet.series[0].values),
+  debug?: boolean,
+): ChartElements => {
+  const { chartWidth, chartHeight } = getChartDimensions(spreadsheet, layout);
+
+  const title = spreadsheet.title
+    ? newTextElement({
+        backgroundColor,
+        ...commonProps,
+        text: spreadsheet.title,
+        x: x + chartWidth / 2,
+        y: y - layout.chartHeight - layout.gap * 2 - DEFAULT_FONT_SIZE,
+        roundness: null,
+        textAlign: "center",
+        fontSize: FONT_SIZES.xl,
+        fontFamily: FONT_FAMILY["Lilita One"],
+      })
+    : null;
+
+  const debugRect = debug
+    ? newElement({
+        backgroundColor,
+        ...commonProps,
+        type: "rectangle",
+        x,
+        y: y - chartHeight,
+        width: chartWidth,
+        height: chartHeight,
+        strokeColor: COLOR_PALETTE.black,
+        fillStyle: "solid",
+        opacity: 6,
+      })
+    : null;
+
+  return [
+    ...(debugRect ? [debugRect] : []),
+    ...(title ? [title] : []),
+    ...chartXLabels(spreadsheet, x, y, backgroundColor, layout),
+    ...chartYLabels(spreadsheet, x, y, backgroundColor, layout, maxValue),
+    ...chartLines(spreadsheet, x, y, backgroundColor, layout),
+  ];
+};

+ 130 - 0
packages/excalidraw/charts/charts.line.ts

@@ -0,0 +1,130 @@
+import { pointFrom } from "@excalidraw/math";
+
+import { isDevEnv } from "@excalidraw/common";
+
+import { newElement, newLinearElement } from "@excalidraw/element";
+
+import type { LocalPoint } from "@excalidraw/math";
+
+import { GRID_OPACITY, commonProps } from "./charts.constants";
+import {
+  chartBaseElements,
+  chartXLabels,
+  createSeriesLegend,
+  getBackgroundColor,
+  getCartesianChartLayout,
+  getChartDimensions,
+  getColorOffset,
+  getRotatedTextElementBottom,
+  getSeriesColors,
+} from "./charts.helpers";
+
+import type { ChartElements, Spreadsheet } from "./charts.types";
+
+export const renderLineChart = (
+  spreadsheet: Spreadsheet,
+  x: number,
+  y: number,
+  colorSeed?: number,
+): ChartElements => {
+  const series = spreadsheet.series;
+  const layout = getCartesianChartLayout("line", series.length);
+  const max = Math.max(1, ...series.flatMap((seriesData) => seriesData.values));
+  const colorOffset = getColorOffset(colorSeed);
+  const backgroundColor = getBackgroundColor(colorOffset);
+  const seriesColors = getSeriesColors(series.length, colorOffset);
+
+  const lines = series.map((seriesData, seriesIndex) => {
+    const points = seriesData.values.map((value, valueIndex) =>
+      pointFrom<LocalPoint>(
+        valueIndex * (layout.slotWidth + layout.gap),
+        -(value / max) * layout.chartHeight,
+      ),
+    );
+
+    const maxX = Math.max(...points.map((point) => point[0]));
+    const maxY = Math.max(...points.map((point) => point[1]));
+    const minX = Math.min(...points.map((point) => point[0]));
+    const minY = Math.min(...points.map((point) => point[1]));
+
+    return newLinearElement({
+      backgroundColor: "transparent",
+      ...commonProps,
+      type: "line",
+      x: x + layout.gap + layout.slotWidth / 2,
+      y: y - layout.gap,
+      height: maxY - minY,
+      width: maxX - minX,
+      strokeColor: seriesColors[seriesIndex],
+      strokeWidth: 2,
+      points,
+    });
+  });
+
+  const dots = series.flatMap((seriesData, seriesIndex) =>
+    seriesData.values.map((value, valueIndex) => {
+      const cx = valueIndex * (layout.slotWidth + layout.gap) + layout.gap / 2;
+      const cy = -(value / max) * layout.chartHeight + layout.gap / 2;
+      return newElement({
+        backgroundColor: seriesColors[seriesIndex],
+        ...commonProps,
+        fillStyle: "solid",
+        strokeColor: seriesColors[seriesIndex],
+        strokeWidth: 2,
+        type: "ellipse",
+        x: x + cx + layout.slotWidth / 2,
+        y: y + cy - layout.gap * 2,
+        width: layout.gap,
+        height: layout.gap,
+      });
+    }),
+  );
+
+  const guideValues = series[0].values.map((_, valueIndex) =>
+    Math.max(
+      0,
+      ...series.map((seriesData) => seriesData.values[valueIndex] ?? 0),
+    ),
+  );
+  const guides = guideValues.map((value, valueIndex) => {
+    const cx = valueIndex * (layout.slotWidth + layout.gap) + layout.gap / 2;
+    const cy = (value / max) * layout.chartHeight + layout.gap / 2 + layout.gap;
+    return newLinearElement({
+      backgroundColor,
+      ...commonProps,
+      type: "line",
+      x: x + cx + layout.slotWidth / 2 + layout.gap / 2,
+      y: y - cy,
+      height: cy,
+      strokeStyle: "dotted",
+      opacity: GRID_OPACITY,
+      points: [pointFrom(0, 0), pointFrom(0, cy)],
+    });
+  });
+
+  const baseElements = chartBaseElements(
+    spreadsheet,
+    x,
+    y,
+    backgroundColor,
+    layout,
+    max,
+    isDevEnv(),
+  );
+  const xLabels = chartXLabels(spreadsheet, x, y, backgroundColor, layout);
+  const xLabelsBottomY = Math.max(
+    y + layout.gap / 2,
+    ...xLabels.map((label) => getRotatedTextElementBottom(label)),
+  );
+  const { chartWidth } = getChartDimensions(spreadsheet, layout);
+  const seriesLegend = createSeriesLegend(
+    series,
+    seriesColors,
+    x + chartWidth / 2,
+    xLabelsBottomY,
+    y + layout.gap * 5,
+    backgroundColor,
+  );
+
+  return [...baseElements, ...lines, ...guides, ...dots, ...seriesLegend];
+};

+ 142 - 0
packages/excalidraw/charts/charts.parse.ts

@@ -0,0 +1,142 @@
+import { type ParseSpreadsheetResult } from "./charts.types";
+
+/**
+ * @private exported for testing
+ */
+export const tryParseNumber = (s: string): number | null => {
+  const match =
+    /^([-+]?)[$\u20AC\u00A3\u00A5\u20A9]?([-+]?)([\d.,]+)[%]?$/.exec(s);
+  if (!match) {
+    return null;
+  }
+  return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
+};
+
+const isNumericColumn = (lines: string[][], columnIndex: number) =>
+  lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
+
+/**
+ * @private exported for testing
+ */
+export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
+  const numCols = cells[0].length;
+
+  if (numCols > 2) {
+    const hasHeader = cells[0].every((cell) => tryParseNumber(cell) === null);
+    const rows = hasHeader ? cells.slice(1) : cells;
+
+    if (rows.length < 2) {
+      return { ok: false, reason: "Less than 2 rows" };
+    }
+
+    const invalidNumericColumn = rows.some((row) =>
+      row.slice(1).some((value) => tryParseNumber(value) === null),
+    );
+    if (invalidNumericColumn) {
+      return { ok: false, reason: "Value is not numeric" };
+    }
+
+    const series = cells[0].slice(1).map((seriesTitle, index) => {
+      const valueColumnIndex = index + 1;
+      const fallbackTitle = `Series ${valueColumnIndex}`;
+      return {
+        title: hasHeader ? seriesTitle.trim() || fallbackTitle : fallbackTitle,
+        values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
+      };
+    });
+
+    return {
+      ok: true,
+      data: {
+        title: hasHeader ? cells[0][0].trim() || null : null,
+        labels: rows.map((row) => row[0]),
+        series,
+      },
+    };
+  }
+
+  if (numCols === 1) {
+    if (!isNumericColumn(cells, 0)) {
+      return { ok: false, reason: "Value is not numeric" };
+    }
+
+    const hasHeader = tryParseNumber(cells[0][0]) === null;
+    const title = hasHeader ? cells[0][0] : null;
+    const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
+      tryParseNumber(line[0]),
+    );
+
+    if (values.length < 2) {
+      return { ok: false, reason: "Less than two rows" };
+    }
+
+    return {
+      ok: true,
+      data: {
+        title,
+        labels: null,
+        series: [{ title, values: values as number[] }],
+      },
+    };
+  }
+
+  const hasHeader = tryParseNumber(cells[0][1]) === null;
+  const rows = hasHeader ? cells.slice(1) : cells;
+
+  if (rows.length < 2) {
+    return { ok: false, reason: "Less than 2 rows" };
+  }
+
+  const invalidNumericColumn = rows.some(
+    (row) => tryParseNumber(row[1]) === null,
+  );
+  if (invalidNumericColumn) {
+    return { ok: false, reason: "Value is not numeric" };
+  }
+
+  const title = hasHeader ? cells[0][1] : null;
+
+  return {
+    ok: true,
+    data: {
+      title,
+      labels: rows.map((row) => row[0]),
+      series: [{ title, values: rows.map((row) => tryParseNumber(row[1])!) }],
+    },
+  };
+};
+
+export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
+  // Copy/paste from excel, spreadsheets, TSV, CSV.
+  const parseDelimitedLines = (delimiter: "\t" | ",") =>
+    text
+      .replace(/\r\n?/g, "\n")
+      .split("\n")
+      .filter((line) => line.trim().length > 0)
+      .map((line) => line.split(delimiter).map((cell) => cell.trim()));
+
+  const tabSeparatedLines = parseDelimitedLines("\t");
+  const commaSeparatedLines = parseDelimitedLines(",");
+  const lines =
+    tabSeparatedLines.length &&
+    tabSeparatedLines[0].length === 1 &&
+    commaSeparatedLines[0]?.length > 1
+      ? commaSeparatedLines
+      : tabSeparatedLines;
+
+  if (lines.length === 0) {
+    return { ok: false, reason: "No values" };
+  }
+
+  const numColsFirstLine = lines[0].length;
+  const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
+
+  if (!isSpreadsheet) {
+    return {
+      ok: false,
+      reason: "All rows don't have same number of columns",
+    };
+  }
+
+  return tryParseCells(lines);
+};

+ 199 - 0
packages/excalidraw/charts/charts.radar.ts

@@ -0,0 +1,199 @@
+import { pointFrom } from "@excalidraw/math";
+
+import {
+  FONT_FAMILY,
+  FONT_SIZES,
+  getFontString,
+  getLineHeight,
+  ROUGHNESS,
+} from "@excalidraw/common";
+
+import {
+  measureText,
+  newLinearElement,
+  newTextElement,
+} from "@excalidraw/element";
+
+import type { LocalPoint } from "@excalidraw/math";
+
+import {
+  BAR_GAP,
+  BAR_HEIGHT,
+  GRID_OPACITY,
+  RADAR_GRID_LEVELS,
+  RADAR_LABEL_OFFSET,
+  commonProps,
+} from "./charts.constants";
+import {
+  createRadarAxisLabels,
+  createSeriesLegend,
+  getBackgroundColor,
+  getColorOffset,
+  getRadarDimensions,
+  getRadarDisplayText,
+  getRadarValueScale,
+  getSeriesColors,
+  isSpreadsheetValidForChartType,
+} from "./charts.helpers";
+
+import type { ChartElements, Spreadsheet } from "./charts.types";
+
+export const renderRadarChart = (
+  spreadsheet: Spreadsheet,
+  x: number,
+  y: number,
+  colorSeed?: number,
+): ChartElements | null => {
+  if (!isSpreadsheetValidForChartType(spreadsheet, "radar")) {
+    return null;
+  }
+
+  const labels =
+    spreadsheet.labels ??
+    spreadsheet.series[0].values.map((_, index) => `Value ${index + 1}`);
+
+  const series = spreadsheet.series;
+  const { normalize, renderSteps } = getRadarValueScale(series, labels.length);
+  const colorOffset = getColorOffset(colorSeed);
+  const backgroundColor = getBackgroundColor(colorOffset);
+  const seriesColors = getSeriesColors(series.length, colorOffset);
+  const { chartWidth, chartHeight } = getRadarDimensions();
+  const centerX = x + chartWidth / 2;
+  const centerY = y - chartHeight / 2;
+  const radius = BAR_HEIGHT / 2;
+  const angles = labels.map(
+    (_, index) => -Math.PI / 2 + (Math.PI * 2 * index) / labels.length,
+  );
+
+  const { axisLabels, axisLabelTopY, axisLabelBottomY } = createRadarAxisLabels(
+    labels,
+    angles,
+    centerX,
+    centerY,
+    radius,
+    backgroundColor,
+  );
+
+  const titleFontFamily = FONT_FAMILY["Lilita One"];
+  const titleFontSize = FONT_SIZES.xl;
+  const titleLineHeight = getLineHeight(titleFontFamily);
+  const titleFontString = getFontString({
+    fontFamily: titleFontFamily,
+    fontSize: titleFontSize,
+  });
+  const titleText = spreadsheet.title
+    ? getRadarDisplayText(
+        spreadsheet.title,
+        titleFontString,
+        chartWidth + RADAR_LABEL_OFFSET * 2,
+      )
+    : null;
+  const titleTextMetrics = titleText
+    ? measureText(titleText, titleFontString, titleLineHeight)
+    : null;
+  const title = titleText
+    ? newTextElement({
+        backgroundColor,
+        ...commonProps,
+        text: titleText,
+        originalText: spreadsheet.title ?? titleText,
+        x: x + chartWidth / 2,
+        y: axisLabelTopY - RADAR_LABEL_OFFSET - titleTextMetrics!.height / 2,
+        fontFamily: titleFontFamily,
+        fontSize: titleFontSize,
+        lineHeight: titleLineHeight,
+        textAlign: "center",
+      })
+    : null;
+
+  const radarGridLines = renderSteps
+    ? Array.from({ length: RADAR_GRID_LEVELS }, (_, levelIndex) => {
+        const levelRatio = (levelIndex + 1) / RADAR_GRID_LEVELS;
+        const levelRadius = radius * levelRatio;
+        const points = angles.map((angle) =>
+          pointFrom<LocalPoint>(
+            Math.cos(angle) * levelRadius,
+            Math.sin(angle) * levelRadius,
+          ),
+        );
+        points.push(pointFrom(points[0][0], points[0][1]));
+
+        return newLinearElement({
+          backgroundColor: "transparent",
+          ...commonProps,
+          type: "line",
+          x: centerX,
+          y: centerY,
+          width: levelRadius * 2,
+          height: levelRadius * 2,
+          strokeStyle: "solid",
+          roughness: ROUGHNESS.architect,
+          opacity: GRID_OPACITY,
+          polygon: true,
+          points,
+        });
+      })
+    : [];
+
+  const spokes = angles.map((angle) => {
+    const px = Math.cos(angle) * radius;
+    const py = Math.sin(angle) * radius;
+    return newLinearElement({
+      backgroundColor: "transparent",
+      ...commonProps,
+      type: "line",
+      x: centerX,
+      y: centerY,
+      width: Math.abs(px),
+      height: Math.abs(py),
+      strokeStyle: "solid",
+      roughness: ROUGHNESS.architect,
+      opacity: GRID_OPACITY,
+      points: [pointFrom(0, 0), pointFrom(px, py)],
+    });
+  });
+
+  const seriesPolygons = series.map((seriesData, index) => {
+    const points = angles.map((angle, axisIndex) => {
+      const value = seriesData.values[axisIndex] ?? 0;
+      const pointRadius = normalize(value, axisIndex) * radius;
+      return pointFrom<LocalPoint>(
+        Math.cos(angle) * pointRadius,
+        Math.sin(angle) * pointRadius,
+      );
+    });
+    points.push(pointFrom(points[0][0], points[0][1]));
+
+    return newLinearElement({
+      backgroundColor: "transparent",
+      ...commonProps,
+      type: "line",
+      x: centerX,
+      y: centerY,
+      width: radius * 2,
+      height: radius * 2,
+      strokeColor: seriesColors[index],
+      strokeWidth: 2,
+      polygon: true,
+      points,
+    });
+  });
+
+  const seriesLegend = createSeriesLegend(
+    series,
+    seriesColors,
+    centerX,
+    axisLabelBottomY,
+    y + BAR_GAP * 5,
+    backgroundColor,
+  );
+
+  return [
+    ...(title ? [title] : []),
+    ...axisLabels,
+    ...radarGridLines,
+    ...spokes,
+    ...seriesPolygons,
+    ...seriesLegend,
+  ];
+};

+ 18 - 0
packages/excalidraw/charts/charts.types.ts

@@ -0,0 +1,18 @@
+import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
+
+export type ChartElements = readonly NonDeletedExcalidrawElement[];
+
+export interface Spreadsheet {
+  title: string | null;
+  labels: string[] | null;
+  series: SpreadsheetSeries[];
+}
+
+export interface SpreadsheetSeries {
+  title: string | null;
+  values: number[];
+}
+
+export type ParseSpreadsheetResult =
+  | { ok: false; reason: string }
+  | { ok: true; data: Spreadsheet };

+ 38 - 0
packages/excalidraw/charts/index.ts

@@ -0,0 +1,38 @@
+import type { ChartType } from "@excalidraw/element/types";
+
+import { renderBarChart } from "./charts.bar";
+import { renderLineChart } from "./charts.line";
+import {
+  tryParseCells,
+  tryParseNumber,
+  tryParseSpreadsheet,
+} from "./charts.parse";
+import { renderRadarChart } from "./charts.radar";
+
+import type { ChartElements, Spreadsheet } from "./charts.types";
+
+export {
+  type ParseSpreadsheetResult,
+  type Spreadsheet,
+  type SpreadsheetSeries,
+  type ChartElements,
+} from "./charts.types";
+
+export { isSpreadsheetValidForChartType } from "./charts.helpers";
+export { tryParseCells, tryParseNumber, tryParseSpreadsheet };
+
+export const renderSpreadsheet = (
+  chartType: ChartType,
+  spreadsheet: Spreadsheet,
+  x: number,
+  y: number,
+  colorSeed?: number,
+): ChartElements | null => {
+  if (chartType === "line") {
+    return renderLineChart(spreadsheet, x, y, colorSeed);
+  }
+  if (chartType === "radar") {
+    return renderRadarChart(spreadsheet, x, y, colorSeed);
+  }
+  return renderBarChart(spreadsheet, x, y, colorSeed);
+};

+ 0 - 63
packages/excalidraw/clipboard.test.ts

@@ -155,67 +155,4 @@ describe("parseClipboard()", () => {
       },
     ]);
   });
-
-  it("should parse spreadsheet from either text/plain and text/html", async () => {
-    let clipboardData;
-    // -------------------------------------------------------------------------
-    clipboardData = await parseClipboard(
-      await parseDataTransferEvent(
-        createPasteEvent({
-          types: {
-            "text/plain": `a	b
-            1	2
-            4	5
-            7	10`,
-          },
-        }),
-      ),
-    );
-    expect(clipboardData.spreadsheet).toEqual({
-      title: "b",
-      labels: ["1", "4", "7"],
-      values: [2, 5, 10],
-    });
-    // -------------------------------------------------------------------------
-    clipboardData = await parseClipboard(
-      await parseDataTransferEvent(
-        createPasteEvent({
-          types: {
-            "text/html": `a	b
-            1	2
-            4	5
-            7	10`,
-          },
-        }),
-      ),
-    );
-    expect(clipboardData.spreadsheet).toEqual({
-      title: "b",
-      labels: ["1", "4", "7"],
-      values: [2, 5, 10],
-    });
-    // -------------------------------------------------------------------------
-    clipboardData = await parseClipboard(
-      await parseDataTransferEvent(
-        createPasteEvent({
-          types: {
-            "text/html": `<html>
-            <body>
-            <!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment-->
-            </body>
-            </html>`,
-            "text/plain": `a	b
-            1	2
-            4	5
-            7	10`,
-          },
-        }),
-      ),
-    );
-    expect(clipboardData.spreadsheet).toEqual({
-      title: "b",
-      labels: ["1", "4", "7"],
-      values: [2, 5, 10],
-    });
-  });
 });

+ 0 - 28
packages/excalidraw/clipboard.ts

@@ -33,12 +33,8 @@ import {
   normalizeFile,
 } from "./data/blob";
 
-import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
-
 import type { FileSystemHandle } from "./data/filesystem";
 
-import type { Spreadsheet } from "./charts";
-
 import type { BinaryFiles } from "./types";
 
 type ElementsClipboard = {
@@ -50,7 +46,6 @@ type ElementsClipboard = {
 export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[];
 
 export interface ClipboardData {
-  spreadsheet?: Spreadsheet;
   elements?: readonly ExcalidrawElement[];
   files?: BinaryFiles;
   text?: string;
@@ -215,16 +210,6 @@ export const copyToClipboard = async (
   );
 };
 
-const parsePotentialSpreadsheet = (
-  text: string,
-): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
-  const result = tryParseSpreadsheet(text);
-  if (result.type === VALID_SPREADSHEET) {
-    return { spreadsheet: result.spreadsheet };
-  }
-  return null;
-};
-
 /** internal, specific to parsing paste events. Do not reuse. */
 function parseHTMLTree(el: ChildNode) {
   let result: PastedMixedContent = [];
@@ -551,19 +536,6 @@ export const parseClipboard = async (
     };
   }
 
-  try {
-    // if system clipboard contains spreadsheet, use it even though it's
-    // technically possible it's staler than in-app clipboard
-    const spreadsheetResult =
-      !isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value);
-
-    if (spreadsheetResult) {
-      return spreadsheetResult;
-    }
-  } catch (error: any) {
-    console.error(error);
-  }
-
   try {
     const systemClipboardData = JSON.parse(parsedEventData.value);
     const programmaticAPI =

+ 15 - 8
packages/excalidraw/components/App.tsx

@@ -424,6 +424,8 @@ import { EraserTrail } from "../eraser";
 
 import { getShortcutKey } from "../shortcut";
 
+import { tryParseSpreadsheet } from "../charts";
+
 import ConvertElementTypePopup, {
   getConversionTypeFromElements,
   convertElementTypePopupAtom,
@@ -3439,14 +3441,19 @@ class App extends React.Component<AppProps, AppState> {
     }
 
     // ------------------- Spreadsheet -------------------
-    if (data.spreadsheet && !isPlainPaste) {
-      this.setState({
-        pasteDialog: {
-          data: data.spreadsheet,
-          shown: true,
-        },
-      });
-      return;
+
+    if (!isPlainPaste && data.text) {
+      const result = tryParseSpreadsheet(data.text);
+      if (result.ok) {
+        this.setState({
+          openDialog: {
+            name: "charts",
+            data: result.data,
+            rawText: data.text,
+          },
+        });
+        return;
+      }
     }
 
     // ------------------- Images or SVG code -------------------

+ 4 - 4
packages/excalidraw/components/LayerUI.tsx

@@ -565,13 +565,13 @@ const LayerUI = ({
       <tunnels.OverwriteConfirmDialogTunnel.Out />
       {renderImageExportDialog()}
       {renderJSONExportDialog()}
-      {appState.pasteDialog.shown && (
+      {appState.openDialog?.name === "charts" && (
         <PasteChartDialog
-          setAppState={setAppState}
-          appState={appState}
+          data={appState.openDialog.data}
+          rawText={appState.openDialog.rawText}
           onClose={() =>
             setAppState({
-              pasteDialog: { shown: false, data: null },
+              openDialog: null,
             })
           }
         />

+ 75 - 15
packages/excalidraw/components/PasteChartDialog.scss

@@ -2,6 +2,40 @@
 
 .excalidraw {
   .PasteChartDialog {
+    .PasteChartDialog__title {
+      display: flex;
+      align-items: center;
+      gap: 0.5rem;
+    }
+
+    .PasteChartDialog__titleText {
+      min-width: 0;
+    }
+
+    .PasteChartDialog__reshuffleBtn {
+      margin-left: auto;
+      flex: 0 0 auto;
+      width: 1rem;
+      height: 1rem;
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      border-radius: 4px;
+      cursor: pointer;
+      color: var(--text-primary-color);
+      transition: transform 120ms ease, background-color 120ms ease,
+        color 120ms ease;
+      user-select: none;
+
+      &:hover {
+        color: $color-blue-6;
+      }
+
+      &:active {
+        transform: scale(0.94);
+      }
+    }
+
     @include isMobile {
       .Island {
         display: flex;
@@ -11,35 +45,61 @@
     .container {
       display: flex;
       align-items: center;
-      justify-content: space-around;
+      justify-content: center;
       flex-wrap: wrap;
+      gap: 1rem;
       @include isMobile {
         flex-direction: column;
         justify-content: center;
+        align-items: stretch;
       }
     }
     .ChartPreview {
-      margin: 8px;
-      text-align: center;
-      width: 192px;
-      height: 128px;
-      border-radius: 2px;
-      padding: 1px;
+      width: 260px;
+      min-height: 190px;
+      border-radius: 8px;
+      padding: 10px;
       border: 1px solid $color-gray-4;
       display: flex;
-      align-items: center;
-      justify-content: center;
+      flex-direction: column;
+      align-items: stretch;
+      justify-content: flex-start;
+      gap: 10px;
       background: transparent;
-      div {
-        display: inline-block;
+      .ChartPreview__canvas {
+        display: flex;
+        flex: 1;
+        align-items: center;
+        justify-content: center;
+        overflow: hidden;
+      }
+      .ChartPreview__label {
+        font-size: 0.875rem;
+        font-weight: 600;
+        line-height: 1;
+        text-align: center;
+        color: var(--text-primary-color);
       }
       svg {
-        max-height: 120px;
-        max-width: 186px;
+        max-height: 144px;
+        max-width: 100%;
       }
       &:hover {
-        padding: 0;
-        border: 2px solid $color-blue-5;
+        border-color: $color-blue-5;
+      }
+      &:active {
+        border-color: $color-blue-5;
+        box-shadow: 0 0 0 1px $color-blue-5;
+        transform: scale(0.98);
+      }
+      &:focus-visible {
+        border-color: $color-blue-5;
+        box-shadow: 0 0 0 1px $color-blue-5;
+      }
+
+      @include isMobile {
+        width: 100%;
+        min-height: 200px;
       }
     }
   }

+ 165 - 34
packages/excalidraw/components/PasteChartDialog.tsx

@@ -1,35 +1,57 @@
 import React, { useLayoutEffect, useRef, useState } from "react";
 
+import { newTextElement } from "@excalidraw/element";
+
 import type { ChartType } from "@excalidraw/element/types";
 
 import { trackEvent } from "../analytics";
-import { renderSpreadsheet } from "../charts";
+import { isSpreadsheetValidForChartType, renderSpreadsheet } from "../charts";
 import { t } from "../i18n";
 import { exportToSvg } from "../scene/export";
 
+import { useUIAppState } from "../context/ui-appState";
+
 import { useApp } from "./App";
 import { Dialog } from "./Dialog";
 
 import "./PasteChartDialog.scss";
 
+import { bucketFillIcon } from "./icons";
+
 import type { ChartElements, Spreadsheet } from "../charts";
-import type { UIAppState } from "../types";
+
+type OnPlainTextPaste = (rawText: string) => void;
 
 type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
 
+const getChartTypeLabel = (chartType: ChartType) => {
+  switch (chartType) {
+    case "bar":
+      return t("labels.chartType_bar");
+    case "line":
+      return t("labels.chartType_line");
+    case "radar":
+      return t("labels.chartType_radar");
+    default:
+      return chartType;
+  }
+};
+
 const ChartPreviewBtn = (props: {
   spreadsheet: Spreadsheet | null;
   chartType: ChartType;
-  selected: boolean;
+  colorSeed: number;
   onClick: OnInsertChart;
 }) => {
   const previewRef = useRef<HTMLDivElement | null>(null);
   const [chartElements, setChartElements] = useState<ChartElements | null>(
     null,
   );
+  const { theme } = useUIAppState();
 
   useLayoutEffect(() => {
     if (!props.spreadsheet) {
+      setChartElements(null);
       return;
     }
 
@@ -38,7 +60,13 @@ const ChartPreviewBtn = (props: {
       props.spreadsheet,
       0,
       0,
+      props.colorSeed,
     );
+    if (!elements) {
+      setChartElements(null);
+      previewRef.current?.replaceChildren();
+      return;
+    }
     setChartElements(elements);
     let svg: SVGSVGElement;
     const previewNode = previewRef.current!;
@@ -49,6 +77,7 @@ const ChartPreviewBtn = (props: {
         {
           exportBackground: false,
           viewBackgroundColor: "#fff",
+          exportWithDarkMode: theme === "dark",
         },
         null, // files
         {
@@ -58,42 +87,108 @@ const ChartPreviewBtn = (props: {
       svg.querySelector(".style-fonts")?.remove();
       previewNode.replaceChildren();
       previewNode.appendChild(svg);
-
-      if (props.selected) {
-        (previewNode.parentNode as HTMLDivElement).focus();
-      }
     })();
 
     return () => {
       previewNode.replaceChildren();
     };
-  }, [props.spreadsheet, props.chartType, props.selected]);
+  }, [props.spreadsheet, props.chartType, props.colorSeed, theme]);
+
+  const chartTypeLabel = getChartTypeLabel(props.chartType);
 
   return (
     <button
       type="button"
       className="ChartPreview"
+      aria-label={chartTypeLabel}
       onClick={() => {
         if (chartElements) {
           props.onClick(props.chartType, chartElements);
         }
       }}
     >
-      <div ref={previewRef} />
+      <div className="ChartPreview__canvas" ref={previewRef} />
+      <div className="ChartPreview__label">{chartTypeLabel}</div>
+    </button>
+  );
+};
+
+const PlainTextPreviewBtn = (props: {
+  rawText: string;
+  onClick: OnPlainTextPaste;
+}) => {
+  const previewRef = useRef<HTMLDivElement | null>(null);
+  const { theme } = useUIAppState();
+
+  useLayoutEffect(() => {
+    if (!props.rawText) {
+      return;
+    }
+
+    const textElement = newTextElement({
+      text: props.rawText,
+      x: 0,
+      y: 0,
+    });
+
+    const previewNode = previewRef.current!;
+
+    (async () => {
+      const svg = await exportToSvg(
+        [textElement],
+        {
+          exportBackground: false,
+          viewBackgroundColor: "#fff",
+          exportWithDarkMode: theme === "dark",
+        },
+        null,
+        {
+          skipInliningFonts: true,
+        },
+      );
+      svg.querySelector(".style-fonts")?.remove();
+      previewNode.replaceChildren();
+      previewNode.appendChild(svg);
+    })();
+
+    return () => {
+      previewNode.replaceChildren();
+    };
+  }, [props.rawText, theme]);
+
+  return (
+    <button
+      type="button"
+      className="ChartPreview"
+      aria-label={t("labels.chartType_plaintext")}
+      onClick={() => {
+        props.onClick(props.rawText);
+      }}
+    >
+      <div className="ChartPreview__canvas" ref={previewRef} />
+      <div className="ChartPreview__label">
+        {t("labels.chartType_plaintext")}
+      </div>
     </button>
   );
 };
 
 export const PasteChartDialog = ({
-  setAppState,
-  appState,
+  data,
+  rawText,
   onClose,
 }: {
-  appState: UIAppState;
+  data: Spreadsheet;
+  rawText: string;
   onClose: () => void;
-  setAppState: React.Component<any, UIAppState>["setState"];
 }) => {
-  const { onInsertElements } = useApp();
+  const { onInsertElements, focusContainer } = useApp();
+  const [colorSeed, setColorSeed] = useState(Math.random());
+
+  const handleReshuffleColors = React.useCallback(() => {
+    setColorSeed(Math.random());
+  }, []);
+
   const handleClose = React.useCallback(() => {
     if (onClose) {
       onClose();
@@ -103,36 +198,72 @@ export const PasteChartDialog = ({
   const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
     onInsertElements(elements);
     trackEvent("paste", "chart", chartType);
-    setAppState({
-      currentChartType: chartType,
-      pasteDialog: {
-        shown: false,
-        data: null,
-      },
+    onClose();
+    focusContainer();
+  };
+
+  const handlePlainTextClick = (rawText: string) => {
+    const textElement = newTextElement({
+      text: rawText,
+      x: 0,
+      y: 0,
     });
+    onInsertElements([textElement]);
+    trackEvent("paste", "chart", "plaintext");
+    onClose();
+    focusContainer();
   };
 
   return (
     <Dialog
-      size="small"
+      size="regular"
       onCloseRequest={handleClose}
-      title={t("labels.pasteCharts")}
+      title={
+        <div className="PasteChartDialog__title">
+          <div className="PasteChartDialog__titleText">
+            {t("labels.pasteCharts")}
+          </div>
+          <div
+            className="PasteChartDialog__reshuffleBtn"
+            onClick={handleReshuffleColors}
+            role="button"
+            tabIndex={0}
+            onKeyDown={(event) => {
+              if (event.key === "Enter" || event.key === " ") {
+                event.preventDefault();
+                handleReshuffleColors();
+              }
+            }}
+          >
+            {bucketFillIcon}
+          </div>
+        </div>
+      }
       className={"PasteChartDialog"}
       autofocus={false}
     >
       <div className={"container"}>
-        <ChartPreviewBtn
-          chartType="bar"
-          spreadsheet={appState.pasteDialog.data}
-          selected={appState.currentChartType === "bar"}
-          onClick={handleChartClick}
-        />
-        <ChartPreviewBtn
-          chartType="line"
-          spreadsheet={appState.pasteDialog.data}
-          selected={appState.currentChartType === "line"}
-          onClick={handleChartClick}
-        />
+        {(["bar", "line", "radar"] as const).map((chartType) => {
+          if (!isSpreadsheetValidForChartType(data, chartType)) {
+            return null;
+          }
+
+          return (
+            <ChartPreviewBtn
+              key={chartType}
+              chartType={chartType}
+              spreadsheet={data}
+              colorSeed={colorSeed}
+              onClick={handleChartClick}
+            />
+          );
+        })}
+        {rawText && (
+          <PlainTextPreviewBtn
+            rawText={rawText}
+            onClick={handlePlainTextClick}
+          />
+        )}
       </div>
     </Dialog>
   );

+ 6 - 0
packages/excalidraw/index.tsx

@@ -319,3 +319,9 @@ export { isElementLink } from "@excalidraw/element";
 export { setCustomTextMetricsProvider } from "@excalidraw/element";
 
 export { CommandPalette } from "./components/CommandPalette/CommandPalette";
+
+export {
+  renderSpreadsheet,
+  tryParseSpreadsheet,
+  isSpreadsheetValidForChartType,
+} from "./charts";

+ 4 - 0
packages/excalidraw/locales/en.json

@@ -3,6 +3,10 @@
     "paste": "Paste",
     "pasteAsPlaintext": "Paste as plaintext",
     "pasteCharts": "Paste charts",
+    "chartType_bar": "Bar chart",
+    "chartType_line": "Line chart",
+    "chartType_radar": "Radar chart",
+    "chartType_plaintext": "Plain text",
     "selectAll": "Select all",
     "multiSelect": "Add element to selection",
     "moveCanvas": "Move canvas",

+ 12 - 7
packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap

@@ -2,19 +2,24 @@
 
 exports[`tryParseSpreadsheet > works for numbers with comma in them 1`] = `
 {
-  "spreadsheet": {
+  "data": {
     "labels": [
       "Week 1",
       "Week 2",
       "Week 3",
     ],
-    "title": "Users",
-    "values": [
-      814,
-      10301,
-      4264,
+    "series": [
+      {
+        "title": "Users",
+        "values": [
+          814,
+          10301,
+          4264,
+        ],
+      },
     ],
+    "title": "Users",
   },
-  "type": "VALID_SPREADSHEET",
+  "ok": true,
 }
 `;

+ 0 - 85
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -889,7 +889,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
     "top": 40,
   },
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -951,10 +950,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
     "x": 0,
     "y": 0,
   },
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -1091,7 +1086,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -1150,10 +1144,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -1308,7 +1298,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -1367,10 +1356,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -1642,7 +1627,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -1701,10 +1685,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -1976,7 +1956,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -2035,10 +2014,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -2193,7 +2168,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -2252,10 +2226,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -2437,7 +2407,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -2496,10 +2465,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -2738,7 +2703,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -2797,10 +2761,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -3113,7 +3073,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "#a5d8ff",
@@ -3172,10 +3131,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -3609,7 +3564,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -3668,10 +3622,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -3935,7 +3885,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -3994,10 +3943,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -4261,7 +4206,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -4320,10 +4264,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -5549,7 +5489,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
     "top": -7,
   },
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -5608,10 +5547,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -6769,7 +6704,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
     "top": -7,
   },
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -6828,10 +6762,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -7707,7 +7637,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
     "top": -9,
   },
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -7769,10 +7698,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
     "x": 0,
     "y": 0,
   },
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -8710,7 +8635,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
     "top": -7,
   },
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -8769,10 +8693,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -9704,7 +9624,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
     "top": 90,
   },
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -9766,10 +9685,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
     "x": 0,
     "y": 0,
   },
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {

File diff suppressed because it is too large
+ 0 - 265
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap


+ 0 - 260
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -15,7 +15,6 @@ exports[`given element A and group of elements B and given both are selected whe
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -74,10 +73,6 @@ exports[`given element A and group of elements B and given both are selected whe
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -444,7 +439,6 @@ exports[`given element A and group of elements B and given both are selected whe
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -503,10 +497,6 @@ exports[`given element A and group of elements B and given both are selected whe
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -863,7 +853,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -922,10 +911,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -1432,7 +1417,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -1491,10 +1475,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -1642,7 +1622,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -1701,10 +1680,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -2029,7 +2004,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -2088,10 +2062,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -2277,7 +2247,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -2336,10 +2305,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -2460,7 +2425,6 @@ exports[`regression tests > can drag element that covers another element, while
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -2519,10 +2483,6 @@ exports[`regression tests > can drag element that covers another element, while
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -2788,7 +2748,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "#ffc9c9",
@@ -2847,10 +2806,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
   "openPopup": "elementStroke",
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -3046,7 +3001,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -3105,10 +3059,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -3290,7 +3240,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -3349,10 +3298,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -3529,7 +3474,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -3588,10 +3532,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -3790,7 +3730,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -3849,10 +3788,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -4107,7 +4042,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -4166,10 +4100,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -4546,7 +4476,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -4605,10 +4534,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -4832,7 +4757,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -4891,10 +4815,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -5111,7 +5031,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -5170,10 +5089,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -5322,7 +5237,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -5381,10 +5295,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -5525,7 +5435,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -5584,10 +5493,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -5921,7 +5826,6 @@ exports[`regression tests > drags selected elements from point inside common bou
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -5980,10 +5884,6 @@ exports[`regression tests > drags selected elements from point inside common bou
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -6221,7 +6121,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -6280,10 +6179,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -7012,7 +6907,6 @@ exports[`regression tests > given a group of selected elements with an element t
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -7071,10 +6965,6 @@ exports[`regression tests > given a group of selected elements with an element t
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -7349,7 +7239,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "#ffc9c9",
@@ -7408,10 +7297,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -7631,7 +7516,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -7690,10 +7574,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -7869,7 +7749,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -7928,10 +7807,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -8112,7 +7987,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -8171,10 +8045,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -8295,7 +8165,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -8354,10 +8223,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -8478,7 +8343,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -8537,10 +8401,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -8661,7 +8521,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -8720,10 +8579,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -8896,7 +8751,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -8955,10 +8809,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -9129,7 +8979,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -9188,10 +9037,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -9324,7 +9169,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -9383,10 +9227,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -9559,7 +9399,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -9618,10 +9457,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -9742,7 +9577,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -9801,10 +9635,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -9975,7 +9805,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -10034,10 +9863,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -10158,7 +9983,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -10217,10 +10041,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -10353,7 +10173,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -10412,10 +10231,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -10536,7 +10351,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -10595,10 +10409,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -11070,7 +10880,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -11129,10 +10938,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -11353,7 +11158,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -11412,10 +11216,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -11479,7 +11279,6 @@ exports[`regression tests > shift click on selected element should deselect it o
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -11538,10 +11337,6 @@ exports[`regression tests > shift click on selected element should deselect it o
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -11682,7 +11477,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -11741,10 +11535,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -12004,7 +11794,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -12063,10 +11852,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -12436,7 +12221,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -12495,10 +12279,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -13079,7 +12859,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -13141,10 +12920,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
     "x": 0,
     "y": 0,
   },
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -13208,7 +12983,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -13267,10 +13041,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -13842,7 +13612,6 @@ exports[`regression tests > switches from group of selected elements to another
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -13901,10 +13670,6 @@ exports[`regression tests > switches from group of selected elements to another
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -14184,7 +13949,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -14243,10 +14007,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -14451,7 +14211,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -14510,10 +14269,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -14577,7 +14332,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -14636,10 +14390,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -14944,7 +14694,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -15003,10 +14752,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
   "openPopup": null,
   "openSidebar": null,
   "originSnapOffset": null,
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {
@@ -15070,7 +14815,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -15132,10 +14876,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
     "x": 0,
     "y": 0,
   },
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {

+ 64 - 0
packages/excalidraw/tests/charts.test.tsx

@@ -10,4 +10,68 @@ Week 3${"\t"}4,264`,
     );
     expect(result).toMatchSnapshot();
   });
+
+  it("parses multi-series CSV for radar charts", () => {
+    const result = tryParseSpreadsheet(
+      `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`,
+    );
+
+    expect(result).toEqual({
+      ok: true,
+      data: {
+        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] },
+        ],
+      },
+    });
+  });
+
+  it("parses TSV with empty chart-name header cell", () => {
+    const result = tryParseSpreadsheet(
+      `\tDunk\tEgg
+Physical Strength\t10\t2
+Swordsmanship\t8\t1
+Political Instinct\t3\t9`,
+    );
+
+    expect(result).toEqual({
+      ok: true,
+      data: {
+        title: null,
+        labels: ["Physical Strength", "Swordsmanship", "Political Instinct"],
+        series: [
+          { title: "Dunk", values: [10, 8, 3] },
+          { title: "Egg", values: [2, 1, 9] },
+        ],
+      },
+    });
+  });
+
+  it("parses 2-row multi-series TSV without transposing", () => {
+    const result = tryParseSpreadsheet(
+      `Physical Strength\t10\t2
+Swordsmanship skill\t8\t1`,
+    );
+
+    expect(result).toEqual({
+      ok: true,
+      data: {
+        title: null,
+        labels: ["Physical Strength", "Swordsmanship skill"],
+        series: [
+          { title: "Series 1", values: [10, 8] },
+          { title: "Series 2", values: [2, 1] },
+        ],
+      },
+    });
+  });
 });

+ 2 - 12
packages/excalidraw/types.ts

@@ -20,7 +20,6 @@ import type {
   GroupId,
   ExcalidrawBindableElement,
   Arrowhead,
-  ChartType,
   FontFamilyValues,
   FileId,
   Theme,
@@ -381,7 +380,8 @@ export interface AppState {
     | { name: "ttd"; tab: "text-to-diagram" | "mermaid" }
     | { name: "commandPalette" }
     | { name: "settings" }
-    | { name: "elementLinkSelector"; sourceElementId: ExcalidrawElement["id"] };
+    | { name: "elementLinkSelector"; sourceElementId: ExcalidrawElement["id"] }
+    | { name: "charts"; data: Spreadsheet; rawText: string };
   /**
    * Reflects user preference for whether the default sidebar should be docked.
    *
@@ -423,16 +423,6 @@ export interface AppState {
     /** bitmap. Use `STATS_PANELS` bit values */
     panels: number;
   };
-  currentChartType: ChartType;
-  pasteDialog:
-    | {
-        shown: false;
-        data: null;
-      }
-    | {
-        shown: true;
-        data: Spreadsheet;
-      };
   showHyperlinkPopup: false | "info" | "editor";
   selectedLinearElement: LinearElementEditor | null;
   snapLines: readonly SnapLine[];

+ 0 - 5
packages/utils/tests/__snapshots__/export.test.ts.snap

@@ -15,7 +15,6 @@ exports[`exportToSvg > with default arguments 1`] = `
   "collaborators": Map {},
   "contextMenu": null,
   "croppingElementId": null,
-  "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
@@ -75,10 +74,6 @@ exports[`exportToSvg > with default arguments 1`] = `
     "x": 0,
     "y": 0,
   },
-  "pasteDialog": {
-    "data": null,
-    "shown": false,
-  },
   "penDetected": false,
   "penMode": false,
   "preferredSelectionTool": {

Some files were not shown because too many files changed in this diff