charts.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. import colors from "./colors";
  2. import {
  3. DEFAULT_FONT_FAMILY,
  4. DEFAULT_FONT_SIZE,
  5. ENV,
  6. VERTICAL_ALIGN,
  7. } from "./constants";
  8. import { newElement, newLinearElement, newTextElement } from "./element";
  9. import { NonDeletedExcalidrawElement } from "./element/types";
  10. import { randomId } from "./random";
  11. export type ChartElements = readonly NonDeletedExcalidrawElement[];
  12. const BAR_WIDTH = 32;
  13. const BAR_GAP = 12;
  14. const BAR_HEIGHT = 256;
  15. const GRID_OPACITY = 50;
  16. export interface Spreadsheet {
  17. title: string | null;
  18. labels: string[] | null;
  19. values: number[];
  20. }
  21. export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
  22. export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
  23. type ParseSpreadsheetResult =
  24. | { type: typeof NOT_SPREADSHEET; reason: string }
  25. | { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
  26. /**
  27. * @private exported for testing
  28. */
  29. export const tryParseNumber = (s: string): number | null => {
  30. const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
  31. if (!match) {
  32. return null;
  33. }
  34. return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
  35. };
  36. const isNumericColumn = (lines: string[][], columnIndex: number) =>
  37. lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
  38. /**
  39. * @private exported for testing
  40. */
  41. export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
  42. const numCols = cells[0].length;
  43. if (numCols > 2) {
  44. return { type: NOT_SPREADSHEET, reason: "More than 2 columns" };
  45. }
  46. if (numCols === 1) {
  47. if (!isNumericColumn(cells, 0)) {
  48. return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
  49. }
  50. const hasHeader = tryParseNumber(cells[0][0]) === null;
  51. const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
  52. tryParseNumber(line[0]),
  53. );
  54. if (values.length < 2) {
  55. return { type: NOT_SPREADSHEET, reason: "Less than two rows" };
  56. }
  57. return {
  58. type: VALID_SPREADSHEET,
  59. spreadsheet: {
  60. title: hasHeader ? cells[0][0] : null,
  61. labels: null,
  62. values: values as number[],
  63. },
  64. };
  65. }
  66. const labelColumnNumeric = isNumericColumn(cells, 0);
  67. const valueColumnNumeric = isNumericColumn(cells, 1);
  68. if (!labelColumnNumeric && !valueColumnNumeric) {
  69. return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
  70. }
  71. const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
  72. ? [0, 1]
  73. : [1, 0];
  74. const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
  75. const rows = hasHeader ? cells.slice(1) : cells;
  76. if (rows.length < 2) {
  77. return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" };
  78. }
  79. return {
  80. type: VALID_SPREADSHEET,
  81. spreadsheet: {
  82. title: hasHeader ? cells[0][valueColumnIndex] : null,
  83. labels: rows.map((row) => row[labelColumnIndex]),
  84. values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
  85. },
  86. };
  87. };
  88. const transposeCells = (cells: string[][]) => {
  89. const nextCells: string[][] = [];
  90. for (let col = 0; col < cells[0].length; col++) {
  91. const nextCellRow: string[] = [];
  92. for (let row = 0; row < cells.length; row++) {
  93. nextCellRow.push(cells[row][col]);
  94. }
  95. nextCells.push(nextCellRow);
  96. }
  97. return nextCells;
  98. };
  99. export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
  100. // Copy/paste from excel, spreadsheets, tsv, csv.
  101. // For now we only accept 2 columns with an optional header
  102. // Check for tab separated values
  103. let lines = text
  104. .trim()
  105. .split("\n")
  106. .map((line) => line.trim().split("\t"));
  107. // Check for comma separated files
  108. if (lines.length && lines[0].length !== 2) {
  109. lines = text
  110. .trim()
  111. .split("\n")
  112. .map((line) => line.trim().split(","));
  113. }
  114. if (lines.length === 0) {
  115. return { type: NOT_SPREADSHEET, reason: "No values" };
  116. }
  117. const numColsFirstLine = lines[0].length;
  118. const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
  119. if (!isSpreadsheet) {
  120. return {
  121. type: NOT_SPREADSHEET,
  122. reason: "All rows don't have same number of columns",
  123. };
  124. }
  125. const result = tryParseCells(lines);
  126. if (result.type !== VALID_SPREADSHEET) {
  127. const transposedResults = tryParseCells(transposeCells(lines));
  128. if (transposedResults.type === VALID_SPREADSHEET) {
  129. return transposedResults;
  130. }
  131. }
  132. return result;
  133. };
  134. const bgColors = colors.elementBackground.slice(
  135. 2,
  136. colors.elementBackground.length,
  137. );
  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: colors.elementStroke[0],
  147. strokeSharpness: "sharp",
  148. strokeStyle: "solid",
  149. strokeWidth: 1,
  150. verticalAlign: VERTICAL_ALIGN.MIDDLE,
  151. locked: false,
  152. } as const;
  153. const getChartDimentions = (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 } = getChartDimentions(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 } = getChartDimentions(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. strokeSharpness: "sharp",
  287. strokeStyle: "solid",
  288. textAlign: "center",
  289. })
  290. : null;
  291. const debugRect = debug
  292. ? newElement({
  293. backgroundColor,
  294. groupIds: [groupId],
  295. ...commonProps,
  296. type: "rectangle",
  297. x,
  298. y: y - chartHeight,
  299. width: chartWidth,
  300. height: chartHeight,
  301. strokeColor: colors.elementStroke[0],
  302. fillStyle: "solid",
  303. opacity: 6,
  304. })
  305. : null;
  306. return [
  307. ...(debugRect ? [debugRect] : []),
  308. ...(title ? [title] : []),
  309. ...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
  310. ...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
  311. ...chartLines(spreadsheet, x, y, groupId, backgroundColor),
  312. ];
  313. };
  314. const chartTypeBar = (
  315. spreadsheet: Spreadsheet,
  316. x: number,
  317. y: number,
  318. ): ChartElements => {
  319. const max = Math.max(...spreadsheet.values);
  320. const groupId = randomId();
  321. const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
  322. const bars = spreadsheet.values.map((value, index) => {
  323. const barHeight = (value / max) * BAR_HEIGHT;
  324. return newElement({
  325. backgroundColor,
  326. groupIds: [groupId],
  327. ...commonProps,
  328. type: "rectangle",
  329. x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
  330. y: y - barHeight - BAR_GAP,
  331. width: BAR_WIDTH,
  332. height: barHeight,
  333. });
  334. });
  335. return [
  336. ...bars,
  337. ...chartBaseElements(
  338. spreadsheet,
  339. x,
  340. y,
  341. groupId,
  342. backgroundColor,
  343. process.env.NODE_ENV === ENV.DEVELOPMENT,
  344. ),
  345. ];
  346. };
  347. const chartTypeLine = (
  348. spreadsheet: Spreadsheet,
  349. x: number,
  350. y: number,
  351. ): ChartElements => {
  352. const max = Math.max(...spreadsheet.values);
  353. const groupId = randomId();
  354. const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
  355. let index = 0;
  356. const points = [];
  357. for (const value of spreadsheet.values) {
  358. const cx = index * (BAR_WIDTH + BAR_GAP);
  359. const cy = -(value / max) * BAR_HEIGHT;
  360. points.push([cx, cy]);
  361. index++;
  362. }
  363. const maxX = Math.max(...points.map((element) => element[0]));
  364. const maxY = Math.max(...points.map((element) => element[1]));
  365. const minX = Math.min(...points.map((element) => element[0]));
  366. const minY = Math.min(...points.map((element) => element[1]));
  367. const line = newLinearElement({
  368. backgroundColor,
  369. groupIds: [groupId],
  370. ...commonProps,
  371. type: "line",
  372. x: x + BAR_GAP + BAR_WIDTH / 2,
  373. y: y - BAR_GAP,
  374. startArrowhead: null,
  375. endArrowhead: null,
  376. height: maxY - minY,
  377. width: maxX - minX,
  378. strokeWidth: 2,
  379. points: points as any,
  380. });
  381. const dots = spreadsheet.values.map((value, index) => {
  382. const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
  383. const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2;
  384. return newElement({
  385. backgroundColor,
  386. groupIds: [groupId],
  387. ...commonProps,
  388. fillStyle: "solid",
  389. strokeWidth: 2,
  390. type: "ellipse",
  391. x: x + cx + BAR_WIDTH / 2,
  392. y: y + cy - BAR_GAP * 2,
  393. width: BAR_GAP,
  394. height: BAR_GAP,
  395. });
  396. });
  397. const lines = spreadsheet.values.map((value, index) => {
  398. const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
  399. const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP;
  400. return newLinearElement({
  401. backgroundColor,
  402. groupIds: [groupId],
  403. ...commonProps,
  404. type: "line",
  405. x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
  406. y: y - cy,
  407. startArrowhead: null,
  408. endArrowhead: null,
  409. height: cy,
  410. strokeStyle: "dotted",
  411. opacity: GRID_OPACITY,
  412. points: [
  413. [0, 0],
  414. [0, cy],
  415. ],
  416. });
  417. });
  418. return [
  419. ...chartBaseElements(
  420. spreadsheet,
  421. x,
  422. y,
  423. groupId,
  424. backgroundColor,
  425. process.env.NODE_ENV === ENV.DEVELOPMENT,
  426. ),
  427. line,
  428. ...lines,
  429. ...dots,
  430. ];
  431. };
  432. export const renderSpreadsheet = (
  433. chartType: string,
  434. spreadsheet: Spreadsheet,
  435. x: number,
  436. y: number,
  437. ): ChartElements => {
  438. if (chartType === "line") {
  439. return chartTypeLine(spreadsheet, x, y);
  440. }
  441. return chartTypeBar(spreadsheet, x, y);
  442. };