Przeglądaj źródła

Support CSV graphs and improve the look and feel (#2495)

Co-authored-by: David Luzar <luzar.david@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
Lipis 4 lat temu
rodzic
commit
b2d442abce
8 zmienionych plików z 151 dodań i 152 usunięć
  1. 1 0
      src/analytics.ts
  2. 134 126
      src/charts.ts
  3. 1 8
      src/clipboard.ts
  4. 1 1
      src/components/App.tsx
  5. 2 1
      src/element/newElement.ts
  6. 11 11
      src/element/types.ts
  7. 0 4
      src/locales/en.json
  8. 1 1
      src/types.ts

+ 1 - 0
src/analytics.ts

@@ -9,6 +9,7 @@ export const EVENT_LIBRARY = "library";
 export const EVENT_LOAD = "load";
 export const EVENT_SHAPE = "shape";
 export const EVENT_SHARE = "share";
+export const EVENT_MAGIC = "magic";
 
 export const trackEvent = window.gtag
   ? (category: string, name: string, label?: string, value?: number) => {

+ 134 - 126
src/charts.ts

@@ -1,28 +1,26 @@
+import { EVENT_MAGIC, trackEvent } from "./analytics";
+import colors from "./colors";
+import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "./constants";
+import { newElement, newTextElement, newLinearElement } from "./element";
 import { ExcalidrawElement } from "./element/types";
-import { newElement, newTextElement } from "./element";
-import { AppState } from "./types";
-import { t } from "./i18n";
-import { DEFAULT_VERTICAL_ALIGN } from "./constants";
+import { randomId } from "./random";
+
+const BAR_WIDTH = 32;
+const BAR_GAP = 12;
+const BAR_HEIGHT = 256;
 
 export interface Spreadsheet {
-  yAxisLabel: string | null;
+  title: string | null;
   labels: string[] | null;
   values: number[];
 }
 
 export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
-export const MALFORMED_SPREADSHEET = "MALFORMED_SPREADSHEET";
 export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
 
 type ParseSpreadsheetResult =
-  | {
-      type: typeof NOT_SPREADSHEET;
-    }
-  | { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
-  | {
-      type: typeof MALFORMED_SPREADSHEET;
-      error: string;
-    };
+  | { type: typeof NOT_SPREADSHEET }
+  | { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
 
 const tryParseNumber = (s: string): number | null => {
   const match = /^[$€£¥₩]?([0-9]+(\.[0-9]+)?)$/.exec(s);
@@ -32,17 +30,14 @@ const tryParseNumber = (s: string): number | null => {
   return parseFloat(match[1]);
 };
 
-const isNumericColumn = (lines: string[][], columnIndex: number) => {
-  return lines
-    .slice(1)
-    .every((line) => tryParseNumber(line[columnIndex]) !== null);
-};
+const isNumericColumn = (lines: string[][], columnIndex: number) =>
+  lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
 
 const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
   const numCols = cells[0].length;
 
   if (numCols > 2) {
-    return { type: MALFORMED_SPREADSHEET, error: t("charts.tooManyColumns") };
+    return { type: NOT_SPREADSHEET };
   }
 
   if (numCols === 1) {
@@ -62,7 +57,7 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
     return {
       type: VALID_SPREADSHEET,
       spreadsheet: {
-        yAxisLabel: hasHeader ? cells[0][0] : null,
+        title: hasHeader ? cells[0][0] : null,
         labels: null,
         values: values as number[],
       },
@@ -72,10 +67,7 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
   const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
 
   if (!isNumericColumn(cells, valueColumnIndex)) {
-    return {
-      type: MALFORMED_SPREADSHEET,
-      error: t("charts.noNumericColumn"),
-    };
+    return { type: NOT_SPREADSHEET };
   }
 
   const labelColumnIndex = (valueColumnIndex + 1) % 2;
@@ -89,7 +81,7 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
   return {
     type: VALID_SPREADSHEET,
     spreadsheet: {
-      yAxisLabel: hasHeader ? cells[0][valueColumnIndex] : null,
+      title: hasHeader ? cells[0][valueColumnIndex] : null,
       labels: rows.map((row) => row[labelColumnIndex]),
       values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
     },
@@ -105,28 +97,35 @@ const transposeCells = (cells: string[][]) => {
     }
     nextCells.push(nextCellRow);
   }
-
   return nextCells;
 };
 
 export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
-  // copy/paste from excel, in-browser excel, and google sheets is tsv
-  // for now we only accept 2 columns with an optional header
-  const lines = text
+  // Copy/paste from excel, spreadhseets, tsv, csv.
+  // For now we only accept 2 columns with an optional header
+
+  // Check for tab separeted values
+  let lines = text
     .trim()
     .split("\n")
     .map((line) => line.trim().split("\t"));
 
+  // Check for comma separeted 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 };
   }
 
   const numColsFirstLine = lines[0].length;
-  const isASpreadsheet = lines.every(
-    (line) => line.length === numColsFirstLine,
-  );
+  const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
 
-  if (!isASpreadsheet) {
+  if (!isSpreadsheet) {
     return { type: NOT_SPREADSHEET };
   }
 
@@ -141,131 +140,140 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
   return result;
 };
 
-const BAR_WIDTH = 32;
-const BAR_SPACING = 12;
-const BAR_HEIGHT = 192;
-const LABEL_SPACING = 3 * BAR_SPACING;
-const Y_AXIS_LABEL_SPACING = LABEL_SPACING;
-const ANGLE = 5.87;
-
+// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
 export const renderSpreadsheet = (
-  appState: AppState,
   spreadsheet: Spreadsheet,
   x: number,
   y: number,
 ): ExcalidrawElement[] => {
-  const max = Math.max(...spreadsheet.values);
-  const min = Math.min(0, ...spreadsheet.values);
-  const range = max - min;
+  const values = spreadsheet.values;
+  const max = Math.max(...values);
+  const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
+  const chartWidth = (BAR_WIDTH + BAR_GAP) * values.length + BAR_GAP;
+  const maxColors = colors.elementBackground.length;
+  const bgColors = colors.elementBackground.slice(2, maxColors);
+
+  // Put all the common properties here so when the whole chart is selected
+  // the properties dialog shows the correct selected values
+  const commonProps = {
+    backgroundColor: bgColors[Math.floor(Math.random() * bgColors.length)],
+    fillStyle: "hachure",
+    fontFamily: DEFAULT_FONT_FAMILY,
+    fontSize: DEFAULT_FONT_SIZE,
+    groupIds: [randomId()],
+    opacity: 100,
+    roughness: 1,
+    strokeColor: colors.elementStroke[0],
+    strokeSharpness: "sharp",
+    strokeStyle: "solid",
+    strokeWidth: 1,
+    verticalAlign: "middle",
+  } as const;
 
   const minYLabel = newTextElement({
-    x,
-    y: y + BAR_HEIGHT,
-    strokeColor: appState.currentItemStrokeColor,
-    backgroundColor: appState.currentItemBackgroundColor,
-    fillStyle: appState.currentItemFillStyle,
-    strokeWidth: appState.currentItemStrokeWidth,
-    strokeStyle: appState.currentItemStrokeStyle,
-    roughness: appState.currentItemRoughness,
-    opacity: appState.currentItemOpacity,
-    strokeSharpness: appState.currentItemStrokeSharpness,
-    text: min.toLocaleString(),
-    fontSize: 16,
-    fontFamily: appState.currentItemFontFamily,
-    textAlign: appState.currentItemTextAlign,
-    verticalAlign: DEFAULT_VERTICAL_ALIGN,
+    ...commonProps,
+    x: x - BAR_GAP,
+    y: y - BAR_GAP,
+    text: "0",
+    textAlign: "right",
   });
 
   const maxYLabel = newTextElement({
+    ...commonProps,
+    x: x - BAR_GAP,
+    y: y - BAR_HEIGHT - minYLabel.height / 2,
+    text: max.toLocaleString(),
+    textAlign: "right",
+  });
+
+  const xAxisLine = newLinearElement({
+    type: "line",
     x,
     y,
-    strokeColor: appState.currentItemStrokeColor,
-    backgroundColor: appState.currentItemBackgroundColor,
-    fillStyle: appState.currentItemFillStyle,
-    strokeWidth: appState.currentItemStrokeWidth,
-    strokeStyle: appState.currentItemStrokeStyle,
-    roughness: appState.currentItemRoughness,
-    opacity: appState.currentItemOpacity,
-    strokeSharpness: appState.currentItemStrokeSharpness,
-    text: max.toLocaleString(),
-    fontSize: 16,
-    fontFamily: appState.currentItemFontFamily,
-    textAlign: appState.currentItemTextAlign,
-    verticalAlign: DEFAULT_VERTICAL_ALIGN,
+    startArrowhead: null,
+    endArrowhead: null,
+    points: [
+      [0, 0],
+      [chartWidth, 0],
+    ],
+    ...commonProps,
   });
 
-  const bars = spreadsheet.values.map((value, index) => {
-    const valueBarHeight = value - min;
-    const percentBarHeight = valueBarHeight / range;
-    const barHeight = percentBarHeight * BAR_HEIGHT;
-    const barX = index * (BAR_WIDTH + BAR_SPACING) + LABEL_SPACING;
-    const barY = BAR_HEIGHT - barHeight;
+  const yAxisLine = newLinearElement({
+    type: "line",
+    x,
+    y,
+    startArrowhead: null,
+    endArrowhead: null,
+    points: [
+      [0, 0],
+      [0, -chartHeight],
+    ],
+    ...commonProps,
+  });
+
+  const maxValueLine = newLinearElement({
+    type: "line",
+    x,
+    y: y - BAR_HEIGHT - BAR_GAP,
+    startArrowhead: null,
+    endArrowhead: null,
+    ...commonProps,
+    strokeStyle: "dotted",
+    points: [
+      [0, 0],
+      [chartWidth, 0],
+    ],
+  });
+
+  const bars = values.map((value, index) => {
+    const barHeight = (value / max) * BAR_HEIGHT;
     return newElement({
+      ...commonProps,
       type: "rectangle",
-      x: barX + x,
-      y: barY + y,
+      x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
+      y: y - barHeight - BAR_GAP,
       width: BAR_WIDTH,
       height: barHeight,
-      strokeColor: appState.currentItemStrokeColor,
-      backgroundColor: appState.currentItemBackgroundColor,
-      fillStyle: appState.currentItemFillStyle,
-      strokeWidth: appState.currentItemStrokeWidth,
-      strokeStyle: appState.currentItemStrokeStyle,
-      roughness: appState.currentItemRoughness,
-      opacity: appState.currentItemOpacity,
-      strokeSharpness: appState.currentItemStrokeSharpness,
     });
   });
 
   const xLabels =
     spreadsheet.labels?.map((label, index) => {
-      const labelX =
-        index * (BAR_WIDTH + BAR_SPACING) + LABEL_SPACING + BAR_SPACING;
-      const labelY = BAR_HEIGHT + BAR_SPACING;
       return newTextElement({
+        ...commonProps,
         text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
-        x: x + labelX,
-        y: y + labelY,
-        strokeColor: appState.currentItemStrokeColor,
-        backgroundColor: appState.currentItemBackgroundColor,
-        fillStyle: appState.currentItemFillStyle,
-        strokeWidth: appState.currentItemStrokeWidth,
-        strokeStyle: appState.currentItemStrokeStyle,
-        roughness: appState.currentItemRoughness,
-        opacity: appState.currentItemOpacity,
-        strokeSharpness: appState.currentItemStrokeSharpness,
+        x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
+        y: y + BAR_GAP / 2,
+        width: BAR_WIDTH,
+        angle: 5.87,
         fontSize: 16,
-        fontFamily: appState.currentItemFontFamily,
         textAlign: "center",
-        verticalAlign: DEFAULT_VERTICAL_ALIGN,
-        width: BAR_WIDTH,
-        angle: ANGLE,
+        verticalAlign: "top",
       });
     }) || [];
 
-  const yAxisLabel = spreadsheet.yAxisLabel
+  const title = spreadsheet.title
     ? newTextElement({
-        text: spreadsheet.yAxisLabel,
-        x: x - Y_AXIS_LABEL_SPACING,
-        y: y + BAR_HEIGHT / 2 - 10,
-        strokeColor: appState.currentItemStrokeColor,
-        backgroundColor: appState.currentItemBackgroundColor,
-        fillStyle: appState.currentItemFillStyle,
-        strokeWidth: appState.currentItemStrokeWidth,
-        strokeStyle: appState.currentItemStrokeStyle,
-        roughness: appState.currentItemRoughness,
-        opacity: appState.currentItemOpacity,
-        strokeSharpness: appState.currentItemStrokeSharpness,
-        fontSize: 20,
-        fontFamily: appState.currentItemFontFamily,
+        ...commonProps,
+        text: spreadsheet.title,
+        x: x + chartWidth / 2,
+        y: y - BAR_HEIGHT - BAR_GAP * 2 - maxYLabel.height,
+        strokeSharpness: "sharp",
+        strokeStyle: "solid",
         textAlign: "center",
-        verticalAlign: DEFAULT_VERTICAL_ALIGN,
-        width: BAR_WIDTH,
-        angle: ANGLE,
       })
     : null;
 
-  return [...bars, yAxisLabel, minYLabel, maxYLabel, ...xLabels].filter(
-    (element) => element !== null,
-  ) as ExcalidrawElement[];
+  trackEvent(EVENT_MAGIC, "chart", "bars", bars.length);
+  return [
+    title,
+    ...bars,
+    ...xLabels,
+    xAxisLine,
+    yAxisLine,
+    maxValueLine,
+    minYLabel,
+    maxYLabel,
+  ].filter((element) => element !== null) as ExcalidrawElement[];
 };

+ 1 - 8
src/clipboard.ts

@@ -5,12 +5,7 @@ import {
 import { getSelectedElements } from "./scene";
 import { AppState } from "./types";
 import { SVG_EXPORT_TAG } from "./scene/export";
-import {
-  tryParseSpreadsheet,
-  Spreadsheet,
-  VALID_SPREADSHEET,
-  MALFORMED_SPREADSHEET,
-} from "./charts";
+import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
 import { canvasToBlob } from "./data/blob";
 
 const TYPE_ELEMENTS = "excalidraw/elements";
@@ -82,8 +77,6 @@ const parsePotentialSpreadsheet = (
   const result = tryParseSpreadsheet(text);
   if (result.type === VALID_SPREADSHEET) {
     return { spreadsheet: result.spreadsheet };
-  } else if (result.type === MALFORMED_SPREADSHEET) {
-    return { errorMessage: result.error };
   }
   return null;
 };

+ 1 - 1
src/components/App.tsx

@@ -990,7 +990,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         this.setState({ errorMessage: data.errorMessage });
       } else if (data.spreadsheet) {
         this.addElementsFromPasteOrLibrary(
-          renderSpreadsheet(this.state, data.spreadsheet, cursorX, cursorY),
+          renderSpreadsheet(data.spreadsheet, cursorX, cursorY),
         );
       } else if (data.elements) {
         this.addElementsFromPasteOrLibrary(data.elements);

+ 2 - 1
src/element/newElement.ts

@@ -217,11 +217,12 @@ export const newLinearElement = (
     type: ExcalidrawLinearElement["type"];
     startArrowhead: Arrowhead | null;
     endArrowhead: Arrowhead | null;
+    points?: ExcalidrawLinearElement["points"];
   } & ElementConstructorOpts,
 ): NonDeleted<ExcalidrawLinearElement> => {
   return {
     ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
-    points: [],
+    points: opts.points || [],
     lastCommittedPoint: null,
     startBinding: null,
     endBinding: null,

+ 11 - 11
src/element/types.ts

@@ -1,7 +1,15 @@
 import { Point } from "../types";
 import { FONT_FAMILY } from "../constants";
 
+export type FillStyle = "hachure" | "cross-hatch" | "solid";
+export type FontFamily = keyof typeof FONT_FAMILY;
+export type FontString = string & { _brand: "fontString" };
 export type GroupId = string;
+export type PointerType = "mouse" | "pen" | "touch";
+export type StrokeSharpness = "round" | "sharp";
+export type StrokeStyle = "solid" | "dashed" | "dotted";
+export type TextAlign = "left" | "center" | "right";
+export type VerticalAlign = "top" | "middle";
 
 type _ExcalidrawElementBase = Readonly<{
   id: string;
@@ -9,10 +17,10 @@ type _ExcalidrawElementBase = Readonly<{
   y: number;
   strokeColor: string;
   backgroundColor: string;
-  fillStyle: string;
+  fillStyle: FillStyle;
   strokeWidth: number;
-  strokeStyle: "solid" | "dashed" | "dotted";
-  strokeSharpness: "round" | "sharp";
+  strokeStyle: StrokeStyle;
+  strokeSharpness: StrokeSharpness;
   roughness: number;
   opacity: number;
   width: number;
@@ -102,11 +110,3 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
     startArrowhead: Arrowhead | null;
     endArrowhead: Arrowhead | null;
   }>;
-
-export type PointerType = "mouse" | "pen" | "touch";
-
-export type TextAlign = "left" | "center" | "right";
-export type VerticalAlign = "top" | "middle";
-
-export type FontFamily = keyof typeof FONT_FAMILY;
-export type FontString = string & { _brand: "fontString" };

+ 0 - 4
src/locales/en.json

@@ -215,10 +215,6 @@
   "encrypted": {
     "tooltip": "Your drawings are end-to-end encrypted so Excalidraw's servers will never see them."
   },
-  "charts": {
-    "noNumericColumn": "You pasted a spreadsheet without a numeric column.",
-    "tooManyColumns": "You pasted a spreadsheet with more than two columns."
-  },
   "stats": {
     "angle": "Angle",
     "element": "Element",

+ 1 - 1
src/types.ts

@@ -52,7 +52,7 @@ export type AppState = {
   shouldAddWatermark: boolean;
   currentItemStrokeColor: string;
   currentItemBackgroundColor: string;
-  currentItemFillStyle: string;
+  currentItemFillStyle: ExcalidrawElement["fillStyle"];
   currentItemStrokeWidth: number;
   currentItemStrokeStyle: ExcalidrawElement["strokeStyle"];
   currentItemRoughness: number;