diff --git a/package.json b/package.json index f152322c..387b7c60 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "typescript": "^5.0.0", "typescript-eslint": "^8.20.0", "vite": "^6.2.6", + "vite-plugin-commonjs": "^0.10.4", "vitest": "^3.0.0" }, "dependencies": { @@ -75,6 +76,8 @@ "apexcharts": "^4.7.0", "bits-ui": "^1.4.8", "bufferutil": "^4.0.9", + "canvas": "^3.1.2", + "chart.js": "^4.5.0", "d3": "^7.9.0", "d3-axis": "^3.0.0", "d3-scale": "^4.0.2", @@ -92,6 +95,7 @@ }, "pnpm": { "onlyBuiltDependencies": [ + "canvas", "esbuild", "svelte-preprocess" ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8887457d..5e20c760 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,12 @@ importers: bufferutil: specifier: ^4.0.9 version: 4.0.9 + canvas: + specifier: ^3.1.2 + version: 3.1.2 + chart.js: + specifier: ^4.5.0 + version: 4.5.0 d3: specifier: ^7.9.0 version: 7.9.0 @@ -122,7 +128,7 @@ importers: version: 6.6.3 '@testing-library/svelte': specifier: ^5.2.4 - version: 5.2.8(svelte@5.34.7)(vite@6.3.5(@types/node@24.0.3)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0))(vitest@3.2.4(@types/node@24.0.3)(jiti@2.4.2)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0)) + version: 5.2.8(svelte@5.34.7)(vite@6.3.5(@types/node@24.0.3)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0))(vitest@3.2.4(@types/node@24.0.3)(jiti@2.4.2)(jsdom@26.1.0(bufferutil@4.0.9)(canvas@3.1.2)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0)) '@types/d3': specifier: ^7.4.3 version: 7.4.3 @@ -158,7 +164,7 @@ importers: version: 8.0.3 jsdom: specifier: ^26.0.0 - version: 26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) + version: 26.1.0(bufferutil@4.0.9)(canvas@3.1.2)(utf-8-validate@6.0.5) lint-staged: specifier: ^16.1.2 version: 16.1.2 @@ -189,9 +195,12 @@ importers: vite: specifier: ^6.2.6 version: 6.3.5(@types/node@24.0.3)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0) + vite-plugin-commonjs: + specifier: ^0.10.4 + version: 0.10.4 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/node@24.0.3)(jiti@2.4.2)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0) + version: 3.2.4(@types/node@24.0.3)(jiti@2.4.2)(jsdom@26.1.0(bufferutil@4.0.9)(canvas@3.1.2)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0) packages: @@ -1189,6 +1198,9 @@ packages: '@jridgewell/trace-mapping@0.3.29': resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@layerstack/svelte-actions@1.0.1': resolution: {integrity: sha512-Tv8B3TeT7oaghx0R0I4avnSdfAT6GxEK+StL8k/hEaa009iNOIGFl3f76kfvNvPioQHAMFGtnWGLPHfsfD41nQ==} @@ -2238,6 +2250,9 @@ packages: peerDependencies: svelte: ^5.11.0 + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2264,6 +2279,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bufferutil@4.0.9: resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==} engines: {node: '>=6.14.2'} @@ -2299,6 +2317,10 @@ packages: caniuse-lite@1.0.30001726: resolution: {integrity: sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==} + canvas@3.1.2: + resolution: {integrity: sha512-Z/tzFAcBzoCvJlOSlCnoekh1Gu8YMn0J51+UAuXJAbW1Z6I9l2mZgdD7738MepoeeIcUdDtbMnOg6cC7GJxy/g==} + engines: {node: ^18.12.0 || >= 20.9.0} + canvg@3.0.11: resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} engines: {node: '>=10.0.0'} @@ -2328,6 +2350,10 @@ packages: character-reference-invalid@1.1.4: resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==} + chart.js@4.5.0: + resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==} + engines: {pnpm: '>=8'} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -2340,6 +2366,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -2651,6 +2680,10 @@ packages: decimal.js@10.5.0: resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + dedent@1.5.1: resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==} peerDependencies: @@ -2746,6 +2779,9 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.18.1: resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} engines: {node: '>=10.13.0'} @@ -2905,6 +2941,10 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.2.1: resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} @@ -3001,6 +3041,9 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@9.1.0: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} @@ -3051,6 +3094,9 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3207,6 +3253,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} @@ -3681,6 +3730,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -3708,6 +3761,9 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -3716,6 +3772,9 @@ packages: resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} engines: {node: '>= 18'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} @@ -3747,6 +3806,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3757,9 +3819,16 @@ packages: next-tick@1.1.0: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} + node-abi@3.75.0: + resolution: {integrity: sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==} + engines: {node: '>=10'} + node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-addon-api@8.4.0: resolution: {integrity: sha512-D9DI/gXHvVmjHS08SVch0Em8G5S1P+QWtU31appcKT/8wFSPRcdHadIFSAntdMMVM5zz+/DL+bL/gz3UDppqtg==} engines: {node: ^18 || ^20 || >= 21} @@ -4017,6 +4086,11 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -4126,6 +4200,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4159,6 +4236,10 @@ packages: randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-copy-to-clipboard@5.1.0: resolution: {integrity: sha512-k61RsNgAayIJNoy9yDsYzDe/yAZAzEbEgcz3DZMhF686LEyukcE1hzurxe85JandPUG+yTfGVFzuEw3xt8WP/A==} peerDependencies: @@ -4221,6 +4302,10 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -4464,6 +4549,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sirv@3.0.1: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} @@ -4555,6 +4646,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-object@3.3.0: resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} engines: {node: '>=4'} @@ -4575,6 +4669,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4725,6 +4823,13 @@ packages: resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} engines: {node: '>=6'} + tar-fs@2.1.3: + resolution: {integrity: sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} @@ -4848,6 +4953,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4981,6 +5089,12 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite-plugin-commonjs@0.10.4: + resolution: {integrity: sha512-eWQuvQKCcx0QYB5e5xfxBNjQKyrjEWZIR9UOkOV6JAgxVhtbZvCOF+FNC2ZijBJ3U3Px04ZMMyyMyFBVWIJ5+g==} + + vite-plugin-dynamic-import@1.6.0: + resolution: {integrity: sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg==} + vite-plugin-pwa@1.0.1: resolution: {integrity: sha512-STyUomQbydj7vGamtgQYIJI0YsUZ3T4pJLGBQDQPhzMse6aGSncmEN21OV35PrFsmCvmtiH+Nu1JS1ke4RqBjQ==} engines: {node: '>=16.0.0'} @@ -6308,6 +6422,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@kurkle/color@0.3.4': {} + '@layerstack/svelte-actions@1.0.1': dependencies: '@floating-ui/dom': 1.7.1 @@ -7186,13 +7302,13 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/svelte@5.2.8(svelte@5.34.7)(vite@6.3.5(@types/node@24.0.3)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0))(vitest@3.2.4(@types/node@24.0.3)(jiti@2.4.2)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0))': + '@testing-library/svelte@5.2.8(svelte@5.34.7)(vite@6.3.5(@types/node@24.0.3)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0))(vitest@3.2.4(@types/node@24.0.3)(jiti@2.4.2)(jsdom@26.1.0(bufferutil@4.0.9)(canvas@3.1.2)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0))': dependencies: '@testing-library/dom': 10.4.0 svelte: 5.34.7 optionalDependencies: vite: 6.3.5(@types/node@24.0.3)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0) - vitest: 3.2.4(@types/node@24.0.3)(jiti@2.4.2)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0) + vitest: 3.2.4(@types/node@24.0.3)(jiti@2.4.2)(jsdom@26.1.0(bufferutil@4.0.9)(canvas@3.1.2)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0) '@tree-sitter-grammars/tree-sitter-yaml@0.7.1(tree-sitter@0.22.4)': dependencies: @@ -7727,6 +7843,12 @@ snapshots: svelte-toolbelt: 0.7.1(svelte@5.34.7) tabbable: 6.2.0 + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -7755,6 +7877,11 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bufferutil@4.0.9: dependencies: node-gyp-build: 4.8.4 @@ -7786,6 +7913,11 @@ snapshots: caniuse-lite@1.0.30001726: {} + canvas@3.1.2: + dependencies: + node-addon-api: 7.1.1 + prebuild-install: 7.1.3 + canvg@3.0.11: dependencies: '@babel/runtime': 7.27.6 @@ -7824,6 +7956,10 @@ snapshots: character-reference-invalid@1.1.4: {} + chart.js@4.5.0: + dependencies: + '@kurkle/color': 0.3.4 + check-error@2.1.1: {} chokidar@3.6.0: @@ -7842,6 +7978,8 @@ snapshots: dependencies: readdirp: 4.1.2 + chownr@1.1.4: {} + chownr@3.0.0: {} classnames@2.5.1: {} @@ -8157,6 +8295,10 @@ snapshots: decimal.js@10.5.0: {} + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + dedent@1.5.1: {} deep-eql@5.0.2: {} @@ -8227,6 +8369,10 @@ snapshots: emoji-regex@9.2.2: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.18.1: dependencies: graceful-fs: 4.2.11 @@ -8520,6 +8666,8 @@ snapshots: eventemitter3@5.0.1: {} + expand-template@2.0.3: {} + expect-type@1.2.1: {} ext@1.7.0: @@ -8617,6 +8765,8 @@ snapshots: format@0.2.2: {} + fs-constants@1.0.0: {} + fs-extra@9.1.0: dependencies: at-least-node: 1.0.0 @@ -8675,6 +8825,8 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -8818,6 +8970,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + inline-style-parser@0.2.4: {} internal-slot@1.1.0: @@ -9024,7 +9178,7 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5): + jsdom@26.1.0(bufferutil@4.0.9)(canvas@3.1.2)(utf-8-validate@6.0.5): dependencies: cssstyle: 4.5.0 data-urls: 5.0.0 @@ -9046,6 +9200,8 @@ snapshots: whatwg-url: 14.2.0 ws: 8.18.2(bufferutil@4.0.9)(utf-8-validate@6.0.5) xml-name-validator: 5.0.0 + optionalDependencies: + canvas: 3.1.2 transitivePeerDependencies: - bufferutil - supports-color @@ -9279,6 +9435,8 @@ snapshots: mimic-function@5.0.1: {} + mimic-response@3.1.0: {} + min-indent@1.0.1: {} mini-svg-data-uri@1.4.4: {} @@ -9303,12 +9461,16 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimist@1.2.8: {} + minipass@7.1.2: {} minizlib@3.0.2: dependencies: minipass: 7.1.2 + mkdirp-classic@0.5.3: {} + mkdirp@3.0.1: {} moment@2.30.1: {} @@ -9329,14 +9491,22 @@ snapshots: nanoid@3.3.11: {} + napi-build-utils@2.0.0: {} + natural-compare@1.4.0: {} neotraverse@0.6.18: {} next-tick@1.1.0: {} + node-abi@3.75.0: + dependencies: + semver: 7.7.2 + node-abort-controller@3.1.1: {} + node-addon-api@7.1.1: {} + node-addon-api@8.4.0: optional: true @@ -9560,6 +9730,21 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.0.4 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.75.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.3 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} prettier-plugin-svelte@3.4.0(prettier@3.5.3)(svelte@5.34.7): @@ -9607,6 +9792,11 @@ snapshots: proxy-from-env@1.1.0: {} + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} qs@6.14.0: @@ -9637,6 +9827,13 @@ snapshots: dependencies: safe-buffer: 5.2.1 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-copy-to-clipboard@5.1.0(react@18.3.1): dependencies: copy-to-clipboard: 3.3.3 @@ -9700,6 +9897,12 @@ snapshots: dependencies: pify: 2.3.0 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -9976,6 +10179,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + sirv@3.0.1: dependencies: '@polka/url': 1.0.0-next.29 @@ -10089,6 +10300,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-object@3.3.0: dependencies: get-own-enumerable-property-symbols: 3.0.2 @@ -10109,6 +10324,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} strip-literal@3.0.0: @@ -10389,6 +10606,21 @@ snapshots: tapable@2.2.2: {} + tar-fs@2.1.3: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar@7.4.3: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -10514,6 +10746,10 @@ snapshots: tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -10674,6 +10910,19 @@ snapshots: - tsx - yaml + vite-plugin-commonjs@0.10.4: + dependencies: + acorn: 8.15.0 + magic-string: 0.30.17 + vite-plugin-dynamic-import: 1.6.0 + + vite-plugin-dynamic-import@1.6.0: + dependencies: + acorn: 8.15.0 + es-module-lexer: 1.7.0 + fast-glob: 3.3.3 + magic-string: 0.30.17 + vite-plugin-pwa@1.0.1(vite@6.3.5(@types/node@24.0.3)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0))(workbox-build@7.3.0)(workbox-window@7.3.0): dependencies: debug: 4.4.1 @@ -10705,7 +10954,7 @@ snapshots: optionalDependencies: vite: 6.3.5(@types/node@24.0.3)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0) - vitest@3.2.4(@types/node@24.0.3)(jiti@2.4.2)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0): + vitest@3.2.4(@types/node@24.0.3)(jiti@2.4.2)(jsdom@26.1.0(bufferutil@4.0.9)(canvas@3.1.2)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -10732,7 +10981,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.0.3 - jsdom: 26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) + jsdom: 26.1.0(bufferutil@4.0.9)(canvas@3.1.2)(utf-8-validate@6.0.5) transitivePeerDependencies: - jiti - less diff --git a/src/hooks.client.ts b/src/hooks.client.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/lib/models/Gateway.ts b/src/lib/models/Gateway.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/lib/pdf/index.ts b/src/lib/pdf/index.ts index 921e50ce..af7b518d 100644 --- a/src/lib/pdf/index.ts +++ b/src/lib/pdf/index.ts @@ -1,5 +1,6 @@ export interface TableCell { label: string; + shortLabel?: string; value?: number | string | Date; color?: string; bgColor?: string; diff --git a/src/lib/pdf/pdfDataTable.ts b/src/lib/pdf/pdfDataTable.ts index 825f5765..ad9bffaf 100644 --- a/src/lib/pdf/pdfDataTable.ts +++ b/src/lib/pdf/pdfDataTable.ts @@ -7,7 +7,6 @@ interface TableConfig { rowsPerColumn: number; cellWidth: number; cellHeight: number; - margin: number; columnMargin: number; fontSize: number; headerHeight: number; @@ -19,7 +18,6 @@ const DEFAULT_CONFIG: TableConfig = { rowsPerColumn: 30, cellWidth: 100, cellHeight: 12, - margin: 40, columnMargin: 10, fontSize: 7, headerHeight: 15 @@ -46,17 +44,22 @@ export function createPDFDataTable({ }): void { const conf = { ...DEFAULT_CONFIG, ...config }; - const { caption, margin, headerHeight, cellWidth, cellHeight, columnsPerPage, columnMargin } = - conf; + const { caption, headerHeight, cellWidth, cellHeight, columnsPerPage, columnMargin } = conf; + + const { + top: marginTop, + right: marginRight, + bottom: marginBottom, + left: marginLeft + } = doc.page.margins; // Calculate how many rows can actually fit on a page const pageHeight = doc.page.height; - const availableHeight = pageHeight - margin * 2 - headerHeight - 50; // 50 for caption space - const actualRowsPerColumn = Math.floor(availableHeight / cellHeight); + const contentHeight = pageHeight - marginTop - marginBottom; // Calculate how many columns can actually fit on a page const pageWidth = doc.page.width; - const availableWidth = pageWidth - margin * 2; + const availableWidth = pageWidth - marginLeft - marginRight; const columnWidth = [dataHeader.header, ...dataHeader.cells].reduce( (total, col) => total + (col.width ?? cellWidth), 0 @@ -66,28 +69,33 @@ export function createPDFDataTable({ const finalColumnsPerPage = Math.min(columnsPerPage, actualColumnsPerPage); if (caption) { - doc.fillColor('black').fontSize(12).text(caption, margin, doc.y); + doc.fillColor('black').fontSize(12).text(caption, marginLeft, doc.y); doc.moveDown(0.5); } - let currentPage = 0; let dataIndex = 0; let startY = doc.y; while (dataIndex < dataRows.length) { - if (currentPage > 0) { - doc.addPage(); - startY = margin; - } + // Draw columns for current page + for (let col = 0; col < finalColumnsPerPage && dataIndex < dataRows.length; col++) { + const firstColumn = col % finalColumnsPerPage === 0; - const totalColumns = Math.min( - finalColumnsPerPage, - Math.ceil((dataRows.length - dataIndex) / actualRowsPerColumn) - ); + if (firstColumn) { + startY = doc.y; + } - // Draw columns for current page - for (let col = 0; col < totalColumns && dataIndex < dataRows.length; col++) { - const startX = margin + col * (columnWidth + columnMargin); + let availableHeight = pageHeight - startY - marginBottom - headerHeight; + + // Insert a new page if we are at the bottom of the current page + if (firstColumn && availableHeight < 200) { + doc.addPage(); + startY = marginTop; + availableHeight = contentHeight - headerHeight; + } + + const actualRowsPerColumn = Math.floor(availableHeight / cellHeight); + const startX = marginLeft + col * (columnWidth + columnMargin); const endIndex = Math.min(dataIndex + actualRowsPerColumn, dataRows.length); drawColumn({ @@ -99,10 +107,9 @@ export function createPDFDataTable({ startY, config: conf }); + dataIndex = endIndex; } - - currentPage++; } } @@ -151,8 +158,8 @@ function drawColumn({ currentY += headerHeight; // Draw data rows - dataRows.forEach(({ header, cells }, index) => { - const isEvenRow = index % 2 === 0; + dataRows.forEach(({ header, cells }, rowIndex) => { + const isEvenRow = rowIndex % 2 === 0; // Row background if (!isEvenRow) { @@ -164,7 +171,7 @@ function drawColumn({ // Row border doc.strokeColor(borderColor).rect(startX, currentY, columnWidth, cellHeight).stroke(); - [header, ...cells].forEach(({ label, bgColor }, cellIndex) => { + [header, ...cells].forEach(({ label, shortLabel, bgColor }, cellIndex) => { const cellX = getCellX(cellIndex); const { width = cellWidth, fontSize = defaultFontSize } = columns[cellIndex]; @@ -176,11 +183,23 @@ function drawColumn({ // Cell border doc.strokeColor(borderColor).rect(cellX, currentY, width, cellHeight).stroke(); + let labelText = label; + + // If the row is not the first one and a short label is provided, check if we can use it + if (rowIndex > 0 && shortLabel) { + const previousLabel = dataRows[rowIndex - 1]?.header.label; + + // If the previous date is the same as the current date, use the short label + if (previousLabel.split(' ')[0] === label.split(' ')[0]) { + labelText = shortLabel; + } + } + // Value text doc .fillColor('#000') .fontSize(fontSize - 1) - .text(label, cellX + 1, currentY + 2, { width: width - 5, align: 'right' }); + .text(labelText, cellX + 1, currentY + 2, { width: width - 5, align: 'right' }); }); currentY += cellHeight; diff --git a/src/lib/pdf/pdfFooterPageNumber.ts b/src/lib/pdf/pdfFooterPageNumber.ts index 32871de7..a78b74f5 100644 --- a/src/lib/pdf/pdfFooterPageNumber.ts +++ b/src/lib/pdf/pdfFooterPageNumber.ts @@ -1,5 +1,8 @@ import PDFDocument from 'pdfkit'; +const footerFontSize = 7; +const footerMargin = 20; + export function addFooterPageNumber( doc: InstanceType, primaryText: string @@ -7,19 +10,25 @@ export function addFooterPageNumber( // 3) now stamp page numbers on every page const range = doc.bufferedPageRange(); // { start: 0, count: N } const total = range.count; - const footerFontSize = 7; - const footerMargin = 20; + const options = { + width: doc.page.width - 20 - 20, + height: footerFontSize + 2, + lineBreak: false + }; + for (let i = 0; i < total; i++) { doc.switchToPage(i); - const text = `${primaryText} | Page ${i + 1} / ${total}`; - const w = doc.widthOfString(text); - const x = doc.page.width / 2 - w / 2; // center the text + + const x = 20; const y = doc.page.height - footerMargin - 5; + doc .fontSize(footerFontSize) .fillColor('black') - .text(text, x, y, { align: 'center', lineBreak: false }); + .text(primaryText, x, y, { ...options, align: 'left' }) + .text(`Page ${i + 1} / ${total}`, x, y, { ...options, align: 'right' }); } + // 4) go back to the last page so that any post‐footer work (if any) ends up there doc.switchToPage(total - 1); } diff --git a/src/lib/pdf/pdfLineChart.ts b/src/lib/pdf/pdfLineChart.ts index 46f52971..4445b8f7 100644 --- a/src/lib/pdf/pdfLineChart.ts +++ b/src/lib/pdf/pdfLineChart.ts @@ -94,23 +94,38 @@ export function createPDFLineChart({ const allValues = dataRows.flatMap((d) => d.cells.map(({ value }) => value).filter((value) => typeof value === 'number' && !isNaN(value)) ) as number[]; + + // Handle case where there are no valid numeric values + if (allValues.length === 0) { + console.warn('No valid numeric values found for line chart'); + return; + } + const minValue = Math.min(...allValues); const maxValue = Math.max(...allValues); const valueRange = maxValue - minValue; - const paddedMin = minValue - valueRange * 0.1; - const paddedMax = maxValue + valueRange * 0.1; + + // Handle case where all values are the same (valueRange = 0) + const paddedMin = valueRange === 0 ? minValue - 1 : minValue - valueRange * 0.1; + const paddedMax = valueRange === 0 ? maxValue + 1 : maxValue + valueRange * 0.1; // Time range const minTime = Math.min(...timestamps.map((t) => t.getTime())); const maxTime = Math.max(...timestamps.map((t) => t.getTime())); + const timeRange = maxTime - minTime; // Scaling functions const xScale = (timestamp: Date) => { - return chartX + ((timestamp.getTime() - minTime) / (maxTime - minTime)) * chartWidth; + return timeRange === 0 + ? chartX + chartWidth / 2 + : chartX + ((timestamp.getTime() - minTime) / timeRange) * chartWidth; }; const yScale = (value: number) => { - return chartY + chartHeight - ((value - paddedMin) / (paddedMax - paddedMin)) * chartHeight; + const paddedRange = paddedMax - paddedMin; + return paddedRange === 0 + ? chartY + chartHeight / 2 + : chartY + chartHeight - ((value - paddedMin) / paddedRange) * chartHeight; }; // Draw title @@ -259,7 +274,7 @@ export function createPDFLineChart({ if (conf.yAxisLabel) { doc .save() - .rotate(-90, chartX - 60, chartY + chartHeight / 2) + .rotate(-90) .fontSize(fontSize) .text(conf.yAxisLabel, chartX - 60, chartY + chartHeight / 2, { align: 'center' diff --git a/src/lib/pdf/pdfLineChartImage.ts b/src/lib/pdf/pdfLineChartImage.ts new file mode 100644 index 00000000..7a3a9b1b --- /dev/null +++ b/src/lib/pdf/pdfLineChartImage.ts @@ -0,0 +1,115 @@ +import type { ReportAlertPoint } from '$lib/models/Report'; +import { createCanvas } from 'canvas'; +import { + CategoryScale, + Chart, + LinearScale, + LineController, + LineElement, + PointElement, + type ChartData, + type ChartOptions +} from 'chart.js'; +import PDFDocument from 'pdfkit'; +import type { TableRow } from '.'; + +interface ChartConfig { + title?: string; + width: number; + height: number; + options?: ChartOptions; +} + +Chart.register([CategoryScale, LineController, LineElement, LinearScale, PointElement]); +Chart.defaults.devicePixelRatio = 3; +Chart.defaults.font.size = 20; + +const DEFAULT_CHART_OPTIONS: ChartOptions = { + elements: { + line: { + borderWidth: 4, + tension: 0.2 // Smooth line + }, + point: { + radius: 0 // No points on the line + } + } +}; + +/** + * Creates a line chart image using Chart.js and returns it as a buffer. + * @returns A buffer containing the image data for a line chart. + * @see https://www.chartjs.org/docs/latest/getting-started/using-from-node-js.html + */ +const createImage = ({ + data, + options, + width, + height +}: { + data: ChartData; + options?: ChartOptions; + width: number; + height: number; +}): Buffer => { + const canvas = createCanvas(width, height); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const chart = new Chart(canvas as any, { type: 'line', data, options }); + const buffer = canvas.toBuffer(); + + chart.destroy(); + + return buffer; +}; + +/** + * Creates a line chart in a PDFKit document + * @param {object} params + * @param params.doc - PDFKit document instance + * @param params.dataHeader - Array of column definitions + * @param params.dataRows - Array of data rows, each row is an array of values + * @param params.config - Chart configuration options + */ +export const createPDFLineChartImage = ({ + doc, + dataHeader, + dataRows, + config = {} +}: { + doc: InstanceType; + dataHeader: TableRow; + dataRows: TableRow[]; + alertPoints: ReportAlertPoint[]; + config?: Partial; +}): void => { + if (!dataRows?.length) { + console.warn('No data provided for line chart'); + return; + } + + const { left: marinLeft, right: marginRight } = doc.page.margins; + const { title, width = 400, height = 300, options = DEFAULT_CHART_OPTIONS } = config; + + const data: ChartData = { + labels: dataRows.map((row) => row.header.shortLabel || row.header.label), + datasets: [ + { + label: dataHeader.header.label, + data: dataRows.map((row) => row.cells[0].value as number), + borderColor: dataHeader.cells[0].color || 'blue' + } + ] + }; + + const buffer = createImage({ data, options, width, height }); + + doc.x = marinLeft; + doc.image(buffer, { width, height }); + + if (title) { + doc.fontSize(8).text(title, marinLeft, doc.y, { + width: doc.page.width - marinLeft - marginRight, + align: 'center' + }); + } +}; diff --git a/src/lib/repositories/GatewayRepository.ts b/src/lib/repositories/GatewayRepository.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/lib/services/DashboardService.ts b/src/lib/services/DashboardService.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/lib/services/DeviceDataService.ts b/src/lib/services/DeviceDataService.ts index bab765d6..ac95a050 100644 --- a/src/lib/services/DeviceDataService.ts +++ b/src/lib/services/DeviceDataService.ts @@ -288,44 +288,44 @@ export class DeviceDataService implements IDeviceDataService { try { // If no filtering parameters provided, get alert points from the device's reports - let p_columns = columns || ['temperature_c', 'humidity']; + let p_columns = columns.length === 0 ? columns : ['temperature_c', 'humidity']; let p_ops = ops || ['>', 'BETWEEN']; let p_mins = mins || [25.0, 55.0]; let p_maxs = maxs || [null, 65.0]; // If no explicit filters provided, try to get from device reports - if (!columns && !ops && !mins && !maxs) { - try { - // Get the first report for this device to extract alert points - const { data: reports, error: reportError } = await this.supabase - .from('reports') - .select('report_id, report_alert_points(*)') - .eq('dev_eui', devEui) - .limit(1); - - if (!reportError && reports && reports.length > 0 && reports[0].report_alert_points) { - const alertPoints = reports[0].report_alert_points; - if (alertPoints && alertPoints.length > 0) { - p_columns = []; - p_ops = []; - p_mins = []; - p_maxs = []; - - alertPoints.forEach((point: any) => { - if (point.data_point_key) { - p_columns.push(point.data_point_key); - p_ops.push(point.operator || '>'); - p_mins.push(point.min || point.value || 0); - p_maxs.push(point.max || null); - } - }); - } + // if (!columns && !ops && !mins && !maxs) { + try { + // Get the first report for this device to extract alert points + const { data: reports, error: reportError } = await this.supabase + .from('reports') + .select('report_id, report_alert_points(*)') + .eq('dev_eui', devEui) + .limit(1); + + if (!reportError && reports && reports.length > 0 && reports[0].report_alert_points) { + const alertPoints = reports[0].report_alert_points; + if (alertPoints && alertPoints.length > 0) { + p_columns = []; + p_ops = []; + p_mins = []; + p_maxs = []; + + alertPoints.forEach((point: any) => { + if (point.data_point_key) { + p_columns.push(point.data_point_key); + p_ops.push(point.operator || '>'); + p_mins.push(point.min || point.value || 0); + p_maxs.push(point.max || null); + } + }); } - } catch (alertError) { - // If we can't get alert points, use default values - console.warn('Could not load alert points, using defaults:', alertError); } + } catch (alertError) { + // If we can't get alert points, use default values + console.warn('Could not load alert points, using defaults:', alertError); } + // } const { data, error: deviceError } = await this.supabase.rpc( 'get_filtered_device_report_data_multi', diff --git a/src/lib/services/GatewayService.ts b/src/lib/services/GatewayService.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/routes/api/devices/[devEui]/pdf/+server.ts b/src/routes/api/devices/[devEui]/pdf/+server.ts index ff27f8ed..bec6d2a9 100644 --- a/src/routes/api/devices/[devEui]/pdf/+server.ts +++ b/src/routes/api/devices/[devEui]/pdf/+server.ts @@ -5,7 +5,7 @@ import type { ReportAlertPoint } from '$lib/models/Report'; import type { TableCell, TableRow } from '$lib/pdf'; import { createPDFDataTable } from '$lib/pdf/pdfDataTable'; import { addFooterPageNumber } from '$lib/pdf/pdfFooterPageNumber'; -import { createPDFLineChart } from '$lib/pdf/pdfLineChart'; +import { createPDFLineChartImage } from '$lib/pdf/pdfLineChartImage'; import { checkMatch, getValue } from '$lib/pdf/utils'; import { DeviceRepository } from '$lib/repositories/DeviceRepository'; import { LocationRepository } from '$lib/repositories/LocationRepository'; @@ -73,19 +73,57 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) .map((key) => key.trim()) .filter(Boolean); - const requestedAlertPoints = (() => { - try { - const points = alertPointsParam ? JSON.parse(alertPointsParam) : []; + // const requestedAlertPoints = (() => { + // try { + // const points = alertPointsParam ? JSON.parse(alertPointsParam) : []; + + // if (!Array.isArray(points)) { + // throw new Error('alertPoints must be an array'); + // } + + // return points; + // } catch { + // return []; + // } + // })() as ReportAlertPoint[]; + // Pull alert points from the database if not provided + const { data: reportParams, error } = await supabase + .from('reports') + .select('report_id, report_alert_points(*)') + .eq('dev_eui', devEui) + .limit(1) // This intruduces a bug where it will ignore multiple reports.... + .single(); + console.log('alertPointsParamData', reportParams); - if (!Array.isArray(points)) { - throw new Error('alertPoints must be an array'); - } + if (error) { + console.error('Error fetching report parameters:', error.message); + return json( + { error: `Failed to fetch report parameters - ${error.message}` }, + { status: 500 } + ); + } - return points; - } catch { - return []; + let requestedAlertPoints: ReportAlertPoint[] = []; + for (const point of reportParams?.report_alert_points || []) { + if (!point.data_point_key) { + console.warn(`Alert point with ID ${point.id} has no data_point_key, skipping this point`); + continue; } - })() as ReportAlertPoint[]; + const pooint = { + created_at: point.created_at, + data_point_key: point.data_point_key, + hex_color: point.hex_color || '#ffffff', + id: point.id, + max: point.max ?? null, + min: point.min ?? null, + name: point.name || point.data_point_key, + operator: point.operator || 'eq', + report_id: point.report_id, + user_id: point.user_id, + value: point.value ?? 0 + }; + requestedAlertPoints.push(pooint as ReportAlertPoint); + } // Determine if this is a report or a specific data request const isReport = !requestedAlertPoints.length; @@ -219,6 +257,15 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) bufferPages: true }); + const { + top: marginTop, + right: marginRight, + bottom: marginBottom, + left: marginLeft + } = doc.page.margins; + + const contentWidth = doc.page.width - marginLeft - marginRight; + // Define possible font paths for NotoSansJP (Japanese font support) const possibleFontPaths = [ path.join(process.cwd(), 'static/fonts/NotoSansJP-Regular.ttf'), @@ -249,42 +296,56 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) // Professional header with Japanese styling doc.fontSize(16).text(`CropWatch ${$_('device_report')}`); - doc.fontSize(10).moveDown(); + + // Signature section + doc.fontSize(7).strokeColor('#ccc'); + doc.rect(400, marginTop, 50, 60).stroke(); + doc.rect(450, marginTop, 50, 60).stroke(); + doc.rect(500, marginTop, 50, 60).stroke(); + doc.text($_('created'), 405, 45); + doc.text($_('verified'), 455, 45); + doc.text($_('approved'), 505, 45); + + doc.x = marginLeft; + doc.y = 70; + + const metaTextOptions = { width: 320 }; // Report metadata doc + .fontSize(8) .text( `${$_('generated_at')}: ${DateTime.now().setZone('Asia/Tokyo').toFormat('yyyy-MM-dd HH:mm:ss')}`, - { width: 280 } + metaTextOptions ) - .text(`${$_('generated_by')}: ${userProfile?.full_name || user.email || $_('Unknown')}`, { - width: 280 - }) - .text(`${$_('Company')}: ${userProfile?.employer || $_('Unknown')}`, { width: 280 }) - .text(`${$_('Device Type')}: ${device.cw_device_type?.name || $_('Unknown')}`, { - width: 280 - }) - .text(`${$_('Device Name')}: ${device.name || $_('Unknown')}`, { width: 280 }) - .text(`${$_('EUI')}: ${devEui}`, { width: 280 }) - .text(`${$_('installed_at')}: ${location.name || $_('Unknown')}`, { width: 280 }); - - // Date, signature section - doc.rect(320, 40, 240, 40).stroke(); - doc.fontSize(10).text($_('date'), 325, 45); - doc.rect(320, 85, 80, 100).stroke(); - doc.text($_('created'), 325, 90); - doc.rect(400, 85, 80, 100).stroke(); - doc.text($_('verified'), 405, 90); - doc.rect(480, 85, 80, 100).stroke(); - doc.text($_('approved'), 485, 90); - doc.fontSize(12).text($_('comment'), 320, 200); + .text( + `${$_('generated_by')}: ${userProfile?.full_name || user.email || $_('Unknown')}`, + metaTextOptions + ); + + if (userProfile?.employer) { + doc.text(`${$_('Company')}: ${userProfile?.employer || $_('Unknown')}`, metaTextOptions); + } doc - .fontSize(10) - .text(`${$_('date_range')}: ${startDateParam} – ${endDateParam}`, 40, 200) + .moveDown(0.5) + .text(`${$_('installed_at')}: ${location.name || $_('Unknown')}`, metaTextOptions) + .text( + `${$_('Device Type')}: ${device.cw_device_type?.name || $_('Unknown')}`, + metaTextOptions + ) + .text(`${$_('Device Name')}: ${device.name || $_('Unknown')}`, metaTextOptions) + .text(`${$_('EUI')}: ${devEui}`, metaTextOptions); + + doc.moveUp(2); + doc.x = 400; + + doc + .text(`${$_('date_range')}: ${startDateParam} – ${endDateParam}`) .text(`${$_('sampling_size')}: ${deviceData.length}`); - doc.moveDown(4); + doc.x = marginLeft; + doc.moveDown(); const numericKeys = getNumericKeys(deviceData); const validKeys = @@ -295,65 +356,52 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) const keyColumns: TableCell[] = validKeys.map((key) => ({ label: $_(key), value: '', - width: 40, + width: 30, color: getColorNameByKey(key) })); const dataHeader: TableRow = { - header: { label: $_('datetime'), value: '', width: 60 }, - cells: [...keyColumns, { label: $_('comment'), width: 60 }] + header: { label: $_('datetime'), value: '', width: 40 }, + cells: [...keyColumns, { label: $_('comment'), width: 40 }] }; const getDataRows = (keys: string[] = validKeys): TableRow[] => - deviceData.map((data) => ({ - header: { - label: DateTime.fromISO(data.created_at) - .setZone('Asia/Tokyo') - .toFormat('yyyy-MM-dd HH:mm'), - value: new Date(data.created_at) - }, - cells: keys.map((key) => { - const value = data[key] as number; - - return { - label: - typeof data[key] === 'number' - ? formatNumber({ key, value, adjustFractionDigits: true }) - : '', - value, - bgColor: - alertPoints.find((point) => point.data_point_key === key && checkMatch(value, point)) - ?.hex_color ?? '#ffffff' - }; - }) - })); - - // Chart - for (const key of validKeys) { - createPDFLineChart({ - doc, - dataHeader: { - header: { label: $_('datetime'), value: '', width: 60 }, - cells: [ - { - label: $_(key), - value: '', - width: 40, - color: getColorNameByKey(key) - } - ] - }, - dataRows: getDataRows([key]), - alertPoints, - config: { - title: $_(key), - width: 600, - height: 300 - } + deviceData.map((data, index) => { + const date = DateTime.fromISO(data.created_at).setZone('Asia/Tokyo'); + const previousData = index > 0 ? deviceData[index - 1] : null; + const previousDate = previousData + ? DateTime.fromISO(previousData.created_at).setZone('Asia/Tokyo') + : null; + const fullDateTime = date.toFormat('M/d H:mm'); + + return { + header: { + value: new Date(data.created_at), + label: fullDateTime, + shortLabel: + // If the date is the same as the previous entry, show only time + previousDate?.toFormat('M/d') === date.toFormat('M/d') + ? date.toFormat('H:mm') + : fullDateTime + }, + cells: keys.map((key) => { + const rawValue = data[key]; + const value = typeof rawValue === 'number' && !isNaN(rawValue) ? rawValue : 0; + + return { + value, + label: + typeof rawValue === 'number' && !isNaN(rawValue) + ? formatNumber({ key, value, adjustFractionDigits: true }) + : '', + bgColor: + alertPoints.find( + (point) => point.data_point_key === key && checkMatch(value, point) + )?.hex_color ?? '#ffffff' + }; + }) + } as TableRow; }); - } - - doc.moveUp(2); // Prepare data rows for the table const dataRows = getDataRows(); @@ -366,7 +414,7 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) }, dataHeader: { header: { label: $_('status'), width: 60 }, - cells: [...keyColumns, { label: `${$_('comment')}:`, width: 60 }] + cells: [...keyColumns, { label: $_('comment'), width: 60 }] }, dataRows: [ ...alertPoints.map((alertPoint) => { @@ -404,25 +452,61 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) ] }); - doc.addPage(); // Add Page break because the data table should always start on a new page. + const chartWidth = contentWidth; + const chartHeight = contentWidth * 0.4; + + // Charts + for (const key of validKeys) { + // Add a new page if the content exceeds the current page height + if (doc.y > doc.page.height - marginBottom - chartHeight + 20) { + doc.addPage(); + } else { + doc.moveDown(2); + } + + createPDFLineChartImage({ + doc, + dataHeader: { + header: { label: $_('datetime'), value: '', width: 60 }, + cells: [ + { + label: $_(key), + value: '', + width: 40, + color: getColorNameByKey(key) + } + ] + }, + dataRows: getDataRows([key]), + alertPoints, + config: { + title: $_(key), + width: chartWidth, + height: chartHeight + } + }); + } + + doc.moveDown(2); // Full data table - createPDFDataTable({ - doc, - config: { - caption: $_('data_history') - }, - dataHeader, - dataRows - }); + createPDFDataTable({ doc, dataHeader, dataRows }); + + const footerText = [ + location.name, + device.name, + devEui, + `${startDateParam} - ${endDateParam}` + ].join(' | '); - addFooterPageNumber(doc, `${device.name} | ${devEui} | ${startDateParam} - ${endDateParam}`); + addFooterPageNumber(doc, footerText); // 5) finalize doc.end(); // Get the PDF as a buffer (async operation) const chunks: Buffer[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any doc.on('data', (chunk: any) => chunks.push(Buffer.from(chunk))); return new Promise((resolve, reject) => { @@ -445,6 +529,7 @@ export const GET: RequestHandler = async ({ params, url, locals: { supabase } }) ); }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any doc.on('error', (err: any) => { reject( json( diff --git a/src/routes/app/+page.server.ts b/src/routes/app/+page.server.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/routes/app/+page.svelte b/src/routes/app/+page.svelte new file mode 100644 index 00000000..e69de29b diff --git a/static/build-info.json b/static/build-info.json index 7f80ea3b..3181f0c6 100644 --- a/static/build-info.json +++ b/static/build-info.json @@ -1,9 +1,9 @@ { - "commit": "b8befd1", + "commit": "1b2899c", "branch": "develop", - "author": "Kevin Cantrell", - "date": "2025-07-14T03:45:41.982Z", + "author": "CropWatch", + "date": "2025-07-22T05:13:59.056Z", "builder": "kevin@kevin-desktop", "ipAddress": "192.168.1.100", - "timestamp": 1752464741983 + "timestamp": 1753161239057 } diff --git a/svelte.config.js b/svelte.config.js index 0db9695f..1ee3c6cd 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -4,7 +4,10 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; const config = { preprocess: vitePreprocess(), kit: { - adapter: adapter(), + adapter: adapter({ + regions: ['hnd1', 'kix1'], // Optional: Specify Vercel regions + maxDuration: 300 // Set the maximum duration (in seconds) + }), serviceWorker: { register: false // Let PWA plugin handle registration } diff --git a/vite.config.ts b/vite.config.ts index f974a0ef..124f537d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,9 +3,13 @@ import { svelteTesting } from '@testing-library/svelte/vite'; import { SvelteKitPWA } from '@vite-pwa/sveltekit'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; +import commonjs from 'vite-plugin-commonjs'; export default defineConfig({ plugins: [ + commonjs({ + filter: (id) => id.includes('node_modules/canvas') + }), tailwindcss(), sveltekit(), SvelteKitPWA({