@svgrid/grid 3.0.2 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +2 -1
  2. package/dist/GridMenus.svelte +44 -2
  3. package/dist/SvChartMenu.svelte +69 -0
  4. package/dist/SvChartMenu.svelte.d.ts +11 -0
  5. package/dist/SvChartPanes.svelte +113 -0
  6. package/dist/SvChartPanes.svelte.d.ts +20 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +110 -39
  8. package/dist/SvGrid.controller.svelte.js +655 -200
  9. package/dist/SvGrid.css +105 -2
  10. package/dist/SvGrid.svelte +386 -514
  11. package/dist/SvGrid.types.d.ts +210 -9
  12. package/dist/SvGridCellEditor.svelte +17 -0
  13. package/dist/SvGridChart.svelte +2257 -329
  14. package/dist/SvGridChart.svelte.d.ts +6 -106
  15. package/dist/SvGridChart.types.d.ts +338 -0
  16. package/dist/SvGridChart.types.js +1 -0
  17. package/dist/SvGridChartBuilder.svelte +323 -0
  18. package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
  19. package/dist/SvGridChartPanel.svelte +221 -275
  20. package/dist/SvGridChartPickers.svelte +417 -0
  21. package/dist/SvGridChartPickers.svelte.d.ts +70 -0
  22. package/dist/SvGridChartView.svelte +13 -14
  23. package/dist/SvModal.svelte +7 -1
  24. package/dist/SvModal.svelte.d.ts +5 -0
  25. package/dist/ai.d.ts +40 -8
  26. package/dist/ai.js +135 -24
  27. package/dist/aria.d.ts +11 -0
  28. package/dist/build-api.js +108 -22
  29. package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
  30. package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
  31. package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
  32. package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
  33. package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
  34. package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
  35. package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
  36. package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
  37. package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
  38. package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
  39. package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
  40. package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
  41. package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
  42. package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
  43. package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
  44. package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
  45. package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
  46. package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
  47. package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
  48. package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
  49. package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
  50. package/dist/cdn/SvModal-CIZWCcad.js +397 -0
  51. package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
  52. package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
  53. package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
  54. package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
  55. package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
  56. package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
  57. package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
  58. package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
  59. package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
  60. package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
  61. package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
  62. package/dist/cdn/dismissable-DAHetSNk.js +44 -0
  63. package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
  64. package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
  65. package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
  66. package/dist/cdn/{src-DNbz94Ct.js → src-BKhZ6eXd.js} +10227 -9787
  67. package/dist/cdn/{src-BJ2opd0j.js → src-V1uu8iE9.js} +6568 -6128
  68. package/dist/cdn/svgrid.js +26 -15
  69. package/dist/cdn/svgrid.svelte-external.js +26 -15
  70. package/dist/chart-axes.d.ts +53 -0
  71. package/dist/chart-axes.js +351 -0
  72. package/dist/chart-cartesian.d.ts +88 -0
  73. package/dist/chart-cartesian.js +1862 -0
  74. package/dist/chart-decimate.d.ts +51 -0
  75. package/dist/chart-decimate.js +199 -0
  76. package/dist/chart-export-pdf.d.ts +47 -0
  77. package/dist/chart-export-pdf.js +187 -0
  78. package/dist/chart-export.d.ts +2 -1
  79. package/dist/chart-export.js +18 -3
  80. package/dist/chart-financial.d.ts +91 -0
  81. package/dist/chart-financial.js +175 -0
  82. package/dist/chart-flow.d.ts +10 -0
  83. package/dist/chart-flow.js +298 -0
  84. package/dist/chart-format.d.ts +18 -0
  85. package/dist/chart-format.js +65 -0
  86. package/dist/chart-grid.d.ts +8 -0
  87. package/dist/chart-grid.js +190 -0
  88. package/dist/chart-hierarchy.d.ts +17 -0
  89. package/dist/chart-hierarchy.js +146 -0
  90. package/dist/chart-indicators.d.ts +115 -0
  91. package/dist/chart-indicators.js +389 -0
  92. package/dist/chart-messages.d.ts +95 -0
  93. package/dist/chart-messages.js +92 -0
  94. package/dist/chart-motion.d.ts +16 -0
  95. package/dist/chart-motion.js +141 -0
  96. package/dist/chart-panel-messages.d.ts +265 -0
  97. package/dist/chart-panel-messages.js +285 -0
  98. package/dist/chart-pivot.d.ts +87 -0
  99. package/dist/chart-pivot.js +156 -0
  100. package/dist/chart-polar.d.ts +25 -0
  101. package/dist/chart-polar.js +700 -0
  102. package/dist/chart-samples.d.ts +16 -0
  103. package/dist/chart-samples.js +153 -0
  104. package/dist/chart-scale.d.ts +113 -0
  105. package/dist/chart-scale.js +405 -0
  106. package/dist/chart-stats.d.ts +111 -0
  107. package/dist/chart-stats.js +419 -0
  108. package/dist/chart-stream.d.ts +45 -0
  109. package/dist/chart-stream.js +91 -0
  110. package/dist/chart-summary.d.ts +27 -0
  111. package/dist/chart-summary.js +205 -0
  112. package/dist/chart-sync.svelte.d.ts +32 -0
  113. package/dist/chart-sync.svelte.js +23 -0
  114. package/dist/chart-table.d.ts +35 -0
  115. package/dist/chart-table.js +165 -0
  116. package/dist/chart-trend.d.ts +19 -0
  117. package/dist/chart-trend.js +61 -0
  118. package/dist/chart-types.d.ts +1456 -0
  119. package/dist/chart-types.js +6 -0
  120. package/dist/chart-validate.d.ts +43 -0
  121. package/dist/chart-validate.js +272 -0
  122. package/dist/chart-zoom.d.ts +40 -0
  123. package/dist/chart-zoom.js +145 -0
  124. package/dist/chart.d.ts +186 -865
  125. package/dist/chart.js +460 -2204
  126. package/dist/clipboard.d.ts +1 -0
  127. package/dist/clipboard.js +137 -18
  128. package/dist/column-resize.d.ts +3 -0
  129. package/dist/column-resize.js +4 -1
  130. package/dist/columns.js +3 -0
  131. package/dist/command-context.d.ts +19 -0
  132. package/dist/command-context.js +116 -0
  133. package/dist/core.d.ts +7 -0
  134. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  135. package/dist/editing.d.ts +2 -8
  136. package/dist/editing.js +262 -33
  137. package/dist/fill-patterns.d.ts +0 -5
  138. package/dist/fill-patterns.js +56 -2
  139. package/dist/grid-messages.d.ts +10 -3
  140. package/dist/grid-messages.js +5 -0
  141. package/dist/history.d.ts +83 -0
  142. package/dist/history.js +133 -0
  143. package/dist/index.d.ts +24 -6
  144. package/dist/index.js +20 -2
  145. package/dist/keyboard-handlers.js +88 -31
  146. package/dist/keyboard.d.ts +55 -0
  147. package/dist/keyboard.js +132 -0
  148. package/dist/menus.d.ts +3 -1
  149. package/dist/menus.js +7 -2
  150. package/dist/merges.d.ts +97 -0
  151. package/dist/merges.js +147 -0
  152. package/dist/row-resize.d.ts +3 -0
  153. package/dist/row-resize.js +14 -0
  154. package/dist/scroll-sync.d.ts +1 -0
  155. package/dist/scroll-sync.js +24 -0
  156. package/dist/selection.d.ts +16 -0
  157. package/dist/selection.js +108 -16
  158. package/dist/shortcut-registry.d.ts +145 -0
  159. package/dist/shortcut-registry.js +44 -0
  160. package/dist/svgrid-wrapper.types.d.ts +213 -22
  161. package/dist/virtualization/virtualizer.js +38 -17
  162. package/package.json +6 -1
  163. package/src/GridMenus.svelte +44 -2
  164. package/src/SvChartMenu.svelte +69 -0
  165. package/src/SvChartPanes.svelte +113 -0
  166. package/src/SvChartPanes.test.ts +66 -0
  167. package/src/SvGrid.controller.svelte.ts +629 -172
  168. package/src/SvGrid.css +105 -2
  169. package/src/SvGrid.svelte +386 -514
  170. package/src/SvGrid.types.ts +188 -8
  171. package/src/SvGridCellEditor.svelte +17 -0
  172. package/src/SvGridChart.svelte +2257 -329
  173. package/src/SvGridChart.test.ts +1419 -1
  174. package/src/SvGridChart.types.ts +336 -0
  175. package/src/SvGridChartBuilder.svelte +323 -0
  176. package/src/SvGridChartPanel.svelte +221 -275
  177. package/src/SvGridChartPickers.svelte +417 -0
  178. package/src/SvGridChartView.svelte +13 -14
  179. package/src/SvModal.svelte +7 -1
  180. package/src/SvModal.test.ts +9 -0
  181. package/src/ai.test.ts +57 -2
  182. package/src/ai.ts +166 -22
  183. package/src/aria.d.ts +11 -0
  184. package/src/build-api.coverage.test.ts +184 -0
  185. package/src/build-api.ts +107 -18
  186. package/src/chart-axes.test.ts +573 -0
  187. package/src/chart-axes.ts +342 -0
  188. package/src/chart-cartesian.ts +1863 -0
  189. package/src/chart-decimate.test.ts +249 -0
  190. package/src/chart-decimate.ts +186 -0
  191. package/src/chart-export-pdf.test.ts +91 -0
  192. package/src/chart-export-pdf.ts +207 -0
  193. package/src/chart-export.test.ts +14 -0
  194. package/src/chart-export.ts +13 -3
  195. package/src/chart-financial.ts +208 -0
  196. package/src/chart-flow.ts +286 -0
  197. package/src/chart-format.ts +67 -0
  198. package/src/chart-grid.ts +179 -0
  199. package/src/chart-hierarchy.ts +135 -0
  200. package/src/chart-indicators.test.ts +443 -0
  201. package/src/chart-indicators.ts +384 -0
  202. package/src/chart-messages.test.ts +21 -0
  203. package/src/chart-messages.ts +184 -0
  204. package/src/chart-motion.test.ts +71 -0
  205. package/src/chart-motion.ts +148 -0
  206. package/src/chart-panel-messages.test.ts +29 -0
  207. package/src/chart-panel-messages.ts +550 -0
  208. package/src/chart-pivot.test.ts +54 -0
  209. package/src/chart-pivot.ts +225 -0
  210. package/src/chart-polar.ts +674 -0
  211. package/src/chart-samples.ts +106 -0
  212. package/src/chart-scale.ts +394 -0
  213. package/src/chart-series-types.test.ts +714 -0
  214. package/src/chart-stats.ts +409 -0
  215. package/src/chart-stream.test.ts +75 -0
  216. package/src/chart-stream.ts +114 -0
  217. package/src/chart-summary.test.ts +90 -0
  218. package/src/chart-summary.ts +197 -0
  219. package/src/chart-sync.svelte.ts +41 -0
  220. package/src/chart-table.test.ts +76 -0
  221. package/src/chart-table.ts +178 -0
  222. package/src/chart-trend.ts +47 -0
  223. package/src/chart-types.ts +1309 -0
  224. package/src/chart-validate.test.ts +100 -0
  225. package/src/chart-validate.ts +247 -0
  226. package/src/chart-zoom.test.ts +137 -0
  227. package/src/chart-zoom.ts +124 -0
  228. package/src/chart.coverage.test.ts +81 -0
  229. package/src/chart.test.ts +56 -3
  230. package/src/chart.ts +557 -2784
  231. package/src/clipboard.test.ts +151 -0
  232. package/src/clipboard.ts +145 -16
  233. package/src/column-resize.ts +7 -1
  234. package/src/columns.ts +2 -0
  235. package/src/command-context.test.ts +200 -0
  236. package/src/command-context.ts +137 -0
  237. package/src/core.ts +7 -0
  238. package/src/editing.test.ts +281 -3
  239. package/src/editing.ts +256 -38
  240. package/src/fill-patterns.test.ts +35 -0
  241. package/src/fill-patterns.ts +56 -2
  242. package/src/grid-messages.ts +16 -3
  243. package/src/history.test.ts +196 -0
  244. package/src/history.ts +162 -0
  245. package/src/icon-seam.test.ts +3 -2
  246. package/src/index.ts +150 -2
  247. package/src/keyboard-handlers.coverage.test.ts +126 -6
  248. package/src/keyboard-handlers.ts +91 -30
  249. package/src/keyboard-shortcuts.seam.test.ts +200 -0
  250. package/src/keyboard.test.ts +124 -1
  251. package/src/keyboard.ts +142 -0
  252. package/src/menus.test.ts +26 -0
  253. package/src/menus.ts +10 -3
  254. package/src/merges.test.ts +112 -0
  255. package/src/merges.ts +168 -0
  256. package/src/pivot.grid.test.ts +40 -5
  257. package/src/row-resize.test.ts +44 -0
  258. package/src/row-resize.ts +17 -0
  259. package/src/scroll-sync.test.ts +42 -1
  260. package/src/scroll-sync.ts +25 -0
  261. package/src/selection.multi-range.test.ts +34 -0
  262. package/src/selection.test.ts +30 -2
  263. package/src/selection.ts +111 -19
  264. package/src/shortcut-registry.test.ts +97 -0
  265. package/src/shortcut-registry.ts +181 -0
  266. package/src/svgrid-wrapper.types.ts +184 -13
  267. package/src/svgrid.behavior.test.ts +135 -0
  268. package/src/svgrid.charting.test.ts +513 -3
  269. package/src/svgrid.context-menu.test.ts +31 -0
  270. package/src/svgrid.new-features.wrapper.test.ts +10 -3
  271. package/src/virtualization/virtualizer.ts +39 -17
  272. package/dist/cdn/GridMenus-DyFA-QxA.js +0 -602
  273. package/dist/cdn/GridMenus-M2XP9t0_.js +0 -603
  274. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  275. package/dist/cdn/SvGridChart-DAb9RbRs.js +0 -1480
  276. package/dist/cdn/SvGridChart-Yl-IXZFO.js +0 -1481
  277. package/dist/cdn/SvGridChartPanel-DayQpDsR.js +0 -834
  278. package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +0 -810
  279. package/dist/cdn/SvGridChartView-CxfakxR0.js +0 -56
  280. package/dist/cdn/SvGridChartView-Dm_YEx9L.js +0 -55
  281. package/dist/cdn/chart-CKJRCPOk.js +0 -1652
