charts.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. import {
  2. COLOR_PALETTE,
  3. DEFAULT_CHART_COLOR_INDEX,
  4. getAllColorsSpecificShade,
  5. } from "./colors";
  6. import {
  7. DEFAULT_FONT_FAMILY,
  8. DEFAULT_FONT_SIZE,
  9. VERTICAL_ALIGN,
  10. } from "./constants";
  11. import { newElement, newLinearElement, newTextElement } from "./element";
  12. import type { NonDeletedExcalidrawElement } from "./element/types";
  13. import { randomId } from "./random";
  14. export type ChartElements = readonly NonDeletedExcalidrawElement[];
  15. const BAR_WIDTH = 32;
  16. const BAR_GAP = 12;
  17. const BAR_HEIGHT = 256;
  18. const GRID_OPACITY = 50;
  19. export interface Spreadsheet {
  20. title: string | null;
  21. labels: string[] | null;
  22. values: number[];
  23. }
  24. export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
  25. export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
  26. type ParseSpreadsheetResult =
  27. | { type: typeof NOT_SPREADSHEET; reason: string }
  28. | { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
  29. /**
  30. * @private exported for testing
  31. */
  32. export const tryParseNumber = (s: string): number | null => {
  33. const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
  34. if (!match) {
  35. return null;
  36. }
  37. return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
  38. };
  39. const isNumericColumn = (lines: string[][], columnIndex: number) =>
  40. lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
  41. /**
  42. * @private exported for testing
  43. */
  44. export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
  45. const numCols = cells[0].length;
  46. if (numCols > 2) {
  47. return { type: NOT_SPREADSHEET, reason: "More than 2 columns" };
  48. }
  49. if (numCols === 1) {
  50. if (!isNumericColumn(cells, 0)) {
  51. return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
  52. }
  53. const hasHeader = tryParseNumber(cells[0][0]) === null;
  54. const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
  55. tryParseNumber(line[0]),
  56. );
  57. if (values.length < 2) {
  58. return { type: NOT_SPREADSHEET, reason: "Less than two rows" };
  59. }
  60. return {
  61. type: VALID_SPREADSHEET,
  62. spreadsheet: {
  63. title: hasHeader ? cells[0][0] : null,
  64. labels: null,
  65. values: values as number[],
  66. },
  67. };
  68. }
  69. const labelColumnNumeric = isNumericColumn(cells, 0);
  70. const valueColumnNumeric = isNumericColumn(cells, 1);
  71. if (!labelColumnNumeric && !valueColumnNumeric) {
  72. return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
  73. }
  74. const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
  75. ? [0, 1]
  76. : [1, 0];
  77. const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
  78. const rows = hasHeader ? cells.slice(1) : cells;
  79. if (rows.length < 2) {
  80. return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" };
  81. }
  82. return {
  83. type: VALID_SPREADSHEET,
  84. spreadsheet: {
  85. title: hasHeader ? cells[0][valueColumnIndex] : null,
  86. labels: rows.map((row) => row[labelColumnIndex]),
  87. values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
  88. },
  89. };
  90. };
  91. const transposeCells = (cells: string[][]) => {
  92. const nextCells: string[][] = [];
  93. for (let col = 0; col < cells[0].length; col++) {
  94. const nextCellRow: string[] = [];
  95. for (let row = 0; row < cells.length; row++) {
  96. nextCellRow.push(cells[row][col]);
  97. }
  98. nextCells.push(nextCellRow);
  99. }
  100. return nextCells;
  101. };
  102. export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
  103. // Copy/paste from excel, spreadsheets, tsv, csv.
  104. // For now we only accept 2 columns with an optional header
  105. // Check for tab separated values
  106. let lines = text
  107. .trim()
  108. .split("\n")
  109. .map((line) => line.trim().split("\t"));
  110. // Check for comma separated files
  111. if (lines.length && lines[0].length !== 2) {
  112. lines = text
  113. .trim()
  114. .split("\n")
  115. .map((line) => line.trim().split(","));
  116. }
  117. if (lines.length === 0) {
  118. return { type: NOT_SPREADSHEET, reason: "No values" };
  119. }
  120. const numColsFirstLine = lines[0].length;
  121. const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
  122. if (!isSpreadsheet) {
  123. return {
  124. type: NOT_SPREADSHEET,
  125. reason: "All rows don't have same number of columns",
  126. };
  127. }
  128. const result = tryParseCells(lines);
  129. if (result.type !== VALID_SPREADSHEET) {
  130. const transposedResults = tryParseCells(transposeCells(lines));
  131. if (transposedResults.type === VALID_SPREADSHEET) {
  132. return transposedResults;
  133. }
  134. }
  135. return result;
  136. };
  137. const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX);
  138. // Put all the common properties here so when the whole chart is selected
  139. // the properties dialog shows the correct selected values
  140. const commonProps = {
  141. fillStyle: "hachure",
  142. fontFamily: DEFAULT_FONT_FAMILY,
  143. fontSize: DEFAULT_FONT_SIZE,
  144. opacity: 100,
  145. roughness: 1,
  146. strokeColor: COLOR_PALETTE.black,
  147. roundness: null,
  148. strokeStyle: "solid",
  149. strokeWidth: 1,
  150. verticalAlign: VERTICAL_ALIGN.MIDDLE,
  151. locked: false,
  152. } as const;
  153. const getChartDimensions = (spreadsheet: Spreadsheet) => {
  154. const chartWidth =
  155. (BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
  156. const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
  157. return { chartWidth, chartHeight };
  158. };
  159. const chartXLabels = (
  160. spreadsheet: Spreadsheet,
  161. x: number,
  162. y: number,
  163. groupId: string,
  164. backgroundColor: string,
  165. ): ChartElements => {
  166. return (
  167. spreadsheet.labels?.map((label, index) => {
  168. return newTextElement({
  169. groupIds: [groupId],
  170. backgroundColor,
  171. ...commonProps,
  172. text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
  173. x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
  174. y: y + BAR_GAP / 2,
  175. width: BAR_WIDTH,
  176. angle: 5.87,
  177. fontSize: 16,
  178. textAlign: "center",
  179. verticalAlign: "top",
  180. });
  181. }) || []
  182. );
  183. };
  184. const chartYLabels = (
  185. spreadsheet: Spreadsheet,
  186. x: number,
  187. y: number,
  188. groupId: string,
  189. backgroundColor: string,
  190. ): ChartElements => {
  191. const minYLabel = newTextElement({
  192. groupIds: [groupId],
  193. backgroundColor,
  194. ...commonProps,
  195. x: x - BAR_GAP,
  196. y: y - BAR_GAP,
  197. text: "0",
  198. textAlign: "right",
  199. });
  200. const maxYLabel = newTextElement({
  201. groupIds: [groupId],
  202. backgroundColor,
  203. ...commonProps,
  204. x: x - BAR_GAP,
  205. y: y - BAR_HEIGHT - minYLabel.height / 2,
  206. text: Math.max(...spreadsheet.values).toLocaleString(),
  207. textAlign: "right",
  208. });
  209. return [minYLabel, maxYLabel];
  210. };
  211. const chartLines = (
  212. spreadsheet: Spreadsheet,
  213. x: number,
  214. y: number,
  215. groupId: string,
  216. backgroundColor: string,
  217. ): ChartElements => {
  218. const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
  219. const xLine = newLinearElement({
  220. backgroundColor,
  221. groupIds: [groupId],
  222. ...commonProps,
  223. type: "line",
  224. x,
  225. y,
  226. startArrowhead: null,
  227. endArrowhead: null,
  228. width: chartWidth,
  229. points: [
  230. [0, 0],
  231. [chartWidth, 0],
  232. ],
  233. });
  234. const yLine = newLinearElement({
  235. backgroundColor,
  236. groupIds: [groupId],
  237. ...commonProps,
  238. type: "line",
  239. x,
  240. y,
  241. startArrowhead: null,
  242. endArrowhead: null,
  243. height: chartHeight,
  244. points: [
  245. [0, 0],
  246. [0, -chartHeight],
  247. ],
  248. });
  249. const maxLine = newLinearElement({
  250. backgroundColor,
  251. groupIds: [groupId],
  252. ...commonProps,
  253. type: "line",
  254. x,
  255. y: y - BAR_HEIGHT - BAR_GAP,
  256. startArrowhead: null,
  257. endArrowhead: null,
  258. strokeStyle: "dotted",
  259. width: chartWidth,
  260. opacity: GRID_OPACITY,
  261. points: [
  262. [0, 0],
  263. [chartWidth, 0],
  264. ],
  265. });
  266. return [xLine, yLine, maxLine];
  267. };
  268. // For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
  269. const chartBaseElements = (
  270. spreadsheet: Spreadsheet,
  271. x: number,
  272. y: number,
  273. groupId: string,
  274. backgroundColor: string,
  275. debug?: boolean,
  276. ): ChartElements => {
  277. const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
  278. const title = spreadsheet.title
  279. ? newTextElement({
  280. backgroundColor,
  281. groupIds: [groupId],
  282. ...commonProps,
  283. text: spreadsheet.title,
  284. x: x + chartWidth / 2,
  285. y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
  286. roundness: null,
  287. textAlign: "center",
  288. })
  289. : null;
  290. const debugRect = debug
  291. ? newElement({
  292. backgroundColor,
  293. groupIds: [groupId],
  294. ...commonProps,
  295. type: "rectangle",
  296. x,
  297. y: y - chartHeight,
  298. width: chartWidth,
  299. height: chartHeight,
  300. strokeColor: COLOR_PALETTE.black,
  301. fillStyle: "solid",
  302. opacity: 6,
  303. })
  304. : null;
  305. return [
  306. ...(debugRect ? [debugRect] : []),
  307. ...(title ? [title] : []),
  308. ...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
  309. ...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
  310. ...chartLines(spreadsheet, x, y, groupId, backgroundColor),
  311. ];
  312. };
  313. const chartTypeBar = (
  314. spreadsheet: Spreadsheet,
  315. x: number,
  316. y: number,
  317. ): ChartElements => {
  318. const max = Math.max(...spreadsheet.values);
  319. const groupId = randomId();
  320. const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
  321. const bars = spreadsheet.values.map((value, index) => {
  322. const barHeight = (value / max) * BAR_HEIGHT;
  323. return newElement({
  324. backgroundColor,
  325. groupIds: [groupId],
  326. ...commonProps,
  327. type: "rectangle",
  328. x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
  329. y: y - barHeight - BAR_GAP,
  330. width: BAR_WIDTH,
  331. height: barHeight,
  332. });
  333. });
  334. return [
  335. ...bars,
  336. ...chartBaseElements(
  337. spreadsheet,
  338. x,
  339. y,
  340. groupId,
  341. backgroundColor,
  342. import.meta.env.DEV,
  343. ),
  344. ];
  345. };
  346. const chartTypeLine = (
  347. spreadsheet: Spreadsheet,
  348. x: number,
  349. y: number,
  350. ): ChartElements => {
  351. const max = Math.max(...spreadsheet.values);
  352. const groupId = randomId();
  353. const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
  354. let index = 0;
  355. const points = [];
  356. for (const value of spreadsheet.values) {
  357. const cx = index * (BAR_WIDTH + BAR_GAP);
  358. const cy = -(value / max) * BAR_HEIGHT;
  359. points.push([cx, cy]);
  360. index++;
  361. }
  362. const maxX = Math.max(...points.map((element) => element[0]));
  363. const maxY = Math.max(...points.map((element) => element[1]));
  364. const minX = Math.min(...points.map((element) => element[0]));
  365. const minY = Math.min(...points.map((element) => element[1]));
  366. const line = newLinearElement({
  367. backgroundColor,
  368. groupIds: [groupId],
  369. ...commonProps,
  370. type: "line",
  371. x: x + BAR_GAP + BAR_WIDTH / 2,
  372. y: y - BAR_GAP,
  373. startArrowhead: null,
  374. endArrowhead: null,
  375. height: maxY - minY,
  376. width: maxX - minX,
  377. strokeWidth: 2,
  378. points: points as any,
  379. });
  380. const dots = spreadsheet.values.map((value, index) => {
  381. const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
  382. const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2;
  383. return newElement({
  384. backgroundColor,
  385. groupIds: [groupId],
  386. ...commonProps,
  387. fillStyle: "solid",
  388. strokeWidth: 2,
  389. type: "ellipse",
  390. x: x + cx + BAR_WIDTH / 2,
  391. y: y + cy - BAR_GAP * 2,
  392. width: BAR_GAP,
  393. height: BAR_GAP,
  394. });
  395. });
  396. const lines = spreadsheet.values.map((value, index) => {
  397. const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
  398. const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP;
  399. return newLinearElement({
  400. backgroundColor,
  401. groupIds: [groupId],
  402. ...commonProps,
  403. type: "line",
  404. x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
  405. y: y - cy,
  406. startArrowhead: null,
  407. endArrowhead: null,
  408. height: cy,
  409. strokeStyle: "dotted",
  410. opacity: GRID_OPACITY,
  411. points: [
  412. [0, 0],
  413. [0, cy],
  414. ],
  415. });
  416. });
  417. return [
  418. ...chartBaseElements(
  419. spreadsheet,
  420. x,
  421. y,
  422. groupId,
  423. backgroundColor,
  424. import.meta.env.DEV,
  425. ),
  426. line,
  427. ...lines,
  428. ...dots,
  429. ];
  430. };
  431. export const renderSpreadsheet = (
  432. chartType: string,
  433. spreadsheet: Spreadsheet,
  434. x: number,
  435. y: number,
  436. ): ChartElements => {
  437. if (chartType === "line") {
  438. return chartTypeLine(spreadsheet, x, y);
  439. }
  440. return chartTypeBar(spreadsheet, x, y);
  441. };