Browse Source

semicolon support & transpose if not enough rows

dwelle 2 days ago
parent
commit
10153d5aca
2 changed files with 131 additions and 12 deletions
  1. 44 12
      packages/excalidraw/charts/charts.parse.ts
  2. 87 0
      packages/excalidraw/tests/charts.test.tsx

+ 44 - 12
packages/excalidraw/charts/charts.parse.ts

@@ -25,8 +25,8 @@ export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
     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" };
+    if (rows.length < 1) {
+      return { ok: false, reason: "No data rows" };
     }
 
     const invalidNumericColumn = rows.some((row) =>
@@ -36,6 +36,28 @@ export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
       return { ok: false, reason: "Value is not numeric" };
     }
 
+    // When there are more value columns than data rows, the data is in
+    // "wide" format — transpose so columns become labels (dimensions)
+    // and rows become series. This enables e.g. radar charts for wide data.
+    const numValueCols = numCols - 1;
+    if (numValueCols > rows.length) {
+      const labels = hasHeader ? cells[0].slice(1).map((h) => h.trim()) : null;
+      const series = rows.map((row) => ({
+        title: row[0]?.trim() || null,
+        values: row.slice(1).map((v) => tryParseNumber(v)!),
+      }));
+      const title =
+        series.length === 1
+          ? series[0].title
+          : hasHeader
+          ? cells[0][0].trim() || null
+          : null;
+      return {
+        ok: true,
+        data: { title, labels, series },
+      };
+    }
+
     const series = cells[0].slice(1).map((seriesTitle, index) => {
       const valueColumnIndex = index + 1;
       const fallbackTitle = `Series ${valueColumnIndex}`;
@@ -107,22 +129,32 @@ export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
 };
 
 export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
-  // Copy/paste from excel, spreadsheets, TSV, CSV.
-  const parseDelimitedLines = (delimiter: "\t" | ",") =>
+  // Copy/paste from excel, spreadsheets, TSV, CSV, semicolon-separated.
+  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;
+  // Score each delimiter: prefer consistent column counts with the most columns.
+  // A delimiter that produces all single-column rows likely isn't the right one.
+  const candidates = (["\t", ",", ";"] as const).map((delimiter) => {
+    const parsed = parseDelimitedLines(delimiter);
+    const numCols = parsed[0]?.length ?? 0;
+    const isConsistent =
+      parsed.length > 0 && parsed.every((line) => line.length === numCols);
+    return { delimiter, parsed, numCols, isConsistent };
+  });
+
+  // Prefer: consistent + most columns. Among ties, tab > comma > semicolon
+  // (the array order already encodes this priority).
+  const best =
+    candidates.find((c) => c.isConsistent && c.numCols > 1) ??
+    candidates.find((c) => c.isConsistent) ??
+    candidates[0];
+
+  const lines = best.parsed;
 
   if (lines.length === 0) {
     return { ok: false, reason: "No values" };

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

@@ -74,4 +74,91 @@ Swordsmanship skill\t8\t1`,
       },
     });
   });
+
+  it("parses semicolon-separated values", () => {
+    const result = tryParseSpreadsheet(
+      `Metric;Player A;Player B
+Speed;80;60
+Strength;65;85
+Agility;90;70`,
+    );
+
+    expect(result).toEqual({
+      ok: true,
+      data: {
+        title: "Metric",
+        labels: ["Speed", "Strength", "Agility"],
+        series: [
+          { title: "Player A", values: [80, 65, 90] },
+          { title: "Player B", values: [60, 85, 70] },
+        ],
+      },
+    });
+  });
+
+  it("transposes wide data (more value cols than rows) into series-per-row", () => {
+    const result = tryParseSpreadsheet(
+      `trait,Dunk,Egg,Daeron
+Physical,10,2,7
+Mental,10,2,7`,
+    );
+
+    expect(result).toEqual({
+      ok: true,
+      data: {
+        title: "trait",
+        labels: ["Dunk", "Egg", "Daeron"],
+        series: [
+          { title: "Physical", values: [10, 2, 7] },
+          { title: "Mental", values: [10, 2, 7] },
+        ],
+      },
+    });
+  });
+
+  it("transposes single data row with header into single series", () => {
+    const result = tryParseSpreadsheet(
+      `trait,Dunk,Egg,Daeron
+Physical,10,2,7`,
+    );
+
+    expect(result).toEqual({
+      ok: true,
+      data: {
+        title: "Physical",
+        labels: ["Dunk", "Egg", "Daeron"],
+        series: [{ title: "Physical", values: [10, 2, 7] }],
+      },
+    });
+  });
+
+  it("transposes single data row without header into single series", () => {
+    const result = tryParseSpreadsheet(`Physical,10,2,7`);
+
+    expect(result).toEqual({
+      ok: true,
+      data: {
+        title: "Physical",
+        labels: null,
+        series: [{ title: "Physical", values: [10, 2, 7] }],
+      },
+    });
+  });
+
+  it("prefers tab over comma/semicolon when tabs produce multiple columns", () => {
+    const result = tryParseSpreadsheet(
+      `Label\tValue
+A\t10
+B\t20`,
+    );
+
+    expect(result).toEqual({
+      ok: true,
+      data: {
+        title: "Value",
+        labels: ["A", "B"],
+        series: [{ title: "Value", values: [10, 20] }],
+      },
+    });
+  });
 });