@@ -0,0 +1,207 @@
1
+ /**
2
+ * PDF and print for a rendered chart, with no PDF library.
3
+ *
4
+ * A one-page PDF is a short text file with one binary blob in it, so the
5
+ * writer here is the whole format we need: a catalog, a page, a Helvetica
6
+ * font, one image XObject and a content stream that places the text and the
7
+ * image. The chart is rasterised through the existing PNG path and re-encoded
8
+ * as JPEG, which the PDF embeds as-is under `/DCTDecode`; nothing needs zlib.
9
+ * JPEG has no alpha, so the chart background is always painted.
10
+ *
11
+ * Print opens a window with the standalone SVG and calls `print()`.
12
+ */
13
+ import { chartToPngBlob, chartToSvgString, type ChartExportOptions } from './chart-export'
14
+
15
+ /** Options for the PDF: page, orientation, the text around the chart. */
16
+ export type ChartPdfOptions = ChartExportOptions & {
17
+ /** Paper size. Default A4. */
18
+ page?: 'A4' | 'Letter'
19
+ /** Default: whichever fits the chart's aspect ratio better. */
20
+ orientation?: 'portrait' | 'landscape'
21
+ title?: string
22
+ subtitle?: string
23
+ caption?: string
24
+ /** JPEG quality, 0..1. Default 0.92. */
25
+ quality?: number
26
+ }
27
+
28
+ const PAGES: Record<'A4' | 'Letter', [number, number]> = { A4: [595.28, 841.89], Letter: [612, 792] }
29
+
30
+ /** Latin-1 bytes of a string; anything outside it becomes `?`. */
31
+ function latin1(s: string): Uint8Array {
32
+ const out = new Uint8Array(s.length)
33
+ for (let i = 0; i < s.length; i += 1) {
34
+ const c = s.charCodeAt(i)
35
+ out[i] = c < 256 ? c : 63
36
+ }
37
+ return out
38
+ }
39
+
40
+ /** A PDF string literal: parentheses and backslashes escaped. */
41
+ function pdfText(s: string): string {
42
+ return '(' + s.replace(/[\\()]/g, (m) => '\\' + m).replace(/[\r\n]+/g, ' ') + ')'
43
+ }
44
+
45
+ /** Roughly how wide Helvetica text is at a size, for fitting a line. */
46
+ function textWidth(s: string, size: number): number {
47
+ return s.length * size * 0.5
48
+ }
49
+
50
+ /** Re-encode a PNG blob as JPEG on a painted background. */
51
+ function pngToJpeg(png: Blob, background: string, quality: number): Promise<{ bytes: Uint8Array; width: number; height: number }> {
52
+ return new Promise((resolve, reject) => {
53
+ const url = URL.createObjectURL(png)
54
+ const img = new Image()
55
+ img.onload = () => {
56
+ const canvas = document.createElement('canvas')
57
+ canvas.width = img.naturalWidth || img.width
58
+ canvas.height = img.naturalHeight || img.height
59
+ const ctx = canvas.getContext('2d')
60
+ if (!ctx) { URL.revokeObjectURL(url); reject(new Error('canvas 2d context unavailable')); return }
61
+ ctx.fillStyle = background
62
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
63
+ ctx.drawImage(img, 0, 0)
64
+ URL.revokeObjectURL(url)
65
+ canvas.toBlob(
66
+ (blob) => {
67
+ if (!blob) { reject(new Error('toBlob failed')); return }
68
+ blob.arrayBuffer().then((buf) => resolve({ bytes: new Uint8Array(buf), width: canvas.width, height: canvas.height }), reject)
69
+ },
70
+ 'image/jpeg',
71
+ quality,
72
+ )
73
+ }
74
+ img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('failed to decode chart PNG')) }
75
+ img.src = url
76
+ })
77
+ }
78
+
79
+ /**
80
+ * Assemble a one-page PDF from a JPEG and the text around it. Exported for
81
+ * tests and for callers that already have a JPEG; `chartToPdfBlob` is the
82
+ * one that starts from a chart element.
83
+ */
84
+ export function buildChartPdf(
85
+ jpeg: { bytes: Uint8Array; width: number; height: number },
86
+ opts: Pick<ChartPdfOptions, 'page' | 'orientation' | 'title' | 'subtitle' | 'caption'> = {},
87
+ ): Uint8Array {
88
+ const [pw0, ph0] = PAGES[opts.page ?? 'A4']
89
+ const landscape = opts.orientation ? opts.orientation === 'landscape' : jpeg.width / jpeg.height > pw0 / ph0
90
+ const pw = landscape ? ph0 : pw0
91
+ const ph = landscape ? pw0 : ph0
92
+ const margin = 40
93
+ const title = (opts.title ?? '').trim()
94
+ const subtitle = (opts.subtitle ?? '').trim()
95
+ const caption = (opts.caption ?? '').trim()
96
+
97
+ // Text block at the top, image below it, caption at the foot.
98
+ let y = ph - margin
99
+ const content: string[] = []
100
+ const line = (text: string, size: number, gray: number) => {
101
+ y -= size + 4
102
+ content.push(`BT /F1 ${size} Tf ${gray} g ${margin} ${y.toFixed(2)} Td ${pdfText(text)} Tj ET`)
103
+ }
104
+ if (title) line(title, 16, 0)
105
+ if (subtitle) line(subtitle, 11, 0.4)
106
+ if (title || subtitle) y -= 8
107
+ const footer = caption ? 20 : 0
108
+ const availW = pw - margin * 2
109
+ const availH = y - margin - footer
110
+ const scale = Math.min(availW / jpeg.width, availH / jpeg.height)
111
+ const iw = jpeg.width * scale
112
+ const ih = jpeg.height * scale
113
+ const ix = margin + (availW - iw) / 2
114
+ const iy = y - ih
115
+ content.push(`q ${iw.toFixed(2)} 0 0 ${ih.toFixed(2)} ${ix.toFixed(2)} ${iy.toFixed(2)} cm /Im1 Do Q`)
116
+ if (caption) {
117
+ const size = 9
118
+ const text = textWidth(caption, size) > availW ? caption.slice(0, Math.floor(availW / (size * 0.5)) - 3) + '...' : caption
119
+ content.push(`BT /F1 ${size} Tf 0.4 g ${margin} ${(margin - 4).toFixed(2)} Td ${pdfText(text)} Tj ET`)
120
+ }
121
+ const stream = content.join('\n')
122
+
123
+ const objects: Array<Uint8Array | string> = []
124
+ const offsets: number[] = []
125
+ let length = 0
126
+ const push = (part: Uint8Array | string) => {
127
+ objects.push(part)
128
+ length += typeof part === 'string' ? part.length : part.length
129
+ }
130
+ push('%PDF-1.4\n%âãÏÓ\n')
131
+ const obj = (n: number, body: string, bin?: Uint8Array) => {
132
+ offsets[n] = length
133
+ push(`${n} 0 obj\n${body}\n`)
134
+ if (bin) {
135
+ push('stream\n')
136
+ push(bin)
137
+ push('\nendstream\n')
138
+ }
139
+ push('endobj\n')
140
+ }
141
+ obj(1, '<< /Type /Catalog /Pages 2 0 R >>')
142
+ obj(2, '<< /Type /Pages /Kids [3 0 R] /Count 1 >>')
143
+ obj(3, `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pw.toFixed(2)} ${ph.toFixed(2)}] /Resources << /XObject << /Im1 4 0 R >> /Font << /F1 5 0 R >> >> /Contents 6 0 R >>`)
144
+ obj(4, `<< /Type /XObject /Subtype /Image /Width ${jpeg.width} /Height ${jpeg.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpeg.bytes.length} >>`, jpeg.bytes)
145
+ obj(5, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>')
146
+ obj(6, `<< /Length ${stream.length} >>`, latin1(stream))
147
+ const xref = length
148
+ push(`xref\n0 7\n0000000000 65535 f \n`)
149
+ for (let n = 1; n <= 6; n += 1) push(`${String(offsets[n]).padStart(10, '0')} 00000 n \n`)
150
+ push(`trailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`)
151
+
152
+ const out = new Uint8Array(new ArrayBuffer(length))
153
+ let at = 0
154
+ for (const part of objects) {
155
+ const bytes = typeof part === 'string' ? latin1(part) : part
156
+ out.set(bytes, at)
157
+ at += bytes.length
158
+ }
159
+ return out
160
+ }
161
+
162
+ /** The chart as a one-page PDF `Blob`. */
163
+ export async function chartToPdfBlob(source: SVGSVGElement | HTMLElement, options: ChartPdfOptions = {}): Promise<Blob> {
164
+ const svg = source instanceof SVGSVGElement ? source : (source.querySelector('svg.sv-grid-chart-svg') ?? source.querySelector('svg'))
165
+ const background = options.background ?? (svg ? getComputedStyle(svg).getPropertyValue('--sg-bg').trim() || '#ffffff' : '#ffffff')
166
+ const png = await chartToPngBlob(source, { ...options, background, scale: options.scale ?? 2 })
167
+ const jpeg = await pngToJpeg(png, background, options.quality ?? 0.92)
168
+ const bytes = buildChartPdf(jpeg, options)
169
+ return new Blob([bytes.buffer as ArrayBuffer], { type: 'application/pdf' })
170
+ }
171
+
172
+ /** Download the chart as a `.pdf` file. */
173
+ export async function downloadChartPdf(source: SVGSVGElement | HTMLElement, filename = 'chart.pdf', options: ChartPdfOptions = {}): Promise<void> {
174
+ const blob = await chartToPdfBlob(source, options)
175
+ const url = URL.createObjectURL(blob)
176
+ const a = document.createElement('a')
177
+ a.href = url
178
+ a.download = filename
179
+ document.body.appendChild(a)
180
+ a.click()
181
+ a.remove()
182
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
183
+ }
184
+
185
+ /**
186
+ * Print the chart: a new window with the standalone SVG (and the title
187
+ * above it), sized to the page, that opens the print dialog once it has
188
+ * drawn. Returns false when the window was blocked.
189
+ */
190
+ export function printChart(source: SVGSVGElement | HTMLElement, options: ChartExportOptions & { title?: string } = {}): boolean {
191
+ const svg = chartToSvgString(source, options)
192
+ const win = window.open('', '_blank', 'width=900,height=700')
193
+ if (!win) return false
194
+ const esc = (s: string) => s.replace(/[&<>]/g, (m) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[m]!)
195
+ win.document.write(
196
+ `<!doctype html><html><head><meta charset="utf-8"><title>${esc(options.title ?? 'Chart')}</title>` +
197
+ `<style>body{margin:24px;font-family:system-ui,sans-serif}h1{font-size:16px;margin:0 0 12px}svg{max-width:100%;height:auto}@page{margin:12mm}</style></head><body>` +
198
+ (options.title ? `<h1>${esc(options.title)}</h1>` : '') +
199
+ svg +
200
+ `</body></html>`,
201
+ )
202
+ win.document.close()
203
+ const go = () => { win.focus(); win.print() }
204
+ if (win.document.readyState === 'complete') setTimeout(go, 50)
205
+ else win.addEventListener('load', () => setTimeout(go, 50))
206
+ return true
207
+ }
@@ -372,6 +372,20 @@ describe('downloadChartPng', () => {
372
372
  })
373
373
  })
