charts.ts 7.7 KB

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