charts.ts 12 KB

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