374
374
 
375
+ describe('chartSpecToCsv: gaps', () => {
376
+ it('writes a NaN or null value as an empty cell, not the word NaN', () => {
377
+ const csv = chartSpecToCsv({
378
+ type: 'line',
379
+ categories: ['Jan', 'Feb', 'Mar'],
380
+ series: [
381
+ { label: 'Actual', values: [10, Number.NaN, 12] },
382
+ { label: 'Forecast', values: [null as unknown as number, 11, Number.NaN] },
383
+ ],
384
+ })
385
+ expect(csv.split('\n')).toEqual(['Category,Actual,Forecast', 'Jan,10,', 'Feb,,11', 'Mar,12,'])
386
+ })
387
+ })
388
+
375
389
  describe('chartSpecToCsv: series that carry more than one number', () => {
376
390
  it('keeps a plain series byte-identical', () => {
377
391
  const csv = chartSpecToCsv({
@@ -170,7 +170,9 @@ export async function downloadChartPng(
170
170
  }
171
171
 
172
172
  const csvCell = (v: unknown): string => {
173
- const s = v == null ? '' : String(v)
173
+ // A gap is an empty cell: `null` already was, and a NaN gap (the way a
174
+ // forecast series marks the months before it starts) wrote the word "NaN".
175
+ const s = v == null || (typeof v === 'number' && !Number.isFinite(v)) ? '' : String(v)
174
176
  return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
175
177
  }
176
178
 
@@ -180,7 +182,8 @@ const csvCell = (v: unknown): string => {
180
182
  *
181
183
  * A series that carries more than one number per category widens instead of
182
184
  * losing them. `ohlc` becomes four columns, `boxes` five plus its outliers,
183
- * `errors` two. This used to write `values` and nothing else, which quietly
185
+ * `errors` two, `lowValues` a low / high pair, `targets` a value / target
186
+ * pair. This used to write `values` and nothing else, which quietly
184
187
  * made "Export CSV" on a candlestick chart hand back the closing prices only -
185
188
  * the screen-reader table was given the four real numbers on purpose and the
186
189
  * export, which is what people actually take away, was not.
@@ -213,9 +216,11 @@ export function chartSpecToCsv(spec: ChartSpec): string {
213
216
  // bare label, so an existing export is byte-identical.
214
217
  const header: string[] = ['Category']
215
218
  for (const s of series) {
216
- if (s.ohlc) header.push(`${s.label} Open`, `${s.label} High`, `${s.label} Low`, `${s.label} Close`)
219
+ if (s.ohlc) header.push(`${s.label} Open`, `${s.label} High`, `${s.label} Low`, `${s.label} Close`, ...(s.volumes ? [`${s.label} Volume`] : []))
217
220
  else if (s.boxes) header.push(`${s.label} Min`, `${s.label} Q1`, `${s.label} Median`, `${s.label} Q3`, `${s.label} Max`, `${s.label} Outliers`)
218
221
  else if (s.errors) header.push(s.label, `${s.label} Low`, `${s.label} High`)
222
+ else if (s.lowValues) header.push(`${s.label} low`, `${s.label} high`)
223
+ else if (s.targets) header.push(s.label, `${s.label} target`)
219
224
  else header.push(s.label)
220
225
  }
221
226
 
@@ -225,6 +230,7 @@ export function chartSpecToCsv(spec: ChartSpec): string {
225
230
  if (s.ohlc) {
226
231
  const k = s.ohlc[i]
227
232
  cells.push(k?.o ?? '', k?.h ?? '', k?.l ?? '', k?.c ?? '')
233
+ if (s.volumes) cells.push(s.volumes[i] ?? '')
228
234
  } else if (s.boxes) {
229
235
  const b = s.boxes[i]
230
236
  // Outliers are a list inside one cell; csvCell quotes it.
@@ -235,6 +241,10 @@ export function chartSpecToCsv(spec: ChartSpec): string {
235
241
  const lo = e == null ? '' : typeof e === 'number' ? (v ?? 0) - Math.abs(e) : Math.min(e.lo, e.hi)
236
242
  const hi = e == null ? '' : typeof e === 'number' ? (v ?? 0) + Math.abs(e) : Math.max(e.lo, e.hi)
237
243
  cells.push(v ?? '', lo, hi)
244
+ } else if (s.lowValues) {
245
+ cells.push(s.lowValues[i] ?? '', s.values[i] ?? '')
246
+ } else if (s.targets) {
247
+ cells.push(s.values[i] ?? '', s.targets[i] ?? '')
238
248
  } else {
239
249
  cells.push(s.values[i] ?? '')
240
250
  }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Price-chart helpers: candle transforms, calendar resampling and the
3
+ * row-to-candlestick adapter. Indicators live in chart-indicators. DOM-free.
4
+ */
5
+ import type { ChartSeries, ChartSpec, OhlcBar, SeriesOverlay } from './chart-types'
6
+ import { bucketStart, type ChartTimeBucket } from './chart-stats'
7
+ import type { ChartIndicatorSpec } from './chart-indicators'
8
+
9
+ /**
10
+ * Heikin-Ashi ("average bar") candles from ordinary OHLC bars. Each close is
11
+ * the mean of the four prices, each open the midpoint of the previous
12
+ * Heikin-Ashi body, and the high / low stretch to include both. Runs of one
13
+ * colour then read as trends, at the cost of the true open and close.
14
+ * `null` bars (gaps) stay null and reset the chain.
15
+ */
16
+ export function heikinAshi(bars: ReadonlyArray<OhlcBar | null>): Array<OhlcBar | null> {
17
+ const out: Array<OhlcBar | null> = new Array(bars.length)
18
+ let prev: OhlcBar | null = null
19
+ for (let i = 0; i < bars.length; i += 1) {
20
+ const b = bars[i]
21
+ if (!b || ![b.o, b.h, b.l, b.c].every(Number.isFinite)) {
22
+ out[i] = null
23
+ prev = null
24
+ continue
25
+ }
26
+ const c = (b.o + b.h + b.l + b.c) / 4
27
+ const o = prev ? (prev.o + prev.c) / 2 : (b.o + b.c) / 2
28
+ const ha: OhlcBar = { o, c, h: Math.max(b.h, o, c), l: Math.min(b.l, o, c) }
29
+ out[i] = ha
30
+ prev = ha
31
+ }
32
+ return out
33
+ }
34
+
35
+ /**
36
+ * Roll daily (or finer) bars up to a coarser calendar bucket: the first
37
+ * open, the highest high, the lowest low, the last close, the summed volume.
38
+ * Categories are ISO dates and come back as the bucket starts (`bucketStart`),
39
+ * sorted; bars whose date does not parse are dropped. Pass `volumes` to get
40
+ * them summed alongside.
41
+ */
42
+ export function resampleOhlc(
43
+ categories: ReadonlyArray<string>,
44
+ ohlc: ReadonlyArray<OhlcBar | null>,
45
+ opts: { bucket: ChartTimeBucket; volumes?: ReadonlyArray<number> },
46
+ ): { categories: string[]; ohlc: Array<OhlcBar | null>; volumes?: number[] } {
47
+ const groups = new Map<string, { bar: OhlcBar | null; vol: number; first: number; last: number }>()
48
+ const order: Array<[string, number]> = []
49
+ for (let i = 0; i < categories.length; i += 1) {
50
+ const t = Date.parse(categories[i]!)
51
+ if (!Number.isFinite(t)) continue
52
+ const key = bucketStart(t, opts.bucket)
53
+ if (!key) continue
54
+ let g = groups.get(key)
55
+ if (!g) {
56
+ g = { bar: null, vol: 0, first: Infinity, last: -Infinity }
57
+ groups.set(key, g)
58
+ order.push([key, Date.parse(key)])
59
+ }
60
+ const b = ohlc[i]
61
+ if (b) {
62
+ // Rows may arrive out of order: the open is the EARLIEST bar's and the
63
+ // close the LATEST bar's, by date, not by position.
64
+ if (!g.bar) g.bar = { ...b }
65
+ else {
66
+ g.bar.h = Math.max(g.bar.h, b.h)
67
+ g.bar.l = Math.min(g.bar.l, b.l)
68
+ if (t >= g.last) g.bar.c = b.c
69
+ if (t <= g.first) g.bar.o = b.o
70
+ }
71
+ if (t > g.last) g.last = t
72
+ if (t < g.first) g.first = t
73
+ }
74
+ const v = opts.volumes?.[i]
75
+ if (Number.isFinite(v)) g.vol += v!
76
+ }
77
+ order.sort((a, b) => a[1] - b[1])
78
+ const out = {
79
+ categories: order.map(([k]) => k),
80
+ ohlc: order.map(([k]) => groups.get(k)!.bar),
81
+ ...(opts.volumes ? { volumes: order.map(([k]) => groups.get(k)!.vol) } : {}),
82
+ }
83
+ return out
84
+ }
85
+
86
+ /** What the grid's chart panel stores for a price chart: the column ids. */
87
+ export type OhlcColumns = { open: string | null; high: string | null; low: string | null; close: string | null; volume: string | null }
88
+
89
+ /**
90
+ * Guess the price columns from their names: the first numeric column called
91
+ * open / high / low / close (or last, price) / volume. What the chart panel
92
+ * shows before the reader picks anything; a picked column always wins.
93
+ */
94
+ export function guessOhlcColumns(
95
+ measures: ReadonlyArray<{ id: string; field: string; label: string }>,
96
+ picked?: Partial<OhlcColumns> | null,
97
+ ): OhlcColumns {
98
+ const guess = (re: RegExp) => measures.find((m) => re.test(m.field) || re.test(m.label))?.id ?? null
99
+ return {
100
+ open: picked?.open ?? guess(/open/i),
101
+ high: picked?.high ?? guess(/high/i),
102
+ low: picked?.low ?? guess(/low/i),
103
+ close: picked?.close ?? guess(/close|last|price/i),
104
+ volume: picked?.volume ?? guess(/vol/i),
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The `rowsToDirectSpec` options for the grid panel's price chart, or null
110
+ * while a price column is still unpicked. `fieldOf` maps a column id to its
111
+ * field name; the date column is the category.
112
+ */
113
+ export function ohlcDirectOptions(
114
+ fieldOf: (id: string | null) => string | null | undefined,
115
+ dateId: string | null,
116
+ cols: OhlcColumns,
117
+ extra: { bucket?: ChartTimeBucket | null; candleStyle?: ChartSpec['candleStyle'] | null },
118
+ ): { category: string; ohlc: NonNullable<Parameters<typeof import('./chart').rowsToDirectSpec>[2]['ohlc']> } | null {
119
+ const date = fieldOf(dateId)
120
+ const open = fieldOf(cols.open)
121
+ const high = fieldOf(cols.high)
122
+ const low = fieldOf(cols.low)
123
+ const close = fieldOf(cols.close)
124
+ if (!date || !open || !high || !low || !close) return null
125
+ const volume = fieldOf(cols.volume)
126
+ return {
127
+ category: date,
128
+ ohlc: {
129
+ open, high, low, close,
130
+ ...(volume ? { volume } : {}),
131
+ ...(extra.bucket ? { bucket: extra.bucket } : {}),
132
+ ...(extra.candleStyle ? { candleStyle: extra.candleStyle } : {}),
133
+ lastPriceLine: true,
134
+ },
135
+ }
136
+ }
137
+
138
+ /** The indicator names the grid's chart panel offers. */
139
+ export type ChartPanelIndicator = 'volume' | 'rsi' | 'macd' | 'stochastic' | 'atr' | 'obv' | 'sma' | 'ema' | 'bb' | 'vwap'
140
+
141
+ /**
142
+ * Split the panel's indicator list into the panes that go under the price
143
+ * (in order) and the one overlay that rides on it (a series carries a single
144
+ * `overlay`, so the first overlay kind wins).
145
+ */
146
+ export function splitPanelIndicators(kinds: ReadonlyArray<ChartPanelIndicator>): { panes: ChartIndicatorSpec[]; overlay: SeriesOverlay | null } {
147
+ const OVERLAY: Partial<Record<ChartPanelIndicator, SeriesOverlay>> = { sma: 'sma:20', ema: 'ema:20', bb: 'bb:20:2', vwap: 'vwap' }
148
+ const panes: ChartIndicatorSpec[] = []
149
+ let overlay: SeriesOverlay | null = null
150
+ for (const k of kinds) {
151
+ const o = OVERLAY[k]
152
+ if (o) { overlay ??= o; continue }
153
+ if (k === 'volume' || k === 'rsi' || k === 'macd' || k === 'stochastic' || k === 'atr' || k === 'obv') panes.push({ kind: k })
154
+ }
155
+ return { panes, overlay }
156
+ }
157
+
158
+ /**
159
+ * A candlestick spec straight from rows: one bar per date (sorted), with the
160
+ * volume alongside when a field is named, optionally rolled up to a calendar
161
+ * bucket first. The x axis is ordinal-time, so weekends and holidays leave no
162
+ * gaps.
163
+ */
164
+ export function rowsToOhlcSpec<T extends Record<string, unknown>>(
165
+ rows: ReadonlyArray<T>,
166
+ opts: {
167
+ date: keyof T & string
168
+ open: keyof T & string
169
+ high: keyof T & string
170
+ low: keyof T & string
171
+ close: keyof T & string
172
+ volume?: keyof T & string
173
+ bucket?: ChartTimeBucket
174
+ /** The series label. Default "Price". */
175
+ label?: string
176
+ type?: 'candlestick' | 'ohlc'
177
+ candleStyle?: ChartSpec['candleStyle']
178
+ lastPriceLine?: boolean
179
+ },
180
+ ): ChartSpec {
181
+ const num = (v: unknown): number => (typeof v === 'number' ? v : Number(v))
182
+ const sorted = rows
183
+ .map((r) => ({ r, t: Date.parse(String(r[opts.date])) }))
184
+ .filter((x) => Number.isFinite(x.t))
185
+ .sort((a, b) => a.t - b.t)
186
+ let categories = sorted.map((x) => new Date(x.t).toISOString().slice(0, 10))
187
+ let ohlc: Array<OhlcBar | null> = sorted.map(({ r }) => {
188
+ const b = { o: num(r[opts.open]), h: num(r[opts.high]), l: num(r[opts.low]), c: num(r[opts.close]) }
189
+ return [b.o, b.h, b.l, b.c].every(Number.isFinite) ? b : null
190
+ })
191
+ let volumes = opts.volume ? sorted.map(({ r }) => num(r[opts.volume!])) : undefined
192
+ if (opts.bucket) {
193
+ const rolled = resampleOhlc(categories, ohlc, { bucket: opts.bucket, volumes })
194
+ categories = rolled.categories
195
+ ohlc = rolled.ohlc
196
+ volumes = rolled.volumes
197
+ }
198
+ const label = opts.label ?? 'Price'
199
+ const series: ChartSeries = { label, values: ohlc.map((b) => (b ? b.c : Number.NaN)), ohlc, ...(volumes ? { volumes } : {}) }
200
+ return {
201
+ type: opts.type ?? 'candlestick',
202
+ categories,
203
+ series: [series],
204
+ xType: 'ordinal-time',
205
+ ...(opts.candleStyle ? { candleStyle: opts.candleStyle } : {}),
206
+ ...(opts.lastPriceLine ? { lastPriceLine: true } : {}),
207
+ }
208
+ }