aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/charts.ts
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/charts.ts
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/charts.ts')
-rw-r--r--packages/excalidraw/charts.ts473
1 files changed, 473 insertions, 0 deletions
diff --git a/packages/excalidraw/charts.ts b/packages/excalidraw/charts.ts
new file mode 100644
index 0000000..65d17e2
--- /dev/null
+++ b/packages/excalidraw/charts.ts
@@ -0,0 +1,473 @@
+import type { Radians } from "@excalidraw/math";
+import { pointFrom } from "@excalidraw/math";
+import {
+ COLOR_PALETTE,
+ DEFAULT_CHART_COLOR_INDEX,
+ getAllColorsSpecificShade,
+} from "./colors";
+import {
+ DEFAULT_FONT_FAMILY,
+ DEFAULT_FONT_SIZE,
+ VERTICAL_ALIGN,
+} from "./constants";
+import { newElement, newLinearElement, newTextElement } from "./element";
+import type { NonDeletedExcalidrawElement } from "./element/types";
+import { randomId } from "./random";
+
+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: 16,
+ 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,
+ import.meta.env.DEV,
+ ),
+ ];
+};
+
+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,
+ import.meta.env.DEV,
+ ),
+ 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);
+};