charts.ts 12 KB

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