charts.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import { ExcalidrawElement } from "./element/types";
  2. import { newElement, newTextElement } from "./element";
  3. import { AppState } from "./types";
  4. import { t } from "./i18n";
  5. import { DEFAULT_VERTICAL_ALIGN } from "./constants";
  6. export interface Spreadsheet {
  7. yAxisLabel: string | null;
  8. labels: string[] | null;
  9. values: number[];
  10. }
  11. export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
  12. export const MALFORMED_SPREADSHEET = "MALFORMED_SPREADSHEET";
  13. export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
  14. type ParseSpreadsheetResult =
  15. | {
  16. type: typeof NOT_SPREADSHEET;
  17. }
  18. | { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
  19. | {
  20. type: typeof MALFORMED_SPREADSHEET;
  21. error: string;
  22. };
  23. const tryParseNumber = (s: string): number | null => {
  24. const match = /^[$€£¥₩]?([0-9]+(\.[0-9]+)?)$/.exec(s);
  25. if (!match) {
  26. return null;
  27. }
  28. return parseFloat(match[1]);
  29. };
  30. const isNumericColumn = (lines: string[][], columnIndex: number) => {
  31. return lines
  32. .slice(1)
  33. .every((line) => tryParseNumber(line[columnIndex]) !== null);
  34. };
  35. const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
  36. const numCols = cells[0].length;
  37. if (numCols > 2) {
  38. return { type: MALFORMED_SPREADSHEET, error: t("charts.tooManyColumns") };
  39. }
  40. if (numCols === 1) {
  41. if (!isNumericColumn(cells, 0)) {
  42. return { type: NOT_SPREADSHEET };
  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 };
  50. }
  51. return {
  52. type: VALID_SPREADSHEET,
  53. spreadsheet: {
  54. yAxisLabel: 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 {
  63. type: MALFORMED_SPREADSHEET,
  64. error: t("charts.noNumericColumn"),
  65. };
  66. }
  67. const labelColumnIndex = (valueColumnIndex + 1) % 2;
  68. const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
  69. const rows = hasHeader ? cells.slice(1) : cells;
  70. if (rows.length < 2) {
  71. return { type: NOT_SPREADSHEET };
  72. }
  73. return {
  74. type: VALID_SPREADSHEET,
  75. spreadsheet: {
  76. yAxisLabel: hasHeader ? cells[0][valueColumnIndex] : null,
  77. labels: rows.map((row) => row[labelColumnIndex]),
  78. values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
  79. },
  80. };
  81. };
  82. const transposeCells = (cells: string[][]) => {
  83. const nextCells: string[][] = [];
  84. for (let col = 0; col < cells[0].length; col++) {
  85. const nextCellRow: string[] = [];
  86. for (let row = 0; row < cells.length; row++) {
  87. nextCellRow.push(cells[row][col]);
  88. }
  89. nextCells.push(nextCellRow);
  90. }
  91. return nextCells;
  92. };
  93. export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
  94. // copy/paste from excel, in-browser excel, and google sheets is tsv
  95. // for now we only accept 2 columns with an optional header
  96. const lines = text
  97. .trim()
  98. .split("\n")
  99. .map((line) => line.trim().split("\t"));
  100. if (lines.length === 0) {
  101. return { type: NOT_SPREADSHEET };
  102. }
  103. const numColsFirstLine = lines[0].length;
  104. const isASpreadsheet = lines.every(
  105. (line) => line.length === numColsFirstLine,
  106. );
  107. if (!isASpreadsheet) {
  108. return { type: NOT_SPREADSHEET };
  109. }
  110. const result = tryParseCells(lines);
  111. if (result.type !== VALID_SPREADSHEET) {
  112. const transposedResults = tryParseCells(transposeCells(lines));
  113. if (transposedResults.type === VALID_SPREADSHEET) {
  114. return transposedResults;
  115. }
  116. }
  117. return result;
  118. };
  119. const BAR_WIDTH = 32;
  120. const BAR_SPACING = 12;
  121. const BAR_HEIGHT = 192;
  122. const LABEL_SPACING = 3 * BAR_SPACING;
  123. const Y_AXIS_LABEL_SPACING = LABEL_SPACING;
  124. const ANGLE = 5.87;
  125. export const renderSpreadsheet = (
  126. appState: AppState,
  127. spreadsheet: Spreadsheet,
  128. x: number,
  129. y: number,
  130. ): ExcalidrawElement[] => {
  131. const max = Math.max(...spreadsheet.values);
  132. const min = Math.min(0, ...spreadsheet.values);
  133. const range = max - min;
  134. const minYLabel = newTextElement({
  135. x,
  136. y: y + BAR_HEIGHT,
  137. strokeColor: appState.currentItemStrokeColor,
  138. backgroundColor: appState.currentItemBackgroundColor,
  139. fillStyle: appState.currentItemFillStyle,
  140. strokeWidth: appState.currentItemStrokeWidth,
  141. strokeStyle: appState.currentItemStrokeStyle,
  142. roughness: appState.currentItemRoughness,
  143. opacity: appState.currentItemOpacity,
  144. strokeSharpness: appState.currentItemStrokeSharpness,
  145. text: min.toLocaleString(),
  146. fontSize: 16,
  147. fontFamily: appState.currentItemFontFamily,
  148. textAlign: appState.currentItemTextAlign,
  149. verticalAlign: DEFAULT_VERTICAL_ALIGN,
  150. });
  151. const maxYLabel = newTextElement({
  152. x,
  153. y,
  154. strokeColor: appState.currentItemStrokeColor,
  155. backgroundColor: appState.currentItemBackgroundColor,
  156. fillStyle: appState.currentItemFillStyle,
  157. strokeWidth: appState.currentItemStrokeWidth,
  158. strokeStyle: appState.currentItemStrokeStyle,
  159. roughness: appState.currentItemRoughness,
  160. opacity: appState.currentItemOpacity,
  161. strokeSharpness: appState.currentItemStrokeSharpness,
  162. text: max.toLocaleString(),
  163. fontSize: 16,
  164. fontFamily: appState.currentItemFontFamily,
  165. textAlign: appState.currentItemTextAlign,
  166. verticalAlign: DEFAULT_VERTICAL_ALIGN,
  167. });
  168. const bars = spreadsheet.values.map((value, index) => {
  169. const valueBarHeight = value - min;
  170. const percentBarHeight = valueBarHeight / range;
  171. const barHeight = percentBarHeight * BAR_HEIGHT;
  172. const barX = index * (BAR_WIDTH + BAR_SPACING) + LABEL_SPACING;
  173. const barY = BAR_HEIGHT - barHeight;
  174. return newElement({
  175. type: "rectangle",
  176. x: barX + x,
  177. y: barY + y,
  178. width: BAR_WIDTH,
  179. height: barHeight,
  180. strokeColor: appState.currentItemStrokeColor,
  181. backgroundColor: appState.currentItemBackgroundColor,
  182. fillStyle: appState.currentItemFillStyle,
  183. strokeWidth: appState.currentItemStrokeWidth,
  184. strokeStyle: appState.currentItemStrokeStyle,
  185. roughness: appState.currentItemRoughness,
  186. opacity: appState.currentItemOpacity,
  187. strokeSharpness: appState.currentItemStrokeSharpness,
  188. });
  189. });
  190. const xLabels =
  191. spreadsheet.labels?.map((label, index) => {
  192. const labelX =
  193. index * (BAR_WIDTH + BAR_SPACING) + LABEL_SPACING + BAR_SPACING;
  194. const labelY = BAR_HEIGHT + BAR_SPACING;
  195. return newTextElement({
  196. text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
  197. x: x + labelX,
  198. y: y + labelY,
  199. strokeColor: appState.currentItemStrokeColor,
  200. backgroundColor: appState.currentItemBackgroundColor,
  201. fillStyle: appState.currentItemFillStyle,
  202. strokeWidth: appState.currentItemStrokeWidth,
  203. strokeStyle: appState.currentItemStrokeStyle,
  204. roughness: appState.currentItemRoughness,
  205. opacity: appState.currentItemOpacity,
  206. strokeSharpness: appState.currentItemStrokeSharpness,
  207. fontSize: 16,
  208. fontFamily: appState.currentItemFontFamily,
  209. textAlign: "center",
  210. verticalAlign: DEFAULT_VERTICAL_ALIGN,
  211. width: BAR_WIDTH,
  212. angle: ANGLE,
  213. });
  214. }) || [];
  215. const yAxisLabel = spreadsheet.yAxisLabel
  216. ? newTextElement({
  217. text: spreadsheet.yAxisLabel,
  218. x: x - Y_AXIS_LABEL_SPACING,
  219. y: y + BAR_HEIGHT / 2 - 10,
  220. strokeColor: appState.currentItemStrokeColor,
  221. backgroundColor: appState.currentItemBackgroundColor,
  222. fillStyle: appState.currentItemFillStyle,
  223. strokeWidth: appState.currentItemStrokeWidth,
  224. strokeStyle: appState.currentItemStrokeStyle,
  225. roughness: appState.currentItemRoughness,
  226. opacity: appState.currentItemOpacity,
  227. strokeSharpness: appState.currentItemStrokeSharpness,
  228. fontSize: 20,
  229. fontFamily: appState.currentItemFontFamily,
  230. textAlign: "center",
  231. verticalAlign: DEFAULT_VERTICAL_ALIGN,
  232. width: BAR_WIDTH,
  233. angle: ANGLE,
  234. })
  235. : null;
  236. return [...bars, yAxisLabel, minYLabel, maxYLabel, ...xLabels].filter(
  237. (element) => element !== null,
  238. ) as ExcalidrawElement[];
  239. };