diff --git a/package-lock.json b/package-lock.json index e8c4e808469..c6bf46f453e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "integrity": "sha512-HbjwKeC+pHUFBfLMNzuSjqFE/58+rLVKmOU3lxQrpsxLBOGosYco/Q0GduBb0/jEMRiyEqjNT/01rRdOMWq5pw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/babel": "3.9.2", "@docusaurus/bundler": "3.9.2", @@ -130,6 +131,7 @@ "version": "3.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -269,6 +271,7 @@ "version": "1.88.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -495,6 +498,7 @@ "integrity": "sha512-G9I2atg1ShtFp0t7zwleP6aPS4DcZvsV4uoQOripp16aR6VJzbEnKFPLW4OFXzX7avgZSpYeBAS+Zx4FOgmpPw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.41.0", "@algolia/requester-browser-xhr": "5.41.0", @@ -710,6 +714,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -837,6 +842,7 @@ "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2763,7 +2769,8 @@ "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", "dev": true, - "license": "(Apache-2.0 AND BSD-3-Clause)" + "license": "(Apache-2.0 AND BSD-3-Clause)", + "peer": true }, "node_modules/@chevrotain/cst-dts-gen": { "version": "11.0.3", @@ -2967,6 +2974,7 @@ "integrity": "sha512-A7+f++LodNNc1wGgoRDTt78cOwWm9KVezApgjOMp1W4hM0898nsqBXwF+sbePE7ZRcjN7Sa1Z5m2oN27XkmEjQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -2982,6 +2990,7 @@ "integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", @@ -3020,6 +3029,7 @@ "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -3030,6 +3040,7 @@ "integrity": "sha512-yoRo4f+FdnD01fFt4XpfpMCcCAo9QvZOtbrXExn4SqzH32YC6LgzqxfLZw/r6Ge65xyY03mK/UfUqrVw1gFiFg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "style-mod": "^4.1.0", @@ -3053,6 +3064,7 @@ "integrity": "sha512-KchMDNtU4CDTdkyf0qG7ugJ6qHTOR/aI7XebYn3OTCNagaDYWiZUVKgRgwH79yeMkpNgvEUaXSK7wKjaBK9b/Q==", "dev": true, "license": "Apache-2.0", + "peer": true, "peerDependencies": { "@bufbuild/protobuf": "^1.10.0" } @@ -3180,6 +3192,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -3203,6 +3216,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -3317,6 +3331,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3724,6 +3739,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -4463,6 +4479,7 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -5114,6 +5131,7 @@ "integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/logger": "3.9.2", @@ -7555,6 +7573,7 @@ "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -7702,6 +7721,7 @@ "integrity": "sha512-6c4DAbR6n6nPbnZhY2V3tzpnKnGL+6aOsLvFL26VRqhlczli9eWG0VDUNoCQEPnGwDMhPS42UhSAnz5pThm5Ag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/mdx-loader": "3.9.2", "@docusaurus/module-type-aliases": "3.9.2", @@ -8264,6 +8284,7 @@ "integrity": "sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/logger": "3.9.2", "@docusaurus/types": "3.9.2", @@ -9944,7 +9965,8 @@ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@lezer/css": { "version": "1.2.0", @@ -11095,7 +11117,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -11117,7 +11138,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -11139,7 +11159,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -11161,7 +11180,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -11183,7 +11201,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -11205,7 +11222,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -11227,7 +11243,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -11249,7 +11264,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -11271,7 +11285,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -11293,7 +11306,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -11315,7 +11327,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -11337,7 +11348,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -11359,7 +11369,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -12561,8 +12570,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.40.2", @@ -12576,8 +12584,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.40.2", @@ -12591,8 +12598,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.40.2", @@ -12606,8 +12612,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.40.2", @@ -12621,8 +12626,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.40.2", @@ -12636,8 +12640,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.40.2", @@ -12651,8 +12654,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.40.2", @@ -12666,8 +12668,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.40.2", @@ -12681,8 +12682,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.40.2", @@ -12696,8 +12696,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { "version": "4.40.2", @@ -12711,8 +12710,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "version": "4.40.2", @@ -12726,8 +12724,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.40.2", @@ -12741,8 +12738,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.40.2", @@ -12756,8 +12752,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.40.2", @@ -12771,8 +12766,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.40.2", @@ -12786,8 +12780,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.40.2", @@ -12801,8 +12794,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.40.2", @@ -12816,8 +12808,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.40.2", @@ -12831,8 +12822,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.40.2", @@ -12846,8 +12836,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@sagold/json-pointer": { "version": "5.1.2", @@ -13181,6 +13170,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -13295,6 +13285,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -13435,6 +13426,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14635,6 +14627,7 @@ "integrity": "sha512-ZDVMlzgqHuYnY6I2xpgPhlv/5Ndj9MiDQSj52y4DBCqNJI3kiU4ZDYLNeorbuCJKYLJ4Fe1nFyut3zDvEl5BlQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/client-logger": "7.6.20", "@storybook/core-client": "7.6.20", @@ -14893,6 +14886,7 @@ "integrity": "sha512-kvu4h9qXduiPk1Q1oqFKDLFGu/7mslEYbVaqpbBcBxjlRJnvNCFwEvEwKt0Mx9TtSi8J77xRelvJobrGlst4nQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", @@ -14925,6 +14919,7 @@ "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", "debug": "^4.3.4", @@ -15129,6 +15124,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -15350,6 +15346,7 @@ "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -15482,6 +15479,7 @@ "integrity": "sha512-kmS7ZVpHm1EMnW1Wmft9H5ZLM7E0G0NGBx+aGEHGDcNxZBXD2ZUa76CuWjIhOGpwsPbELp684ZdpF2JWoNi4Dg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -15569,6 +15567,7 @@ "integrity": "sha512-plCQDLCZIOc92cizB8NNhBRN0szvYR3cx9i5IXo6v9Xsgcun8KHNcJkesc2AyeqdIs0BtOJZaqQ9adHThz8UDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -16468,6 +16467,7 @@ "integrity": "sha512-gXLBtmlcRJeT09/sI4PxVwyrku6SaNUj/6cMubjE6T6XdY1fDmBL7r0nX0jbSZPU/Xr0KuwLLZh6aOYY5d91Xw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -16773,6 +16773,7 @@ "integrity": "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.32.1", "@typescript-eslint/types": "8.32.1", @@ -16894,6 +16895,7 @@ "integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.32.1", @@ -17523,6 +17525,7 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -17593,6 +17596,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -17662,6 +17666,7 @@ "integrity": "sha512-9E4b3rJmYbBkn7e3aAPt1as+VVnRhsR4qwRRgOzpeyz4PAOuwKh0HI4AN6mTrqK0S0M9fCCSTOUnuJ8gPY/tvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.7.0", "@algolia/client-abtesting": "5.41.0", @@ -18894,6 +18899,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -19391,6 +19397,7 @@ "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -20442,6 +20449,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -20784,6 +20792,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -21166,6 +21175,7 @@ "integrity": "sha512-5JHBC9n75kz5851jeklCPmZWcg3hUe6sjqJvyk3+hVqFaKcHwHgxsjeN1yLmggoUc6STbtm9/NQyabQehfjvWQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -21498,7 +21508,6 @@ "integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "commander": "7", "d3-array": "1 - 3", @@ -21521,7 +21530,6 @@ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10" } @@ -21678,6 +21686,7 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -23386,6 +23395,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -23512,6 +23522,7 @@ "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -24470,8 +24481,7 @@ "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", @@ -28077,6 +28087,7 @@ "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.16.0" } @@ -31981,6 +31992,7 @@ "integrity": "sha512-aChaVU/DO5aRPmk1GX8L+whocagUUpBQqoPtJk+cm7UOXUk87J4PeWCh6nNmTTIfEhiR9DI/+FnA8dln/hTK7g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -33795,6 +33807,7 @@ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8.6" }, @@ -33998,6 +34011,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -34662,6 +34676,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "lilconfig": "^3.1.1" }, @@ -35069,6 +35084,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -35766,6 +35782,7 @@ "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -36140,6 +36157,7 @@ "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -36172,6 +36190,7 @@ "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -36224,6 +36243,7 @@ "integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -36679,6 +36699,7 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -36703,6 +36724,7 @@ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -36764,6 +36786,7 @@ "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" }, @@ -36843,6 +36866,7 @@ "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -38557,29 +38581,6 @@ "rimraf": "bin.js" } }, - "node_modules/sass": { - "version": "1.97.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz", - "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, "node_modules/sass-loader": { "version": "16.0.5", "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz", @@ -38621,40 +38622,6 @@ } } }, - "node_modules/sass/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/sass/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -38718,6 +38685,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -39841,6 +39809,7 @@ "integrity": "sha512-Wt04pPTO71pwmRmsgkyZhNo4Bvdb/1pBAMsIFb9nQLykEdzzpXjvingxFFvdOG4nIowzwgxD+CLlyRqVJqnATw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/cli": "7.6.20" }, @@ -40195,6 +40164,7 @@ "integrity": "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.2.2", "@emotion/unitless": "0.8.1", @@ -40380,6 +40350,7 @@ "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", @@ -41449,6 +41420,7 @@ "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -42206,6 +42178,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -42316,7 +42289,6 @@ "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "commander": "2" }, @@ -42331,8 +42303,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/toposort": { "version": "2.0.2", @@ -42633,7 +42604,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.19.4", @@ -42641,6 +42613,7 @@ "integrity": "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -43235,6 +43208,7 @@ "integrity": "sha512-xKvKpIywE1rnqqLgjkoq0F3wOqYaKO9nV6YkkSat6IxOWacUCc/7Es0hR3OPmkIqkPoEn7U3x+sYdG72rstZQA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@gerrit0/mini-shiki": "^3.2.2", "lunr": "^2.3.9", @@ -43272,6 +43246,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -44075,8 +44050,7 @@ "resolved": "https://registry.npmjs.org/vega-canvas/-/vega-canvas-1.2.7.tgz", "integrity": "sha512-OkJ9CACVcN9R5Pi9uF6MZBF06pO6qFpDYHWSKBJsdHP5o724KrsgR6UvbnXFH82FdsiTOff/HqjuaG8C7FL+9Q==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/vega-crossfilter": { "version": "4.1.3", @@ -44084,7 +44058,6 @@ "integrity": "sha512-nyPJAXAUABc3EocUXvAL1J/IWotZVsApIcvOeZaUdEQEtZ7bt8VtP2nj3CLbHBA8FZZVV+K6SmdwvCOaAD4wFQ==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-array": "^3.2.2", "vega-dataflow": "^5.7.7", @@ -44097,7 +44070,6 @@ "integrity": "sha512-R2NX2HvgXL+u4E6u+L5lKvvRiCtnE6N6l+umgojfi53suhhkFP+zB+2UAQo4syxuZ4763H1csfkKc4xpqLzKnw==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "vega-format": "^1.1.3", "vega-loader": "^4.5.3", @@ -44110,7 +44082,6 @@ "integrity": "sha512-ZHQPWSs9mUTGJPZ5yQVhHV+OLDCoTIjR//De93vG6igZX1MQCVo03ePWlfWCUAnPV1IsKfeJLqA3K/Qd11bAFQ==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "fast-json-patch": "^3.1.1", "json-stringify-pretty-compact": "^4.0.0", @@ -44135,7 +44106,6 @@ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -44149,7 +44119,6 @@ "integrity": "sha512-fsjEY1VaBAmqwt7Jlpz0dpPtfQFiBdP9igEefvumSpy7XUxOJmDQcRDnT3Qh9ctkv3itfPfI9g8FSnGcv2b4jQ==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-array": "^3.2.2", "d3-interpolate": "^3.0.1", @@ -44182,7 +44151,6 @@ "integrity": "sha512-cHZVaY2VNNIG2RyihhSiWniPd2W9R9kJq0znxzV602CgUVgxEfTKtx/lxnVCn8nNrdKAYrGiqIsBzIeKG1GWHw==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-force": "^3.0.0", "vega-dataflow": "^5.7.7", @@ -44195,7 +44163,6 @@ "integrity": "sha512-wQhw7KR46wKJAip28FF/CicW+oiJaPAwMKdrxlnTA0Nv8Bf7bloRlc+O3kON4b4H1iALLr9KgRcYTOeXNs2MOA==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-array": "^3.2.2", "d3-format": "^3.1.0", @@ -44210,7 +44177,6 @@ "integrity": "sha512-+D+ey4bDAhZA2CChh7bRZrcqRUDevv05kd2z8xH+il7PbYQLrhi6g1zwvf8z3KpgGInFf5O13WuFK5DQGkz5lQ==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-array": "^3.2.2", "d3-color": "^3.1.0", @@ -44231,7 +44197,6 @@ "integrity": "sha512-+WnnzEPKIU1/xTFUK3EMu2htN35gp9usNZcC0ZFg2up1/Vqu6JyZsX0PIO51oXSIeXn9bwk6VgzlOmJUcx92tA==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-array": "^3.2.2", "d3-color": "^3.1.0", @@ -44249,7 +44214,6 @@ "integrity": "sha512-0Z+TYKRgOEo8XYXnJc2HWg1EGpcbNAhJ9Wpi9ubIbEyEHqIgjCIyFVN8d4nSfsJOcWDzsSmRqohBztxAhOCSaw==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-hierarchy": "^3.1.2", "vega-dataflow": "^5.7.7", @@ -44279,7 +44243,6 @@ "integrity": "sha512-Emx4b5s7pvuRj3fBkAJ/E2snCoZACfKAwxVId7f/4kYVlAYLb5Swq6W8KZHrH4M9Qds1XJRUYW9/Y3cceqzEFA==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "vega-canvas": "^1.2.7", "vega-dataflow": "^5.7.7", @@ -44293,6 +44256,7 @@ "integrity": "sha512-l4J6+AWE3DIjvovEoHl2LdtCUkfm4zs8Xxx7INwZEAv+XVb6kR6vIN1gt3t2gN2gs/y4DYTs/RPoTeYAuEg6mA==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "json-stringify-pretty-compact": "~4.0.0", "tslib": "~2.8.1", @@ -44331,7 +44295,6 @@ "integrity": "sha512-dUfIpxTLF2magoMaur+jXGvwMxjtdlDZaIS8lFj6N7IhUST6nIvBzuUlRM+zLYepI5GHtCLOnqdKU4XV0NggCA==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-dsv": "^3.0.1", "node-fetch": "^2.6.7", @@ -44346,7 +44309,6 @@ "integrity": "sha512-jltyrwCTtWeidi/6VotLCybhIl+ehwnzvFWYOdWNUP0z/EskdB64YmawNwjCjzTBMemeiQtY6sJPPbewYqe3Vg==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "vega-dataflow": "^5.7.7", "vega-event-selector": "^3.0.1", @@ -44361,7 +44323,6 @@ "integrity": "sha512-3pcVaQL9R3Zfk6PzopLX6awzrQUeYOXJzlfLGP2Xd93mqUepBa6m/reVrTUoSFXA3v9lfK4W/PS2AcVzD/MIcQ==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-geo": "^3.1.0", "d3-geo-projection": "^4.0.0", @@ -44374,7 +44335,6 @@ "integrity": "sha512-AmccF++Z9uw4HNZC/gmkQGe6JsRxTG/R4QpbcSepyMvQN1Rj5KtVqMcmVFP1r3ivM4dYGFuPlzMWvuqp0iKMkQ==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-array": "^3.2.2", "vega-dataflow": "^5.7.7", @@ -44388,7 +44348,6 @@ "integrity": "sha512-b4eot3tWKCk++INWqot+6sLn3wDTj/HE+tRSbiaf8aecuniPMlwJEK7wWuhVGeW2Ae5n8fI/8TeTViaC94bNHA==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "vega-dataflow": "^5.7.7", "vega-util": "^1.17.3" @@ -44400,7 +44359,6 @@ "integrity": "sha512-o6Hl76aU1jlCK7Q8DPYZ8OGsp4PtzLdzI6nGpLt8rxoE78QuB3GBGEwGAQJitp4IF7Lb2rL5oAXEl3ZP6xf9jg==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-array": "^3.2.2", "d3-interpolate": "^3.0.1", @@ -44416,7 +44374,6 @@ "integrity": "sha512-LFY9+sLIxRfdDI9ZTKjLoijMkIAzPLBWHpPkwv4NPYgdyx+0qFmv+puBpAUGUY9VZqAZ736Uj5NJY9zw+/M3yQ==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-path": "^3.1.0", "d3-shape": "^3.2.0", @@ -44431,8 +44388,7 @@ "resolved": "https://registry.npmjs.org/vega-schema-url-parser/-/vega-schema-url-parser-3.0.2.tgz", "integrity": "sha512-xAnR7KAvNPYewI3O0l5QGdT8Tv0+GCZQjqfP39cW/hbe/b3aYMAQ39vm8O2wfXUHzm04xTe7nolcsx8WQNVLRQ==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/vega-selections": { "version": "5.6.0", @@ -44440,7 +44396,6 @@ "integrity": "sha512-UE2w78rUUbaV3Ph+vQbQDwh8eywIJYRxBiZdxEG/Tr/KtFMLdy2BDgNZuuDO1Nv8jImPJwONmqjNhNDYwM0VJQ==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-array": "3.2.4", "vega-expression": "^5.2.0", @@ -44453,7 +44408,6 @@ "integrity": "sha512-GAqS7mkatpXcMCQKWtFu1eMUKLUymjInU0O8kXshWaQrVWjPIO2lllZ1VNhdgE0qGj4oOIRRS11kzuijLshGXQ==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-array": "^3.2.2" } @@ -44464,7 +44418,6 @@ "integrity": "sha512-1iFiI3BNmW9FrsLnDLx0ZKEddsCitRY3XmUAwp6qmp+p+IXyJYc9pfjlVj9E6KXBPfm4cQyU++s0smKNiWzO4g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "funding": { "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" }, @@ -44479,7 +44432,6 @@ "integrity": "sha512-hFcWPdTV844IiY0m97+WUoMLADCp+8yUQR1NStWhzBzwDDA7QEGGwYGxALhdMOaDTwkyoNj3V/nox2rQAJD/vQ==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-array": "^3.2.2", "d3-time": "^3.1.0", @@ -44492,7 +44444,6 @@ "integrity": "sha512-P1R0JP29v0qnTuwzCQ0SPJlkjAzr6qeyj+H4VgUFSykHmHc1OBxda//XBaFDl/bZgIscEMvjKSjZpXd84x3aZQ==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "vega-util": "^2.0.0" }, @@ -44505,8 +44456,7 @@ "resolved": "https://registry.npmjs.org/vega-util/-/vega-util-2.0.0.tgz", "integrity": "sha512-/ayLYX3VVqfkKJB1mG+xkOKiBVlfFZ9BfUB5vf7eVyIRork24sABXdeH4x+XeWuqDKnLBTDedotA+1a5MxlV2Q==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/vega-transforms": { "version": "4.12.1", @@ -44514,7 +44464,6 @@ "integrity": "sha512-Qxo+xeEEftY1jYyKgzOGc9NuW4/MqGm1YPZ5WrL9eXg2G0410Ne+xL/MFIjHF4hRX+3mgFF4Io2hPpfy/thjLg==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-array": "^3.2.2", "vega-dataflow": "^5.7.7", @@ -44556,7 +44505,6 @@ "integrity": "sha512-Nxp1MEAY+8bphIm+7BeGFzWPoJnX9+hgvze6wqCAPoM69YiyVR0o0VK8M2EESIL+22+Owr0Fdy94hWHnmon5tQ==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-array": "^3.2.2", "d3-timer": "^3.0.1", @@ -44574,7 +44522,6 @@ "integrity": "sha512-RYlyMJu5kZV4XXjmyTQKADJWDB25SMHsiF+B1rbE1p+pmdQPlp5tGdPl9r5dUJOp3p8mSt/NGI8GPGucmPMxtw==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "vega-dataflow": "^5.7.7", "vega-scenegraph": "^4.13.1", @@ -44587,7 +44534,6 @@ "integrity": "sha512-lWNimgJAXGeRFu2Pz8axOUqVf1moYhD+5yhBzDSmckE9I5jLOyZc/XvgFTXwFnsVkMd1QW1vxJa+y9yfUblzYw==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "d3-delaunay": "^6.0.2", "vega-dataflow": "^5.7.7", @@ -44600,7 +44546,6 @@ "integrity": "sha512-lFmF3u9/ozU0P+WqPjeThQfZm0PigdbXDwpIUCxczrCXKYJLYFmZuZLZR7cxtmpZ0/yuvRvAJ4g123LXbSZF8A==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "vega-canvas": "^1.2.7", "vega-dataflow": "^5.7.7", @@ -44660,6 +44605,7 @@ "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -44757,7 +44703,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=12" } @@ -44775,7 +44720,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -44793,7 +44737,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -44811,7 +44754,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -44829,7 +44771,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=12" } @@ -44847,7 +44788,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=12" } @@ -44865,7 +44805,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -44883,7 +44822,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -44901,7 +44839,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -44919,7 +44856,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -44937,7 +44873,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -44955,7 +44890,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -44973,7 +44907,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -44991,7 +44924,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -45009,7 +44941,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -45027,7 +44958,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -45045,7 +44975,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -45063,7 +44992,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -45081,7 +45009,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -45099,7 +45026,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=12" } @@ -45117,7 +45043,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -45135,7 +45060,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -45153,7 +45077,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -45613,6 +45536,7 @@ "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -46762,6 +46686,7 @@ "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/scripts/tsc-with-whitelist.sh b/scripts/tsc-with-whitelist.sh index b198a0ac01a..ca2fdffbf5a 100755 --- a/scripts/tsc-with-whitelist.sh +++ b/scripts/tsc-with-whitelist.sh @@ -22,12 +22,6 @@ web-admin/src/routes/[organization]/-/settings/usage/+page.ts: error TS2307 web-admin/src/routes/[organization]/-/upgrade-callback/+page.ts: error TS2307 web-admin/src/routes/[organization]/[project]/-/open-query/+page.ts: error TS2307 web-admin/src/routes/[organization]/[project]/-/share/[token]/+page.ts: error TS2345 -web-common/src/components/data-graphic/actions/mouse-position-to-domain-action-factory.ts: error TS2322 -web-common/src/components/data-graphic/actions/outline.ts: error TS18047 -web-common/src/components/data-graphic/actions/outline.ts: error TS2345 -web-common/src/components/data-graphic/marks/segment.ts: error TS2345 -web-common/src/components/data-graphic/utils.ts: error TS2362 -web-common/src/components/data-graphic/utils.ts: error TS2363 web-common/src/components/editor/line-status/line-number-gutter.ts: error TS2322 web-common/src/components/editor/line-status/line-number-gutter.ts: error TS2339 web-common/src/components/editor/line-status/line-status-gutter.ts: error TS2339 diff --git a/scripts/web-test-code-quality.sh b/scripts/web-test-code-quality.sh index 1a32b3b81a9..3ed053d0f81 100755 --- a/scripts/web-test-code-quality.sh +++ b/scripts/web-test-code-quality.sh @@ -79,7 +79,7 @@ if [[ "$COMMON" == "true" ]]; then npx svelte-kit sync cd .. npx eslint web-common --quiet || exit_code=$? - npx svelte-check --workspace web-common --no-tsconfig --ignore "src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte,src/features/dashboards/time-series/MeasureChart.svelte,src/features/dashboards/time-controls/TimeControls.svelte,src/components/data-graphic/elements/GraphicContext.svelte,src/components/data-graphic/guides/Axis.svelte,src/components/data-graphic/guides/DynamicallyPlacedLabel.svelte,src/components/data-graphic/guides/Grid.svelte,src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte,src/components/data-graphic/marks/Area.svelte,src/components/data-graphic/marks/ChunkedLine.svelte,src/components/data-graphic/marks/HistogramPrimitive.svelte,src/components/data-graphic/marks/Line.svelte,src/components/data-graphic/marks/MultiMetricMouseoverLabel.svelte,src/features/column-profile/column-types/details/SummaryNumberPlot.svelte,src/stories/Tooltip.stories.svelte,src/lib/number-formatting/__stories__/NumberFormatting.stories.svelte" || exit_code=$? + npx svelte-check --workspace web-common --no-tsconfig --ignore "src/stories/Tooltip.stories.svelte,src/lib/number-formatting/__stories__/NumberFormatting.stories.svelte" || exit_code=$? fi if [[ "$LOCAL" == "true" ]]; then diff --git a/web-common/src/app.css b/web-common/src/app.css index 34f229a890d..ec3cb920d56 100644 --- a/web-common/src/app.css +++ b/web-common/src/app.css @@ -48,6 +48,12 @@ .ui-measure-bar-excluded { @apply bg-surface-overlay; } + + .text-outline { + stroke: var(--surface-background); + stroke-width: 4px; + paint-order: stroke; + } } @layer base { diff --git a/web-common/src/components/BarAndLabel.svelte b/web-common/src/components/BarAndLabel.svelte index 3dba580e36d..43285d948e0 100644 --- a/web-common/src/components/BarAndLabel.svelte +++ b/web-common/src/components/BarAndLabel.svelte @@ -34,7 +34,7 @@ {customBackgroundColor !== '' ? customBackgroundColor : showBackground - ? 'bg-gray-100' + ? 'bg-surface-muted' : 'bg-transparent'} " style:flex="1" diff --git a/web-common/src/components/data-graphic/README.md b/web-common/src/components/data-graphic/README.md deleted file mode 100644 index c3764f29e4c..00000000000 --- a/web-common/src/components/data-graphic/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# `data-graphic` - -This directory contains components for building flexible data graphics. A solid component system should be: - -- _composable_ - the system of domain and range cascading should ensure that we can do lots of complex things easily, such as nesting graphic contexts within each other. -- _mostly-declarative_ - by taking care of a great deal of complexity for the user – and exposing common primitives like scales in the right places – someone shouldn't have to write too much code to make an interactive data graphic. -- _reactive_ - the domain-space (e.g. the scale domains) should be able to reactively respond to changes in the data. The range-space should be able to respond to changes in configuration parameters. And this should be efficient enough that all values should be tweenable! -- _elegant_ - the system should have sensible defaults and high-quality, human design. -- _cohesive_ - ideally, all our charts and plots should fit nicely within Rill's larger design system. One of the benefits of building our own system is that we can achieve this thread without paying for loss of functionality. - -This component set is organized as such: - -- `elements` - contains the main containers for data graphics. -- `actions` - contains various actions & action factories used for data graphics. -- `constants` - contains the constants used throughout the component set. -- `guides` - guides are components that orient the data graphic, such as axes, grids, and mouseover labels. -- `marks` - contains the main components used to map data to geometric shapes. -- `functional-components` - components that perform some small function and then expose the output in a slot. These convenience components enable users to add a bit of custom functionality when needed without having to resort to reaching into the `script` tag. -- `state` - contains various store factories used throughout the component set. diff --git a/web-common/src/components/data-graphic/actions/mouse-position-to-domain-action-factory.ts b/web-common/src/components/data-graphic/actions/mouse-position-to-domain-action-factory.ts deleted file mode 100644 index 3e349ec5dc5..00000000000 --- a/web-common/src/components/data-graphic/actions/mouse-position-to-domain-action-factory.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @module mousePositionToDomainActionFactory - * This action factory creates - * 1. a readable store that contains the domain coordinates - * 2. an action that updates the readable store when the mouse moves over the attached DOM element - */ -import { getContext } from "svelte"; -import { get, type Readable, writable } from "svelte/store"; -import { DEFAULT_COORDINATES } from "../constants"; -import type { Action, ActionReturn } from "svelte/action"; -import { contexts } from "../constants"; -import type { DomainCoordinates } from "../constants/types"; -import type { ScaleStore } from "../state/types"; - -export interface MousePositionToDomainActionSet { - coordinates: Readable; - mousePositionToDomain: Action; - mouseover: Readable; -} - -export function mousePositionToDomainActionFactory(): MousePositionToDomainActionSet { - const coordinateStore = writable({ - ...DEFAULT_COORDINATES, - }); - const xScale = getContext(contexts.scale("x")); - const yScale = getContext(contexts.scale("y")); - - let offsetX: number; - let offsetY: number; - const mouseover = writable(false); - - const unsubscribeFromXScale = xScale.subscribe((xs) => { - if (get(mouseover)) { - coordinateStore.update((coords) => { - return { ...coords, x: xs(offsetX) }; - }); - } - }); - const unsubscribeFromYScale = yScale.subscribe((ys) => { - if (get(mouseover)) { - coordinateStore.update((coords) => { - return { ...coords, y: ys(offsetY) }; - }); - } - }); - - function onMouseMove(event) { - offsetX = event.offsetX; - offsetY = event.offsetY; - - coordinateStore.set({ - x: get(xScale).invert(offsetX), - y: get(yScale).invert(offsetY), - xActual: offsetX, - yActual: offsetY, - }); - mouseover.set(true); - } - - function onMouseLeave() { - coordinateStore.set({ ...DEFAULT_COORDINATES }); - mouseover.set(false); - } - const coordinates = { - subscribe: coordinateStore.subscribe, - } as Readable; - return { - coordinates, - mouseover, - mousePositionToDomain(node: HTMLElement | SVGElement): ActionReturn { - node.addEventListener("mousemove", onMouseMove); - node.addEventListener("mouseleave", onMouseLeave); - return { - destroy(): void { - unsubscribeFromXScale(); - unsubscribeFromYScale(); - node.removeEventListener("mousemove", onMouseMove); - node.removeEventListener("mouseleave", onMouseLeave); - }, - }; - }, - }; -} diff --git a/web-common/src/components/data-graphic/actions/outline.ts b/web-common/src/components/data-graphic/actions/outline.ts deleted file mode 100644 index c7e9accb8fc..00000000000 --- a/web-common/src/components/data-graphic/actions/outline.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** this action appends another text DOM element - * that gives an outlined / punched-out look to whatever - * svg text node it is applied to. It will then listen to - * any of the relevant attributes / the content itself - * and update accordingly via a basic MutationObserver. - */ - -interface OutlineAction { - destroy: () => void; -} - -export function outline( - node: SVGElement, - args = { color: "white" }, -): OutlineAction { - const enclosingSVG = node.ownerSVGElement; - - // create a clone of the element. - const clonedElement = node.cloneNode(true) as SVGElement; - node.parentElement.insertBefore(clonedElement, node); - clonedElement.setAttribute("fill", args.color); - clonedElement.style.fill = args.color; - clonedElement.setAttribute("filter", "url(#outline-filter)"); - // apply the filter to this svg element. - let outlineFilter = enclosingSVG.querySelector("#outline-filter"); - if (outlineFilter === null) { - outlineFilter = document.createElementNS( - "http://www.w3.org/2000/svg", - "filter", - ); - - outlineFilter.id = "outline-filter"; - - const morph = document.createElementNS( - "http://www.w3.org/2000/svg", - "feMorphology", - ); - morph.setAttribute("operator", "dilate"); - morph.setAttribute("radius", "2"); - morph.setAttribute("in", "SourceGraphic"); - morph.setAttribute("result", "THICKNESS"); - - const composite = document.createElementNS( - "http://www.w3.org/2000/svg", - "feComposite", - ); - composite.setAttribute("operator", "out"); - composite.setAttribute("in", "THICKNESS"); - composite.setAttribute("in2", "SourceGraphic"); - - outlineFilter.appendChild(morph); - outlineFilter.appendChild(composite); - enclosingSVG.prepend(outlineFilter); - } - - const config = { - attributes: true, - childList: true, - subtree: true, - characterData: true, - }; - const observer = new MutationObserver(() => { - clonedElement.setAttribute("x", node.getAttribute("x")); - clonedElement.setAttribute("y", node.getAttribute("y")); - if (node.getAttribute("text-anchor")) { - clonedElement.setAttribute( - "text-anchor", - node.getAttribute("text-anchor"), - ); - } - - if (node.getAttribute("dx")) { - clonedElement.setAttribute("dx", node.getAttribute("dx")); - } - if (node.getAttribute("dy")) { - clonedElement.setAttribute("dy", node.getAttribute("dy")); - } - - // clone any animations that may be applied via svelte transitions. - clonedElement.style.animation = node.style.animation; - // copy the contents of the node. - clonedElement.innerHTML = node.innerHTML; - }); - observer.observe(node, config); - - return { - destroy() { - clonedElement.remove(); - }, - }; -} diff --git a/web-common/src/components/data-graphic/actions/scrub-action-factory.ts b/web-common/src/components/data-graphic/actions/scrub-action-factory.ts deleted file mode 100644 index 0d4ff77bd72..00000000000 --- a/web-common/src/components/data-graphic/actions/scrub-action-factory.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * scrub-action-factory - * -------------------- - * This action factory produces an object that contains - * - a coordinates store, which has the x and y start and stop values - * of the in-progress scrub. - * - an isScrubbing store, which the user can exploit to see if scrubbing is - * currently happening - * - a movement store, which captures the momentum of the scrub. - * - a customized action - * - * Why is this an action factory and not an action? Because we actually want to initialize a bunch - * of stores that are used throughout the app, which respond to the action's logic automatically, - * and can thus be consumed within the application without any other explicit call point. - * This action factory pattern is quite useful in a variety of settings. - * - */ - -import { get, writable } from "svelte/store"; -import { DEFAULT_NUMBER_COORDINATES } from "../constants"; - -/** converts an event to a simplified object - * with only the needed properties - */ -function mouseEvents(event: MouseEvent) { - return { - movementX: event.movementX, - movementY: event.movementY, - clientX: event.clientX, - clientY: event.clientY, - ctrlKey: event.ctrlKey, - altKey: event.altKey, - shiftKey: event.shiftKey, - metaKey: event.metaKey, - }; -} - -interface ScrubActionFactoryArguments { - /** the bounds where the scrub is active. */ - plotLeft: number; - plotRight: number; - plotTop: number; - plotBottom: number; - /** the name of the events we declare for start, move, end. - * Typically mousedown, mousemove, and mouseup. - */ - - startEvent?: string; - endEvent?: string; - moveEvent?: string; - startEventName?: string; - /** the dispatched move event name for the scrub move effect, to be - * passed up to the parent element when the scrub move has happened. - * e.g. - */ - moveEventName?: string; - /** the dispatched move event name for the scrub completion effect, to be - * passed up to the parent element when the scrub is completed. - * e.g. when moveEventName = "scrubbing", we have
- */ - endEventName?: string; - /** These predicates will gate whether we continue with - * the startEvent, moveEvent, and endEvents. - * If they're not passed in as arguments, the action - * will always assume they're true. - * This is used e.g. when a user wants to hold the shift or alt key, or - * check for some other condition to to be true. - * e.g when completedEventName = "scrub", we have
- */ - startPredicate?: (event: Event) => boolean; - movePredicate?: (event: Event) => boolean; - endPredicate?: (event: Event) => boolean; -} - -export interface PlotBounds { - plotLeft?: number; - plotRight?: number; - plotTop?: number; - plotBottom?: number; -} - -interface ScrubAction { - destroy: () => void; -} - -function clamp(v: number, min: number, max: number) { - if (v < min) return min; - if (v > max) return max; - return v; -} - -/** - * - * NOTE: types for these scrub action are added to the - * `interface SVGAttributes` in the svelteHTML namespace in - * `web-common/app.d.ts` - */ -export function createScrubAction({ - plotLeft, - plotRight, - plotTop, - plotBottom, - startEvent = "mousedown", - startEventName = undefined, - startPredicate = undefined, - endEvent = "mouseup", - endPredicate = undefined, - moveEvent = "mousemove", - movePredicate = undefined, - endEventName = undefined, - moveEventName = undefined, -}: ScrubActionFactoryArguments) { - const coordinates = writable({ - start: DEFAULT_NUMBER_COORDINATES, - stop: DEFAULT_NUMBER_COORDINATES, - }); - - /** local plot bound state */ - let _plotLeft = plotLeft; - let _plotRight = plotRight; - let _plotTop = plotTop; - let _plotBottom = plotBottom; - - const movement = writable({ - xMovement: 0, - yMovement: 0, - }); - - const isScrubbing = writable(false); - - function setCoordinateBounds(event: MouseEvent) { - return { - x: clamp(event.offsetX, _plotLeft, _plotRight), - y: clamp(event.offsetY, _plotTop, _plotBottom), - }; - } - - return { - coordinates, - isScrubbing, - movement, - updatePlotBounds(bounds: PlotBounds) { - if (bounds.plotLeft) _plotLeft = bounds.plotLeft; - if (bounds.plotRight) _plotRight = bounds.plotRight; - if (bounds.plotTop) _plotTop = bounds.plotTop; - if (bounds.plotBottom) _plotBottom = bounds.plotBottom; - }, - scrubAction(node: Node): ScrubAction { - function reset() { - coordinates.set({ - start: DEFAULT_NUMBER_COORDINATES, - stop: DEFAULT_NUMBER_COORDINATES, - }); - isScrubbing.set(false); - } - - function onScrubStart(event: MouseEvent) { - // Check for the main button press - if (event.button !== 0) return; - if (!(startPredicate === undefined || startPredicate(event))) { - return; - } - node.addEventListener(moveEvent, onScrub); - coordinates.set({ - start: setCoordinateBounds(event), - stop: DEFAULT_NUMBER_COORDINATES, - }); - isScrubbing.set(true); - if (startEventName) { - node.dispatchEvent( - new CustomEvent(startEventName, { - detail: { - ...get(coordinates), - ...mouseEvents(event), - }, - }), - ); - } - } - - function onScrub(event: MouseEvent) { - event.preventDefault(); - - if (!(movePredicate === undefined || movePredicate(event))) { - reset(); - return; - } - coordinates.update((coords) => { - const newCoords = { ...coords }; - newCoords.stop = setCoordinateBounds(event); - return newCoords; - }); - const coords = get(coordinates); - // fire the moveEventName event. - // e.g. on:scrubbing={(event) => { ... }} - if (moveEventName) { - node.dispatchEvent( - new CustomEvent(moveEventName, { - detail: { - ...coords, - ...mouseEvents(event), - }, - }), - ); - } - } - - function onScrubEnd(event: MouseEvent) { - node.removeEventListener(moveEvent, onScrub); - if (!(endPredicate === undefined || endPredicate(event))) { - reset(); - return; - } - const coords = get(coordinates); - if (coords.start.x && coords.stop.x && endEventName) { - node.dispatchEvent( - new CustomEvent(endEventName, { - detail: { - ...coords, - ...mouseEvents(event), - }, - }), - ); - } - reset(); - } - - node.addEventListener(startEvent, onScrubStart); - window.addEventListener(endEvent, onScrubEnd); - window.addEventListener(endEvent, reset); - return { - destroy() { - node.removeEventListener(startEvent, onScrubStart); - node.removeEventListener(moveEvent, onScrub); - window.removeEventListener(endEvent, onScrubEnd); - window.removeEventListener(endEvent, reset); - }, - }; - }, - }; -} diff --git a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte index d6decf004cb..f48756cb4b1 100644 --- a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte +++ b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampDetail.svelte @@ -1,297 +1,309 @@ + - - - - {#each [[yAccessor, "rgb(100,100,100)"]] as [accessor, color]} - {@const cx = $X(point[xAccessor])} - {@const cy = $Y(point[accessor])} - {#if cx && cy} - - {/if} - {/each} - - - {datePortion(xLabel)} - - - {timePortion(xLabel)} - - - {formatInteger(Math.trunc(point[yAccessor]))} row{#if point[yAccessor] !== 1}s{/if} - - - diff --git a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampPaths.svelte b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampPaths.svelte deleted file mode 100644 index d0bf55df120..00000000000 --- a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampPaths.svelte +++ /dev/null @@ -1,148 +0,0 @@ - - - - - - - $plotConfig.width * $plotConfig.devicePixelRatio - ? 1 - : 0} -> - - - diff --git a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte index faa3855b2b7..5f5bc114c41 100644 --- a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte +++ b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampSpark.svelte @@ -1,123 +1,103 @@ {#if data.length} - - - - + + + + + + + + + {#if linePath} + + {/if} + {#if areaPath} + + {/if} - {#if zoomWindowXMin && zoomWindowXMax} - + {/if} - + {/if} diff --git a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampTooltipContent.svelte b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampTooltipContent.svelte index 75101726e96..6d11890bfeb 100644 --- a/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampTooltipContent.svelte +++ b/web-common/src/components/data-graphic/compositions/timestamp-profile/TimestampTooltipContent.svelte @@ -14,10 +14,9 @@ } from "@rilldata/web-common/lib/formatters"; import { isClipboardApiSupported } from "../../../../lib/actions/copy-to-clipboard"; import TimestampSpark from "./TimestampSpark.svelte"; + import type { TimestampDataPoint } from "@rilldata/web-common/features/column-profile/queries"; - export let xAccessor: string; - export let yAccessor: string; - export let data; + export let data: TimestampDataPoint[]; // FIXME: document meaning of these special looking numbers // e.g. something like width = y* CHAR_HEIGHT, height = CHAR_HEIGHT? export let width = 84; @@ -78,17 +77,13 @@
diff --git a/web-common/src/components/data-graphic/compositions/timestamp-profile/ZoomWindow.svelte b/web-common/src/components/data-graphic/compositions/timestamp-profile/ZoomWindow.svelte deleted file mode 100644 index 602494f9764..00000000000 --- a/web-common/src/components/data-graphic/compositions/timestamp-profile/ZoomWindow.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - - - diff --git a/web-common/src/components/data-graphic/constants/index.ts b/web-common/src/components/data-graphic/constants/index.ts deleted file mode 100644 index a0600045ac3..00000000000 --- a/web-common/src/components/data-graphic/constants/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { DomainCoordinates } from "./types"; - -export const DEFAULT_COORDINATES: DomainCoordinates = { - x: undefined, - y: undefined, -}; - -export const DEFAULT_NUMBER_COORDINATES: DomainCoordinates = { - x: undefined, - y: undefined, -}; - -export const contexts = { - config: "rill:data-graphic:plot-config", - scale(namespace: string) { - return `rill:data-graphic:${namespace}-scale`; - }, - min(namespace: string) { - return `rill:data-graphic:${namespace}-min`; - }, - max(namespace: string) { - return `rill:data-graphic:${namespace}-max`; - }, -}; diff --git a/web-common/src/components/data-graphic/constants/types.d.ts b/web-common/src/components/data-graphic/constants/types.d.ts deleted file mode 100644 index 1f476832a37..00000000000 --- a/web-common/src/components/data-graphic/constants/types.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface DomainCoordinates { - x?: T; - y?: number; - // For annotations we need the actual x/y. So save it directly for easy access. - xActual?: number; - yActual?: number; -} diff --git a/web-common/src/components/data-graphic/elements/Body.svelte b/web-common/src/components/data-graphic/elements/Body.svelte deleted file mode 100644 index f3eeab87649..00000000000 --- a/web-common/src/components/data-graphic/elements/Body.svelte +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - -{#if bg} - -{/if} - - - - - - {#if leftBorder} - - {/if} - {#if rightBorder} - - {/if} - {#if topBorder} - - {/if} - {#if bottomBorder} - - {/if} - diff --git a/web-common/src/components/data-graphic/elements/GraphicContext.svelte b/web-common/src/components/data-graphic/elements/GraphicContext.svelte deleted file mode 100644 index 432e5f0c7ae..00000000000 --- a/web-common/src/components/data-graphic/elements/GraphicContext.svelte +++ /dev/null @@ -1,219 +0,0 @@ - - - - diff --git a/web-common/src/components/data-graphic/elements/SimpleDataGraphic.svelte b/web-common/src/components/data-graphic/elements/SimpleDataGraphic.svelte deleted file mode 100644 index faeab4e2cf6..00000000000 --- a/web-common/src/components/data-graphic/elements/SimpleDataGraphic.svelte +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - diff --git a/web-common/src/components/data-graphic/elements/SimpleSVGContainer.svelte b/web-common/src/components/data-graphic/elements/SimpleSVGContainer.svelte deleted file mode 100644 index 12284f3b3de..00000000000 --- a/web-common/src/components/data-graphic/elements/SimpleSVGContainer.svelte +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - diff --git a/web-common/src/components/data-graphic/elements/index.ts b/web-common/src/components/data-graphic/elements/index.ts deleted file mode 100644 index fcf67660cca..00000000000 --- a/web-common/src/components/data-graphic/elements/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { default as Body } from "./Body.svelte"; -export { default as GraphicContext } from "./GraphicContext.svelte"; -export { default as SimpleDataGraphic } from "./SimpleDataGraphic.svelte"; -export { default as SimpleSVGContainer } from "./SimpleSVGContainer.svelte"; diff --git a/web-common/src/components/data-graphic/functional-components/WithBisector.svelte b/web-common/src/components/data-graphic/functional-components/WithBisector.svelte deleted file mode 100644 index 2c86b384711..00000000000 --- a/web-common/src/components/data-graphic/functional-components/WithBisector.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - - diff --git a/web-common/src/components/data-graphic/functional-components/WithDelayedValue.svelte b/web-common/src/components/data-graphic/functional-components/WithDelayedValue.svelte deleted file mode 100644 index c959fbbc655..00000000000 --- a/web-common/src/components/data-graphic/functional-components/WithDelayedValue.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - - diff --git a/web-common/src/components/data-graphic/functional-components/WithGraphicContexts.svelte b/web-common/src/components/data-graphic/functional-components/WithGraphicContexts.svelte deleted file mode 100644 index db8d63be910..00000000000 --- a/web-common/src/components/data-graphic/functional-components/WithGraphicContexts.svelte +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/web-common/src/components/data-graphic/functional-components/WithParentClientRect.svelte b/web-common/src/components/data-graphic/functional-components/WithParentClientRect.svelte deleted file mode 100644 index d6872b21a07..00000000000 --- a/web-common/src/components/data-graphic/functional-components/WithParentClientRect.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - diff --git a/web-common/src/components/data-graphic/functional-components/WithRoundToTimegrain.svelte b/web-common/src/components/data-graphic/functional-components/WithRoundToTimegrain.svelte deleted file mode 100644 index c6a0b140204..00000000000 --- a/web-common/src/components/data-graphic/functional-components/WithRoundToTimegrain.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - - diff --git a/web-common/src/components/data-graphic/functional-components/WithSimpleLinearScale.svelte b/web-common/src/components/data-graphic/functional-components/WithSimpleLinearScale.svelte deleted file mode 100644 index d12139765cd..00000000000 --- a/web-common/src/components/data-graphic/functional-components/WithSimpleLinearScale.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/web-common/src/components/data-graphic/functional-components/get-graphic-contexts.ts b/web-common/src/components/data-graphic/functional-components/get-graphic-contexts.ts deleted file mode 100644 index 6d91f92a58c..00000000000 --- a/web-common/src/components/data-graphic/functional-components/get-graphic-contexts.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { contexts } from "@rilldata/web-common/components/data-graphic/constants"; -import type { - ScaleStore, - SimpleConfigurationStore, -} from "@rilldata/web-common/components/data-graphic/state/types"; -import { getContext } from "svelte"; - -export function getGraphicContexts() { - return { - xScale: getContext(contexts.scale("x")) as ScaleStore, - yScale: getContext(contexts.scale("y")) as ScaleStore, - config: getContext(contexts.config) as SimpleConfigurationStore, - }; -} diff --git a/web-common/src/components/data-graphic/functional-components/index.ts b/web-common/src/components/data-graphic/functional-components/index.ts deleted file mode 100644 index da379431bb6..00000000000 --- a/web-common/src/components/data-graphic/functional-components/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { default as WithBisector } from "./WithBisector.svelte"; -export { default as WithDelayedValue } from "./WithDelayedValue.svelte"; -export { default as WithGraphicContexts } from "./WithGraphicContexts.svelte"; -export { default as WithParentClientRect } from "./WithParentClientRect.svelte"; -export { default as WithSimpleLinearScale } from "./WithSimpleLinearScale.svelte"; -export { default as WithTween } from "./WithTween.svelte"; diff --git a/web-common/src/components/data-graphic/functional-components/types.ts b/web-common/src/components/data-graphic/functional-components/types.ts deleted file mode 100644 index 48c581f0ad3..00000000000 --- a/web-common/src/components/data-graphic/functional-components/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type NumericPlotPoint = { - low: number; - high: number; - count: number; - value: number; -}; diff --git a/web-common/src/components/data-graphic/guides/Axis.svelte b/web-common/src/components/data-graphic/guides/Axis.svelte deleted file mode 100644 index 3e17e2a0a3c..00000000000 --- a/web-common/src/components/data-graphic/guides/Axis.svelte +++ /dev/null @@ -1,208 +0,0 @@ - - - - - {#each ticks as tick, i} - {@const tickPlacement = placeTick(side, tick)} - - {formatterFunction(tick)} - - {#if showTicks} - - - {/if} - {#if superLabelFormatter && shouldPlaceSuperLabel(superLabelFormatter(tick), i)} - - - {superLabelFormatter(tick)} - - {/if} - {/each} - diff --git a/web-common/src/components/data-graphic/guides/DynamicallyPlacedLabel.svelte b/web-common/src/components/data-graphic/guides/DynamicallyPlacedLabel.svelte deleted file mode 100644 index 7a24e811a6d..00000000000 --- a/web-common/src/components/data-graphic/guides/DynamicallyPlacedLabel.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - diff --git a/web-common/src/components/data-graphic/guides/Grid.svelte b/web-common/src/components/data-graphic/guides/Grid.svelte deleted file mode 100644 index 0479f52a82f..00000000000 --- a/web-common/src/components/data-graphic/guides/Grid.svelte +++ /dev/null @@ -1,66 +0,0 @@ - - - - - {#if showX} - {#each xTicks as tick} - - {/each} - {/if} - {#if showY} - {#each yTicks as tick} - - {/each} - {/if} - diff --git a/web-common/src/components/data-graphic/guides/PointLabel.svelte b/web-common/src/components/data-graphic/guides/PointLabel.svelte deleted file mode 100644 index 8806ba81c4c..00000000000 --- a/web-common/src/components/data-graphic/guides/PointLabel.svelte +++ /dev/null @@ -1,223 +0,0 @@ - - - - {@const isNull = point[yAccessor] == null} - {@const comparisonIsNull = - yComparisonAccessor === undefined || - point[yComparisonAccessor] === null || - point[yComparisonAccessor] === undefined} - {@const x = xScale(point[xAccessor])} - {@const y = !isNull - ? yScale(point[yAccessor]) - : lastAvailablePoint - ? yScale(lastAvailablePoint[yAccessor]) - : (config.plotBottom - config.plotTop) / 2} - - {@const comparisonY = yScale(point?.[`comparison.${yAccessor}`] || 0)} - - {@const text = isNull - ? "no data" - : format - ? format(point[yAccessor]) - : point[yAccessor]} - {@const comparisonText = - isNull || yComparisonAccessor === undefined - ? "no data" - : format - ? format(point[yAccessor] - point[yComparisonAccessor]) - : point[yAccessor] - point[yComparisonAccessor]} - {@const percentageDifference = - (isNull && comparisonIsNull) || yComparisonAccessor === undefined - ? undefined - : (point[yAccessor] - point[yComparisonAccessor]) / - point[yComparisonAccessor]} - {@const comparisonIsPositive = percentageDifference - ? percentageDifference >= 0 - : undefined} - {#if showReferenceLine} - - {/if} - {#if showText} - - {text} - - {/if} - {#if !isNull && showDistanceLine} - - {#if showComparisonText} - {@const signedDist = !comparisonIsPositive - ? -1 * COMPARISON_DIST - : 1 * COMPARISON_DIST} - {@const yLoc = output.y + signedDist} - {@const show = Math.abs(output.y - output.cdy) > 24} - {#if show} - - - {/if} - {/if} - {/if} - {#if !isNull && showPoint} - - {/if} - {#if !isNull && showPoint && showComparisonText} - - {/if} - {#if showComparisonText && percentageDifference} - {@const diffParts = - formatMeasurePercentageDifference(percentageDifference)} - - {comparisonText} - {" "} - ({diffParts?.neg || ""}{diffParts?.int || ""}{diffParts?.percent || ""}) - - - {/if} - - diff --git a/web-common/src/components/data-graphic/guides/TimeSeriesMouseover.svelte b/web-common/src/components/data-graphic/guides/TimeSeriesMouseover.svelte deleted file mode 100644 index 19e52f90146..00000000000 --- a/web-common/src/components/data-graphic/guides/TimeSeriesMouseover.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - - { - showRawValue = false; - }} -/> - - d[xAccessor]} - let:point -> - - {#key showRawValue} - - v.toString() : format} - /> - - {/key} - diff --git a/web-common/src/components/data-graphic/guides/index.ts b/web-common/src/components/data-graphic/guides/index.ts deleted file mode 100644 index 3715a7e4e1c..00000000000 --- a/web-common/src/components/data-graphic/guides/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * These guide components are used to provide some context - * for the data being displayed in the chart. - * Currently, they're a pastiche of components, without much - * thought to how they should be organized or generalized. - */ -export { default as Axis } from "./Axis.svelte"; -export { default as DynamicallyPlacedLabel } from "./DynamicallyPlacedLabel.svelte"; -export { default as Grid } from "./Grid.svelte"; -export { default as PointLabel } from "./PointLabel.svelte"; -export { default as TimeSeriesMouseover } from "./TimeSeriesMouseover.svelte"; diff --git a/web-common/src/components/data-graphic/guides/types.d.ts b/web-common/src/components/data-graphic/guides/types.d.ts deleted file mode 100644 index d4fd7fb6b37..00000000000 --- a/web-common/src/components/data-graphic/guides/types.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type PointLabelVariant = "moving" | "fixed"; -export type AxisSide = "left" | "right" | "top" | "bottom"; diff --git a/web-common/src/components/data-graphic/marks/AnnotationGroupPopover.svelte b/web-common/src/components/data-graphic/marks/AnnotationGroupPopover.svelte deleted file mode 100644 index 0773026cb47..00000000000 --- a/web-common/src/components/data-graphic/marks/AnnotationGroupPopover.svelte +++ /dev/null @@ -1,118 +0,0 @@ - - -{#if $hoveredAnnotationGroup} -
- (showingMore = false)} - > - - - - -
annotationPopoverHovered.set(true)} - on:mouseleave={() => annotationPopoverHovered.set(false)} - role="menu" - tabindex="-1" - > - {#each annotationsToShow as annotation, i (i)} -
-
- {annotation.description} -
-
- {annotation.formattedTimeOrRange} -
-
- {/each} - {#if hasMoreAnnotations && !showingMore} - - {/if} -
-
-
-
-{/if} diff --git a/web-common/src/components/data-graphic/marks/Annotations.svelte b/web-common/src/components/data-graphic/marks/Annotations.svelte deleted file mode 100644 index 172d9a24dc7..00000000000 --- a/web-common/src/components/data-graphic/marks/Annotations.svelte +++ /dev/null @@ -1,90 +0,0 @@ - - -{#each $annotationGroups as annotationGroup, i (i)} - {@const hovered = $hoveredAnnotationGroup === annotationGroup} - -{/each} - -{#if hasRange} - - - - - - - - -{/if} diff --git a/web-common/src/components/data-graphic/marks/Area.svelte b/web-common/src/components/data-graphic/marks/Area.svelte deleted file mode 100644 index 57851dae0ed..00000000000 --- a/web-common/src/components/data-graphic/marks/Area.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - - -{#if areaFcn} - - - - - - - -{/if} diff --git a/web-common/src/components/data-graphic/marks/ChunkedLine.svelte b/web-common/src/components/data-graphic/marks/ChunkedLine.svelte deleted file mode 100644 index 1579408d8cd..00000000000 --- a/web-common/src/components/data-graphic/marks/ChunkedLine.svelte +++ /dev/null @@ -1,186 +0,0 @@ - - - - - {@const delayedFilteredData = delayedValues[0]} - {@const delayedSegments = delayedValues[1]} - {@const delayedSingletons = delayedValues[2]} - {#each delayedSingletons as [singleton]} - - - {/each} - - - - - - {#if areaGradientColors !== null} - - - - - - - - - - {/if} - - - - {#each delayedSegments as segment (segment[0][xAccessor])} - {@const x = $xScale(segment[0][xAccessor])} - {@const width = - $xScale(segment.at(-1)[xAccessor]) - $xScale(segment[0][xAccessor])} - - {/each} - - - - diff --git a/web-common/src/components/data-graphic/marks/ClippedChunkedLine.svelte b/web-common/src/components/data-graphic/marks/ClippedChunkedLine.svelte deleted file mode 100644 index be63bd01e57..00000000000 --- a/web-common/src/components/data-graphic/marks/ClippedChunkedLine.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - diff --git a/web-common/src/components/data-graphic/marks/DelayedLabel.svelte b/web-common/src/components/data-graphic/marks/DelayedLabel.svelte deleted file mode 100644 index c31c4b6fab3..00000000000 --- a/web-common/src/components/data-graphic/marks/DelayedLabel.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - - diff --git a/web-common/src/components/data-graphic/marks/HistogramPrimitive.svelte b/web-common/src/components/data-graphic/marks/HistogramPrimitive.svelte deleted file mode 100644 index 4c3ff1ca814..00000000000 --- a/web-common/src/components/data-graphic/marks/HistogramPrimitive.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - - -{#if d?.length} - - - - - - - - -{/if} diff --git a/web-common/src/components/data-graphic/marks/Line.svelte b/web-common/src/components/data-graphic/marks/Line.svelte deleted file mode 100644 index 78752e88afe..00000000000 --- a/web-common/src/components/data-graphic/marks/Line.svelte +++ /dev/null @@ -1,124 +0,0 @@ - - - -{#if lineFcn} - -{/if} diff --git a/web-common/src/components/data-graphic/marks/MultiMetricMouseoverLabel.svelte b/web-common/src/components/data-graphic/marks/MultiMetricMouseoverLabel.svelte deleted file mode 100644 index 6b7c0ece55a..00000000000 --- a/web-common/src/components/data-graphic/marks/MultiMetricMouseoverLabel.svelte +++ /dev/null @@ -1,336 +0,0 @@ - - - - - {#if showLabels} - {#each locations as location (location.key || location.label)} - {#if (location.y || location.yRange) && (location.x || location.xRange)} - - {@const xText = - internalDirection === "right" - ? location.xRange + (xBuffer + xOffset + labelWidth) - : location.xRange - xBuffer - xOffset} - - - - {#if internalDirection === "right"} - - {#if !location?.yOverride} - {location.value - ? location.value - : formatValue(location.y)} - {/if} - - - - {#if location?.yOverride} - {location.yOverrideLabel} - {:else} - {location.label} - {/if} - - {:else} - - {#if location?.yOverride} - {location.yOverrideLabel} - {:else} - {location.label} - {/if} - - - {#if !location?.yOverride} - {location.value - ? location.value - : formatValue(location.y)} - {/if} - - {/if} - - - {#if location.yRange} - - {/if} - - - {/if} - {/each} - {/if} - - - diff --git a/web-common/src/components/data-graphic/marks/Rug.svelte b/web-common/src/components/data-graphic/marks/Rug.svelte deleted file mode 100644 index 6d797cc5586..00000000000 --- a/web-common/src/components/data-graphic/marks/Rug.svelte +++ /dev/null @@ -1,67 +0,0 @@ - - -{#if xScale && data} - - - {#each counts as countSet, i} - - - - {/each} - - -{/if} diff --git a/web-common/src/components/data-graphic/marks/annotations.ts b/web-common/src/components/data-graphic/marks/annotations.ts index c8947c3930b..d6f35c5b1c7 100644 --- a/web-common/src/components/data-graphic/marks/annotations.ts +++ b/web-common/src/components/data-graphic/marks/annotations.ts @@ -1,213 +1,8 @@ -import type { DomainCoordinates } from "@rilldata/web-common/components/data-graphic/constants/types"; -import type { - GraphicScale, - SimpleDataGraphicConfiguration, -} from "@rilldata/web-common/components/data-graphic/state/types"; -import { Throttler } from "@rilldata/web-common/lib/throttler.ts"; import type { V1MetricsViewAnnotationsResponseAnnotation } from "@rilldata/web-common/runtime-client"; -import type { ActionReturn } from "svelte/action"; -import { get, writable } from "svelte/store"; +import type { DateTime } from "luxon"; export type Annotation = V1MetricsViewAnnotationsResponseAnnotation & { - startTime: Date; - endTime?: Date; + startTime: DateTime; + endTime?: DateTime; formattedTimeOrRange: string; }; - -export type AnnotationGroup = { - items: Annotation[]; - top: number; - left: number; - bottom: number; - right: number; - hasRange: boolean; -}; - -export const AnnotationWidth = 10; -const AnnotationOverlapWidth = AnnotationWidth * (1 - 0.4); // Width where 40% overlap -export const AnnotationHeight = 10; - -export class AnnotationsStore { - public lookupTable = writable<(AnnotationGroup | undefined)[]>([]); - public annotationGroups = writable([]); - public hoveredAnnotationGroup = writable( - undefined, - ); - - public annotationPopoverOpened = writable(false); - public annotationPopoverHovered = writable(false); - public annotationPopoverTextHiddenCount = writable(0); - - private hoverCheckThrottler = new Throttler(100, 100); - - public updateData( - annotations: Annotation[], - scaler: GraphicScale, - config: SimpleDataGraphicConfiguration, - ) { - const groups = this.createAnnotationGroups(annotations, scaler, config); - this.annotationGroups.set(groups); - const lookupTable = this.buildLookupTable(groups); - this.lookupTable.set(lookupTable); - } - - public triggerHoverCheck( - mouseoverValue: DomainCoordinates | undefined, - mouseOverThisChart: boolean, - annotationPopoverHovered: boolean, - ) { - // Check the hover at a slight delay. - // When the popover is hovered, there will be a small window where both mouseOverThisChart and annotationPopoverHovered will be false. - this.hoverCheckThrottler.throttle(() => - this.checkHover( - mouseoverValue, - mouseOverThisChart, - annotationPopoverHovered, - ), - ); - } - - public textHiddenActions = (node: HTMLElement): ActionReturn => { - let hidden = false; - const checkTextHidden = () => { - const currentlyHidden = - node.scrollWidth > node.clientWidth || - node.scrollHeight > node.clientHeight; - if (currentlyHidden === hidden) return; - - this.annotationPopoverTextHiddenCount.set( - get(this.annotationPopoverTextHiddenCount) + (currentlyHidden ? 1 : -1), - ); - hidden = currentlyHidden; - }; - checkTextHidden(); - - node.addEventListener("resize", checkTextHidden); - return { - destroy: () => { - this.annotationPopoverTextHiddenCount.set( - get(this.annotationPopoverTextHiddenCount) + (hidden ? -1 : 0), - ); - node.removeEventListener("resize", checkTextHidden); - }, - }; - }; - - private checkHover( - mouseoverValue: DomainCoordinates | undefined, - mouseOverThisChart: boolean, - annotationPopoverHovered: boolean, - ) { - const annotationGroups = get(this.annotationGroups); - const lookupTable = get(this.lookupTable); - const top = annotationGroups[0]?.top; - let hoveredAnnotationGroup = get(this.hoveredAnnotationGroup); - - const mouseX = mouseoverValue?.xActual; - const mouseY = mouseoverValue?.yActual; - - const yNearAnnotations = mouseY !== undefined && mouseY > top; - const checkXCoord = yNearAnnotations && mouseX !== undefined; - - if (!mouseOverThisChart && !annotationPopoverHovered) { - // If the mouse is no longer hovering, the current chart or an annotation popover unset the group. - hoveredAnnotationGroup = undefined; - } else { - const tempHoverGroup = checkXCoord ? lookupTable[mouseX] : undefined; - const hoverGroupChanged = - tempHoverGroup && tempHoverGroup !== hoveredAnnotationGroup; - const cursorToLeftOfCurrentGroup = - hoveredAnnotationGroup && - mouseX !== undefined && - mouseX < hoveredAnnotationGroup.left; - - if (hoverGroupChanged) { - // To keep the popover opened for interaction, only update the hovered group when it changes but not when it goes undefined. - hoveredAnnotationGroup = tempHoverGroup; - } else if (cursorToLeftOfCurrentGroup) { - // Else to have better UX, if cursor is to the left of the currently hovered group then unset it. - hoveredAnnotationGroup = undefined; - } - } - - this.hoveredAnnotationGroup.set(hoveredAnnotationGroup); - } - - private createAnnotationGroups( - annotations: Annotation[], - scaler: GraphicScale, - config: SimpleDataGraphicConfiguration, - ): AnnotationGroup[] { - if (annotations.length === 0 || !scaler || !config) return []; - - let currentGroup: AnnotationGroup = this.getSingletonAnnotationGroup( - annotations[0], - scaler, - config, - ); - const groups: AnnotationGroup[] = [currentGroup]; - - for (let i = 1; i < annotations.length; i++) { - const annotation = annotations[i]; - const group = this.getSingletonAnnotationGroup( - annotation, - scaler, - config, - ); - - const leftDiff = group.left - currentGroup.left; - - if (leftDiff < AnnotationOverlapWidth) { - currentGroup.right = Math.max(currentGroup.right, group.right); - currentGroup.items.push(annotation); - } else { - currentGroup = group; - groups.push(currentGroup); - } - } - - // Filter out-of-bounds items. - return groups.filter( - (g) => g.left > config.plotLeft && g.left < config.plotRight, - ); - } - - private buildLookupTable(annotationGroups: AnnotationGroup[]) { - if (annotationGroups.length === 0) return []; - const lastGroup = annotationGroups[annotationGroups.length - 1]; - - const lookupTable = new Array( - Math.ceil(lastGroup.right) + 1, - ).fill(undefined); - - annotationGroups.forEach((group) => { - const left = Math.floor(group.left); - for (let x = 0; x <= AnnotationWidth; x++) { - lookupTable[left + x] = group; - } - }); - - return lookupTable; - } - - private getSingletonAnnotationGroup( - annotation: Annotation, - scaler: GraphicScale, - config: SimpleDataGraphicConfiguration, - ): AnnotationGroup { - const left = config.bodyLeft / 2 + scaler(annotation.startTime); - const right = - config.bodyLeft / 2 + - (annotation.endTime - ? scaler(annotation.endTime) - : left + AnnotationWidth); - return { - items: [annotation], - top: config.plotBottom - AnnotationHeight, - left, - bottom: config.plotBottom, - right, - hasRange: !!annotation.endTime, - }; - } -} diff --git a/web-common/src/components/data-graphic/marks/index.ts b/web-common/src/components/data-graphic/marks/index.ts deleted file mode 100644 index ed351e974ab..00000000000 --- a/web-common/src/components/data-graphic/marks/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { default as Area } from "./Area.svelte"; -export { default as ChunkedLine } from "./ChunkedLine.svelte"; -export { default as ClippedChunkedLine } from "./ClippedChunkedLine.svelte"; -export { default as HistogramPrimitive } from "./HistogramPrimitive.svelte"; -export { default as Line } from "./Line.svelte"; -export { default as Rug } from "./Rug.svelte"; diff --git a/web-common/src/components/data-graphic/marks/prevent-vertical-overlap.spec.ts b/web-common/src/components/data-graphic/marks/prevent-vertical-overlap.spec.ts deleted file mode 100644 index 91fdb68e306..00000000000 --- a/web-common/src/components/data-graphic/marks/prevent-vertical-overlap.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { preventVerticalOverlap } from "./prevent-vertical-overlap"; - -describe("preventVerticalOverlap", () => { - it("returns an empty array if input is empty", () => { - const result = preventVerticalOverlap([], 0, 100, 10, 2); - expect(result).toEqual([]); - }); - - it("returns the input array if only one point is provided", () => { - const input = [{ key: 1, value: 50 }]; - const expectedOutput = [{ key: 1, value: 50 }]; - const result = preventVerticalOverlap(input, 0, 100, 10, 2); - expect(result).toEqual(expectedOutput); - }); - - it("prevents overlap for points close together", () => { - const input = [ - { key: 1, value: 50 }, - { key: 2, value: 55 }, - ]; - const expectedOutput = [ - { key: 1, value: 43 }, - { key: 2, value: 55 }, - ]; - const result = preventVerticalOverlap(input, 0, 100, 10, 2); - expect(result).toEqual(expectedOutput); - }); - - it("prevents overlap for points and respects boundaries", () => { - const input = [ - { key: 1, value: 10 }, - { key: 2, value: 25 }, - { key: 3, value: 60 }, - { key: 4, value: 90 }, - ]; - const expectedOutput = [ - { key: 1, value: 12 }, - { key: 2, value: 25 }, - { key: 3, value: 60 }, - { key: 4, value: 88 }, - ]; - const result = preventVerticalOverlap(input, 10, 90, 10, 2); - expect(result).toEqual(expectedOutput); - }); - - it("handles case when all points are close to the top boundary", () => { - const input = [ - { key: 1, value: 15 }, - { key: 2, value: 20 }, - { key: 3, value: 25 }, - ]; - const expectedOutput = [ - { key: 1, value: 12 }, - { key: 2, value: 24 }, - { key: 3, value: 36 }, - ]; - const result = preventVerticalOverlap(input, 10, 90, 10, 2); - expect(result).toEqual(expectedOutput); - }); - it("handles case when all points are close to the bottom boundary", () => { - const input = [ - { key: 1, value: 75 }, - { key: 2, value: 80 }, - { key: 3, value: 85 }, - ]; - const expectedOutput = [ - { key: 1, value: 64 }, - { key: 2, value: 76 }, - { key: 3, value: 88 }, - ]; - const result = preventVerticalOverlap(input, 10, 90, 10, 2); - expect(result).toEqual(expectedOutput); - }); - - it("handles points close together in the middle", () => { - const input = [ - { key: 1, value: 45 }, - { key: 2, value: 50 }, - { key: 3, value: 55 }, - ]; - const expectedOutput = [ - { key: 1, value: 38 }, - { key: 2, value: 50 }, - { key: 3, value: 62 }, - ]; - const result = preventVerticalOverlap(input, 10, 90, 10, 2); - expect(result).toEqual(expectedOutput); - }); - - it("handles points near both boundaries", () => { - const input = [ - { key: 1, value: 15 }, - { key: 2, value: 85 }, - ]; - const expectedOutput = [ - { key: 1, value: 15 }, - { key: 2, value: 85 }, - ]; - const result = preventVerticalOverlap(input, 10, 90, 10, 2); - expect(result).toEqual(expectedOutput); - }); - - it("handles a large number of points close together", () => { - const input = Array.from({ length: 10 }, (_, i) => ({ - key: i, - value: i * 5 + 10, - })); - const result = preventVerticalOverlap(input, 0, 100, 10, 2); - - for (let i = 1; i < result.length; i++) { - const difference = result[i].value - result[i - 1].value; - expect(difference).toBeGreaterThanOrEqual(12); - } - }); -}); diff --git a/web-common/src/components/data-graphic/marks/prevent-vertical-overlap.ts b/web-common/src/components/data-graphic/marks/prevent-vertical-overlap.ts deleted file mode 100644 index 1cd15fb2296..00000000000 --- a/web-common/src/components/data-graphic/marks/prevent-vertical-overlap.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** This function implements a point-pushing algorithm that prevents - * y values from overlapping. It is used to prevent labels from - * overlapping in a data graphic. - * The algorithm is as follows: - * 1. Sort the points by y value. - * 2. If there is only one point, return it. - * 3. If there are no points, return an empty array. - * 4. Calculate the middle index. - * 5. Adjust the position of the points above the middle index. - * 6. Adjust the position of the top point and push down overlapping points. - * 7. Adjust the position of the points below the middle index. - * 8. Adjust the position of the bottom point and push up overlapping points. - * We can't claim that this works for every case, but it seems to work - * in most cases we care about. - */ -export function preventVerticalOverlap( - pt: { key: unknown; value: number }[], - topBoundary, - bottomBoundary, - elementHeight, - yBuffer, -): { key: unknown; value: number }[] { - // this is where the boundary condition lives. - - const locations = [...pt.map((p) => ({ ...p }))]; - // sort the locations by y value. - locations.sort((a, b) => a.value - b.value); - - if (locations.length === 1) { - return locations; - } - - if (!locations.length) return locations; - - // calculate the middle index. - const middle = Math.trunc(locations.length / 2); // eslint-disable-line - - // Adjust position of labels above the middle index - let i = middle; - while (i >= 0) { - if (i !== middle) { - const diff = locations[i + 1].value - locations[i].value; - if (diff <= elementHeight + yBuffer) { - locations[i].value -= elementHeight + yBuffer - diff; - } - } - i -= 1; - } - - // Adjust position of top label and push down overlapping labels - if (locations[0].value < topBoundary + yBuffer) { - locations[0].value = topBoundary + yBuffer; - i = 0; - while (i < middle) { - const diff = locations[i + 1].value - locations[i].value; - if (diff <= elementHeight + yBuffer) { - locations[i + 1].value += elementHeight + yBuffer - diff; - } - i += 1; - } - } - - // Adjust position of labels below the middle index - i = middle; - while (i < locations.length) { - if (i !== middle) { - const diff = locations[i].value - locations[i - 1].value; - if (diff < elementHeight + yBuffer) { - locations[i].value += elementHeight + yBuffer - diff; - } - } - i += 1; - } - - // Adjust position of bottom label and push up overlapping labels - if (locations[locations.length - 1].value > bottomBoundary - yBuffer) { - locations[locations.length - 1].value = bottomBoundary - yBuffer; - i = locations.length - 1; - while (i > 0) { - const diff = locations[i].value - locations[i - 1].value; - if (diff <= elementHeight + yBuffer) { - locations[i - 1].value -= elementHeight + yBuffer - diff; - } - i -= 1; - } - } - return locations; -} diff --git a/web-common/src/components/data-graphic/marks/segment.ts b/web-common/src/components/data-graphic/marks/segment.ts deleted file mode 100644 index 05236d14b80..00000000000 --- a/web-common/src/components/data-graphic/marks/segment.ts +++ /dev/null @@ -1,69 +0,0 @@ -function pathIsDefined(yAccessor: string) { - return (d: Record) => { - const val = d[yAccessor]; - return !( - val === undefined || - (typeof val === "number" && isNaN(val)) || - val === null - ); - }; -} - -/** - * Helper function to compute the contiguous segments of the data - * based on https://github.com/pbeshai/d3-line-chunked/blob/master/src/lineChunked.js - */ -export function computeSegments( - lineData: Record[], - yAccessor: string, -): Record[][] { - let startNewSegment = true; - - const defined = pathIsDefined(yAccessor); - // split into segments of continuous data - const segments = lineData.reduce( - (segments: Record[][], d) => { - // skip if this point has no data - if (!defined(d)) { - startNewSegment = true; - return segments; - } - - // if we are starting a new segment, start it with this point - if (startNewSegment) { - segments.push([d]); - startNewSegment = false; - - // otherwise see if we are adding to the last segment - } else { - const lastSegment = segments[segments.length - 1]; - lastSegment.push(d); - // if we expect this point to come next, add it to the segment - } - - return segments; - }, - [], - ); - - return segments; -} - -/** - * Compute the gaps from segments. Takes an array of segments and creates new segments - * based on the edges of adjacent segments. - * - * @param {Array} segments The segments array (e.g. from computeSegments) - * @return {Array} gaps The gaps array (same form as segments, but representing spaces between segments) - */ -export function gapsFromSegments(segments) { - const gaps = []; - for (let i = 0; i < segments.length - 1; i++) { - const currSegment = segments[i]; - const nextSegment = segments[i + 1]; - - gaps.push([currSegment[currSegment.length - 1], nextSegment[0]]); - } - - return gaps; -} diff --git a/web-common/src/components/data-graphic/marks/types.ts b/web-common/src/components/data-graphic/marks/types.ts deleted file mode 100644 index a3c60370901..00000000000 --- a/web-common/src/components/data-graphic/marks/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface Point { - x: number; - y: number; - value?: string; - label: string; - key: string; - valueColorClass?: string; - valueStyleClass?: string; - labelColorClass?: string; - labelStyleClass?: string; - pointColor?: string; - pointOpacity?: number; - yOverride?: boolean; - yOverrideLabel?: string; - yOverrideStyleClass?: string; -} - -export interface YValue { - y: string | number | Date | undefined | null; - name?: string | null; - color?: string; - isTimeComparison?: boolean; -} diff --git a/web-common/src/components/data-graphic/state/cascading-context-store.ts b/web-common/src/components/data-graphic/state/cascading-context-store.ts deleted file mode 100644 index daafb4d9e9a..00000000000 --- a/web-common/src/components/data-graphic/state/cascading-context-store.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { getContext, hasContext, setContext } from "svelte"; -import { get, writable } from "svelte/store"; - -export function pruneProps(props: T): T { - return Object.keys(props).reduce((next, prop) => { - if (props[prop] !== undefined) next[prop] = props[prop]; - return next; - }, {}) as T; -} - -function addDerivations(store, derivations) { - store.update((state) => { - Object.keys(derivations).forEach((key) => { - state[key] = derivations[key](state); - }); - return state; - }); -} - -/** Creates a store that passes itself down as a context. - * If any children of the parent that created the store create a cascadingContextStore, - * the store value will look like {...parentProps, ...childProps}. - * In this case, the child component calling the new cascadingContextStore will pass the - * new store down to its children, reconciling any differences downstream. - * - * this may seem complicated, but it does enable a lot of important - * reactive data viz component compositions. - * Most consumers of the data graphic components won't need to worry about this store. - */ -export function cascadingContextStore( - namespace: string, - props: Props, - derivations = {}, -) { - // check to see if namespace exists. - const hasParentCascade = hasContext(namespace); - - const prunedProps = pruneProps(props); - - let lastProps = props; - let lastParentState = {}; - - const store = writable(prunedProps); - let parentStore; - - if (hasParentCascade) { - parentStore = getContext(namespace); - store.set({ - ...get(parentStore), - ...prunedProps, - }); - - /** When the parent updates, we need to take care - * to reconcile parent and child + any changed props. - */ - parentStore.subscribe((parentState) => { - lastParentState = { ...parentState }; - store.set({ - ...parentState, // the parent state - ...pruneProps(lastProps), // last props to be reconciled overrides clashing keys with current state - }); - // add the derived values into the final store. - addDerivations(store, derivations); - }); - } - addDerivations(store, derivations); - // always reset the context for all children. - setContext(namespace, store); - return { - hasParentCascade, - subscribe: store.subscribe, - reconcileProps(props: Props) { - lastProps = { ...props }; - - /** let's update the store with the latest props. */ - store.set({ ...lastParentState, ...pruneProps(lastProps) }); - addDerivations(store, derivations); - }, - }; -} diff --git a/web-common/src/components/data-graphic/state/extremum-resolution-store.spec.ts b/web-common/src/components/data-graphic/state/extremum-resolution-store.spec.ts deleted file mode 100644 index 524531adb22..00000000000 --- a/web-common/src/components/data-graphic/state/extremum-resolution-store.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { createExtremumResolutionStore } from "./extremum-resolution-store"; -import { get } from "svelte/store"; - -describe("createExtremumResolutionStore", () => { - it("instantiates either with undefined or with a concrete value", () => { - const undefinedStore = createExtremumResolutionStore(); - expect(get(undefinedStore)).toBe(undefined); - const storeWithPassedValue = createExtremumResolutionStore(10); - expect(get(storeWithPassedValue)).toBe(10); - }); - it("instantiates with an empty value if you pass in a single instance value", () => { - const store = createExtremumResolutionStore(10); - expect(get(store)).toBe(10); - }); - it("picks the min value by default if direction not specified. Order should not matter", () => { - const store1 = createExtremumResolutionStore(10); - store1.setWithKey("first", 10); - expect(get(store1)).toBe(10); - store1.setWithKey("second", 5); - expect(get(store1)).toBe(5); - - // order should not matter - const store2 = createExtremumResolutionStore(10); - store2.setWithKey("second", 5); - expect(get(store2)).toBe(5); - store2.setWithKey("first", 10); - expect(get(store2)).toBe(5); - }); - it("picks the max value by default if not specified. Order should not matter.", () => { - const store = createExtremumResolutionStore(10, { direction: "max" }); - store.setWithKey("first", 10); - store.setWithKey("second", 5); - expect(get(store)).toBe(10); - - // order should not matter - store.setWithKey("first", 10); - store.setWithKey("second", 5); - expect(get(store)).toBe(10); - }); - - it("respects an override no matter the extremum values passed in", () => { - const minStore = createExtremumResolutionStore(10, { direction: "min" }); - minStore.setWithKey("overriding", 10, true); - expect(get(minStore)).toBe(10); - minStore.setWithKey("will not work", 5); - expect(get(minStore)).toBe(10); - }); - - it("defaults to the next most extreme value when a key is removed", () => { - const minStore = createExtremumResolutionStore(10, { direction: "min" }); - minStore.setWithKey("first", 3); - expect(get(minStore)).toBe(3); - minStore.setWithKey("second", 2); - expect(get(minStore)).toBe(2); - minStore.setWithKey("third", 1); - expect(get(minStore)).toBe(1); - minStore.removeKey("third"); - expect(get(minStore)).toBe(2); - minStore.removeKey("second"); - expect(get(minStore)).toBe(3); - minStore.removeKey("first"); - expect(get(minStore)).toBe(10); - }); -}); diff --git a/web-common/src/components/data-graphic/state/extremum-resolution-store.ts b/web-common/src/components/data-graphic/state/extremum-resolution-store.ts deleted file mode 100644 index 6666fa7eb20..00000000000 --- a/web-common/src/components/data-graphic/state/extremum-resolution-store.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * @module extremum-resolution-store - * This specialized store handles the resolution of plot bounds based - * on multiple extrema. If multiple components set a maximum value within - * the same namespace, the store will automatically pick the largest - * for the store value. This enables us to determine, for instance, if - * multiple lines are on the same chart, which ones determine the bounds. - */ -import { max, min } from "d3-array"; -import { cubicOut } from "svelte/easing"; -import { tweened } from "svelte/motion"; -import { derived, get, writable, type Writable } from "svelte/store"; -import type { EasingFunction } from "svelte/transition"; - -const LINEAR_SCALE_STORE_DEFAULTS = { - duration: 0, - easing: cubicOut, - direction: "min", - namespace: undefined, - alwaysOverrideInitialValue: false, -}; - -interface extremumArgs { - duration?: number; - easing?: EasingFunction; - direction?: string; - alwaysOverrideInitialValue?: boolean; -} - -interface Extremum { - value: number | Date | undefined; - override?: boolean; -} - -interface ExtremaStoreValue { - [key: string]: Extremum; -} - -const extremaFunctions = { min, max }; - -export function createExtremumResolutionStore( - initialValue: number | Date | undefined = undefined, - passedArgs: extremumArgs = {}, -) { - const args = { ...LINEAR_SCALE_STORE_DEFAULTS, ...passedArgs }; - const storedValues: Writable = writable({}); - let tweenProps = { - duration: args.duration, - easing: args.easing, - }; - const valueTween = tweened(initialValue, tweenProps); - function _update( - key: string, - value: number | Date | undefined, - override = false, - ) { - // FIXME: there's an odd bug where if I don't check for equality first, I tend - // to get an infinite loop with dates and the downstream scale. - // This is easily fixed by only updating if the value has in fact changed. - const extremum = get(storedValues)[key]; - if (extremum?.value === value && extremum?.override === override) return; - storedValues.update((storeValue) => { - if (!(key in storeValue)) - storeValue[key] = { value: undefined, override: false }; - storeValue[key].value = value; - storeValue[key].override = override; - return storeValue; - }); - } - /** add the initial value as its own key, if set by user. */ - if (initialValue && args.alwaysOverrideInitialValue === false) { - _update("__initial_value__", initialValue); - } - - function _remove(key: string) { - storedValues.update((storeValue) => { - delete storeValue[key]; - return storeValue; - }); - } - - const domainExtremum = derived( - storedValues, - ($storedValues) => { - let extremum; - const extrema: Extremum[] = [...Object.values($storedValues)]; - for (const entry of extrema) { - if (entry.override) { - extremum = entry.value; - break; - } else { - extremum = extremaFunctions[args.direction]([entry.value, extremum]); - } - } - return extremum; - }, - initialValue, - ); - - // set the final tween with the value. - domainExtremum.subscribe((value) => { - if (value !== undefined) { - valueTween.set(value, tweenProps); - } - }); - - const returnedStore = { - subscribe: valueTween.subscribe, - setWithKey( - key, - value: number | Date | undefined = undefined, - override: boolean | undefined = undefined, - ) { - _update(key, value, override); - }, - removeKey(key: string) { - _remove(key); - }, - setTweenProps(tweenPropsArgs) { - tweenProps = tweenPropsArgs; - }, - }; - return returnedStore; -} diff --git a/web-common/src/components/data-graphic/state/index.ts b/web-common/src/components/data-graphic/state/index.ts deleted file mode 100644 index 2fae0151c89..00000000000 --- a/web-common/src/components/data-graphic/state/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { initializeMaxMinStores, initializeScale } from "./scale-stores"; -export { cascadingContextStore, pruneProps } from "./cascading-context-store"; - -export enum ScaleType { - NUMBER = "number", - DATE = "date", -} diff --git a/web-common/src/components/data-graphic/state/scale-stores.ts b/web-common/src/components/data-graphic/state/scale-stores.ts deleted file mode 100644 index 60491973ae7..00000000000 --- a/web-common/src/components/data-graphic/state/scale-stores.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { scaleLinear, scaleTime } from "d3-scale"; -import { getContext, setContext } from "svelte"; -import { derived, type Writable } from "svelte/store"; -import { contexts } from "../constants"; -import { createExtremumResolutionStore } from "./extremum-resolution-store"; -import type { ScaleStore, SimpleConfigurationStore } from "./types"; - -const SCALES = { - number: scaleLinear, - date: scaleTime, -}; - -/** We operate on the domain through these stores. */ -export function initializeMaxMinStores({ - namespace, - domainMin = undefined, - domainMax = undefined, - domainMinTweenProps = { duration: 0 }, - domainMaxTweenProps = { duration: 0 }, -}: { - namespace: string; - domainMin?: number | Date; - domainMax?: number | Date; - domainMinTweenProps?: { duration: number }; - domainMaxTweenProps?: { duration: number }; -}) { - // initialize - const minStore = createExtremumResolutionStore(domainMin, { - direction: "min", - ...domainMinTweenProps, - }); - const maxStore = createExtremumResolutionStore(domainMax, { - direction: "max", - ...domainMaxTweenProps, - }); - if (domainMin !== undefined) minStore.setWithKey("global", domainMin, true); - if (domainMax !== undefined) maxStore.setWithKey("global", domainMax, true); - // set the contexts. - setContext(contexts.min(namespace), minStore); - setContext(contexts.max(namespace), maxStore); - return { minStore, maxStore }; -} - -export function initializeScale(args): ScaleStore { - const minStore = getContext(contexts.min(args.namespace)) as Writable< - number | Date - >; - const maxStore = getContext(contexts.max(args.namespace)) as Writable< - number | Date - >; - const config = getContext(contexts.config) as SimpleConfigurationStore; - const scaleStore = derived( - [minStore, maxStore, config], - ([$min, $max, $config]) => { - const scale = SCALES[args.scaleType]; - const minRangeValue: number | Date = - typeof args.rangeMin === "function" - ? args.rangeMin($config) - : args.rangeMin; - const maxRangeValue: number | Date = - typeof args.rangeMax === "function" - ? args.rangeMax($config) - : args.rangeMax; - return scale().domain([$min, $max]).range([minRangeValue, maxRangeValue]); - }, - ) as ScaleStore; - scaleStore.type = args.scaleType; - setContext(contexts.scale(args.namespace), scaleStore); - return scaleStore; -} diff --git a/web-common/src/components/data-graphic/state/types.d.ts b/web-common/src/components/data-graphic/state/types.d.ts deleted file mode 100644 index 0165ae79f61..00000000000 --- a/web-common/src/components/data-graphic/state/types.d.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { ScaleLinear, ScaleTime } from "d3-scale"; -import type { Readable } from "svelte/store"; - -export interface ExtremumResolutionTweenProps { - delay?: number; - duration?: number | ((from: T, to: T) => number); - easing?: (t: number) => number; - interpolate?: (a: T, b: T) => (t: number) => T; -} - -export interface ExtremumResolutionStore extends Readable { - setWithKey: (arg0: string, arg1: number | Date, arg2?: boolean) => void; - removeKey: (arg0: string) => void; - setTweenProps: (arg0: ExtremumResolutionTweenProps) => void; -} - -export type GraphicScale = - | ScaleLinear - | ScaleTime; -export interface ScaleStore extends Readable { - type: string; -} - -export interface SimpleDataGraphicConfigurationArguments { - id: string; - width: number; - height: number; - left: number; - right: number; - top: number; - bottom: number; - fontSize: number; - textGap: number; - xType: ScaleType; - yType: ScaleType; - xMin: number | Date; - xMax: number | Date; - yMin: number | Date; - yMax: number | Date; - bodyBuffer: number; - marginBuffer: number; - devicePixelRatio: number; -} - -export interface SimpleGraphicConfigurationDerivations { - bodyLeft: number; - bodyRight: number; - bodyTop: number; - bodyBottom: number; - plotLeft: number; - plotRight: number; - plotTop: number; - plotBottom: number; - graphicWidth: number; - graphicHeight: number; -} - -export interface SimpleDataGraphicConfiguration - extends SimpleDataGraphicConfigurationArguments, - SimpleGraphicConfigurationDerivations {} - -export interface CascadingContextStore - extends Readable { - hasParentCascade: boolean; - reconcileProps: (props: Arguments) => void; -} - -export type SimpleConfigurationStore = CascadingContextStore< - SimpleDataGraphicConfigurationArguments, - SimpleDataGraphicConfiguration ->; diff --git a/web-common/src/components/data-graphic/utils.ts b/web-common/src/components/data-graphic/utils.ts index eaf6a81c4e4..c9d0db1655d 100644 --- a/web-common/src/components/data-graphic/utils.ts +++ b/web-common/src/components/data-graphic/utils.ts @@ -1,45 +1,59 @@ -import { bisector } from "d3-array"; -import type { ScaleLinear, ScaleTime } from "d3-scale"; -import { area, curveLinear, curveStep, line } from "d3-shape"; -import { timeFormat } from "d3-time-format"; -import { getContext } from "svelte"; -import { derived, writable } from "svelte/store"; -import { contexts } from "./constants"; -import { curveStepExtended } from "./marks/curveStepExtended"; -import { ScaleType } from "./state"; -import type { - GraphicScale, - ScaleStore, - SimpleConfigurationStore, -} from "./state/types"; +import type { NumericHistogramBinsBin } from "@rilldata/web-common/runtime-client"; +import { area, curveLinear, line } from "d3-shape"; +import type { Area, CurveFactory, Line } from "d3-shape"; /** - * Creates a string to be fed into the d attribute of a path, - * producing a single path definition for one circle. - * These completed, segmented arcs will not overlap in a way where - * we can overplot if part of the same path. + * Creates a configured d3 line generator. + * Defaults to curveLinear; pass `defined` to skip null/invalid points. */ -export function circlePath(cx: number, cy: number, r: number): string { - return ` - M ${cx - r}, ${cy} - a ${r},${r} 0 1,0 ${r * 2},0 - a ${r},${r} 0 1,0 ${-r * 2},0 - `; +export function createLineGenerator(args: { + x: (d: T, i: number, data: T[]) => number; + y: (d: T, i: number, data: T[]) => number; + defined?: (d: T, i: number, data: T[]) => boolean; + curve?: CurveFactory; +}): Line { + const gen = line() + .x(args.x) + .y(args.y) + .curve(args.curve ?? curveLinear); + if (args.defined) gen.defined(args.defined); + return gen; } -const curves = { - curveLinear, - curveStep, - curveStepExtended, -}; +/** + * Creates a configured d3 area generator. + * y0/y1 each accept a constant number or an accessor function. + * Defaults to curveLinear; pass `defined` to skip null/invalid points. + */ +export function createAreaGenerator(args: { + x: (d: T, i: number, data: T[]) => number; + y0: number | ((d: T, i: number, data: T[]) => number); + y1: number | ((d: T, i: number, data: T[]) => number); + defined?: (d: T, i: number, data: T[]) => boolean; + curve?: CurveFactory; +}): Area { + const gen = area() + .x(args.x) + .curve(args.curve ?? curveLinear); + // The typeof narrowing is required: d3's .y0()/.y1() have separate overloads + // for number vs function, and TypeScript won't accept the union directly. + if (typeof args.y0 === "number") gen.y0(args.y0); + else gen.y0(args.y0); + if (typeof args.y1 === "number") gen.y1(args.y1); + else gen.y1(args.y1); + if (args.defined) gen.defined(args.defined); + return gen; +} -export function pathDoesNotDropToZero(yAccessor: string) { - return (d, i: number, arr) => { +/** + * Filter predicate that removes consecutive zero values from a path. + * Zeroes are kept if at least one neighbor is non-zero. + */ +export function pathDoesNotDropToZero(yAccessor: keyof T) { + return (d: T, i: number, arr: T[]): boolean => { return ( - !isNaN(d[yAccessor]) && + (typeof d[yAccessor] !== "number" || !isNaN(d[yAccessor])) && d[yAccessor] !== undefined && - // remove all zeroes where the previous or next value is also zero. - // these do not add to our understanding. (!(i !== 0 && d[yAccessor] === 0 && arr[i - 1][yAccessor] === 0) || !( i !== arr.length - 1 && @@ -50,304 +64,67 @@ export function pathDoesNotDropToZero(yAccessor: string) { }; } -export interface PlotConfig { - top: number; - bottom: number; - left: number; - right: number; - buffer: number; - width: number; - height: number; - devicePixelRatio: number; - plotTop: number; - plotBottom: number; - plotLeft: number; - plotRight: number; - fontSize: number; - textGap: number; - id: string; -} - -interface LineGeneratorArguments { - xAccessor: string; - xScale: - | ScaleLinear - | ScaleTime - | ((d) => number); - yScale: - | ScaleLinear - | ScaleTime - | ((d) => number); - curve?: string; - pathDefined?: ( - datum: object, - i?: number, - arr?: ArrayLike, - ) => boolean; -} - /** - * A convenience function to generate a nice SVG path for a time series. - * FIXME: rename to timeSeriesLineFactory. - * FIXME: once we've gotten the data generics in place and threaded into components, let's make sure to type this. + * Generates an SVG path string for a histogram / bar plot. + * Each bin is defined by a low/high x range and a y count value. + * The path traces the outline of all non-zero bins, suitable for + * both fill and stroke rendering. */ -export function lineFactory(args: LineGeneratorArguments) { - return (yAccessor: string) => - line() - .x((d) => args.xScale(d[args.xAccessor])) - .y((d) => args.yScale(d[yAccessor])) - .curve(args.curve ? curves[args.curve] : curveLinear) - .defined(args.pathDefined || pathDoesNotDropToZero(yAccessor)); -} - -/** - * A convenience function to generate a nice SVG area path for a time series. - * FIXME: rename to timeSeriesAreaFactory. - * FIXME: once we've gotten the data generics in place and threaded into components, let's make sure to type this. - */ -export function areaFactory(args: LineGeneratorArguments) { - return (yAccessor: string) => - area() - .x((d) => args.xScale(d[args.xAccessor])) - .y0(args.yScale(0)) - .y1((d) => args.yScale(d[yAccessor])) - .curve(args.curve ? curves[args.curve] : curveLinear) - .defined(args.pathDefined || pathDoesNotDropToZero(yAccessor)); -} - -/** - * Return a list of ticks to be represented on the - * axis or grid depending on axis-side, it's length and - * the data type of field - */ -export function getTicks( - xOrY: string, - scale: GraphicScale, - axisLength: number, - scaleType: ScaleType, -) { - const isDate = scaleType === ScaleType.DATE; - const tickCount = Math.trunc(axisLength / (xOrY === "x" ? 100 : 50)); - - let ticks = scale.ticks(tickCount); - - // Prevent overlapping ticks on X axis - if (xOrY === "x" && axisLength / ticks.length < 60) { - ticks = scale.ticks(tickCount - 1); - } - - if (ticks.length <= 1) { - if (isDate) ticks = scale.domain(); - else ticks = scale.nice().domain(); - } - - return ticks; -} - export function barplotPolyline( - data, - xLow, - xHigh, - yAccessor, - X, - Y, + data: NumericHistogramBinsBin[], + xScale: (v: number) => number, + yScale: (v: number) => number, separator = 1, closeBottom = false, inflator = 1, ): string { if (!data?.length) return ""; - const path = data.reduce((pointsPathString, datum, i) => { - const low = datum[xLow]; - const high = datum[xHigh]; - const count = datum[yAccessor]; - const x = X(low) + separator; + const baseline = yScale(0); - const width = Math.max(0.5, X(high) - X(low) - separator * 2); - const y = Y(0) * (1 - inflator) + Y(count) * inflator; + const path = data.reduce((acc: string, datum, i) => { + const count = datum.count ?? 0; + if (count === 0) return acc; - const computedHeight = Math.min( - Y(0), - Y(0) * inflator - Y(count) * inflator, + const low = datum.low ?? 0; + const high = datum.high ?? 0; + const x = xScale(low) + separator; + const width = Math.max(0.5, xScale(high) - xScale(low) - separator * 2); + const y = baseline * (1 - inflator) + yScale(count) * inflator; + const barHeight = Math.min( + baseline, + baseline * inflator - yScale(count) * inflator, ); - const height = separator > 0 ? computedHeight : 0; + const dropHeight = separator > 0 ? barHeight : 0; - // do not add zero values here - if (count === 0) { - return pointsPathString; - } - - let p1 = ""; - - const nextPointIsZero = i < data.length - 1 && data[i + 1][yAccessor] === 0; + const prevIsZero = i > 0 && !data[i - 1].count; + const nextIsZero = i < data.length - 1 && !data[i + 1].count; - const lastPointWasZero = i > 0 && data[i - 1][yAccessor] === 0; - - if (separator === 0 && lastPointWasZero) { - // we will need to start this thing at 0? - p1 = `M${x},${y + computedHeight}`; + // Move to the bottom-left of this bar + let move: string; + if (separator === 0 && prevIsZero) { + move = `M${x},${y + barHeight}`; } else if (separator > 0 || i === 0) { - // standard case. - p1 = `${i !== 0 ? "M" : ""}${x},${y + height}`; + move = `${i !== 0 ? "M" : ""}${x},${y + dropHeight}`; + } else { + move = ""; } - const p2 = `${x},${y}`; - const p3 = `${x + width},${y}`; - - const p4 = - separator > 0 || nextPointIsZero - ? `${x + width},${y + (separator > 0 ? height : computedHeight)}` + const topLeft = `${x},${y}`; + const topRight = `${x + width},${y}`; + const bottomRight = + separator > 0 || nextIsZero + ? `${x + width},${y + (separator > 0 ? dropHeight : barHeight)}` : ""; - const closedBottom = closeBottom ? `${x},${y + height}` : ""; + const close = closeBottom ? `${x},${y + dropHeight}` : ""; - return pointsPathString + `${p1} ${p2} ${p3} ${p4} ${closedBottom} `; + return acc + `${move} ${topLeft} ${topRight} ${bottomRight} ${close} `; }, " "); - const lastElement = data.findLast((d) => d[yAccessor]); - if (!lastElement) return ""; - return ( - `M${X(data[0][xLow]) + separator},${Y(0)} ` + - path + - ` ${X(lastElement[xHigh]) - separator},${Y(0)} ` - ); -} - -/** utilizes the provided scales to calculate the line thinness in a way - * that enables higher-density "overplotted lines". - */ - -export function createAdaptiveLineThicknessStore(yAccessor) { - let data; - - // get xScale, yScale, and config from contexts - const xScale = getContext(contexts.scale("x")); - const yScale = getContext(contexts.scale("y")); - const config = getContext(contexts.config); - - // capture data state. - const dataStore = writable(data); - - const store = derived( - [xScale, yScale, config, dataStore], - ([$xScale, $yScale, $config, $data]) => { - if (!$data) { - return 1; - } - const totalTravelDistance = $data - .filter((di) => di[yAccessor] !== null) - .map((di, i) => { - if (i === $data.length - 1) { - return 0; - } - const max = Math.max( - $yScale($data[i + 1][yAccessor]), - $yScale($data[i][yAccessor]), - ); - const min = Math.min( - $yScale($data[i + 1][yAccessor]), - $yScale($data[i][yAccessor]), - ); - if (isNaN(min) || isNaN(max)) return 1 / $data.length; - return Math.abs(max - min); - }) - .reduce((acc, v) => acc + v, 0); - - const yIshDistanceTravelled = - 2 / - (totalTravelDistance / - (($xScale.range()[1] - $xScale.range()[0]) * - ($config.devicePixelRatio || 3))); - - const xIshDistanceTravellled = - (($xScale.range()[1] - $xScale.range()[0]) * - ($config.devicePixelRatio || 3) * - 0.7) / - $data.length / - 1.5; - - const value = Math.min( - 1, - /** to determine the stroke width of the path, let's look at - * the bigger of two values: - * 1. the "y-ish" distance travelled - * the inverse of "total travel distance", which is the Y - * gap size b/t successive points divided by the zoom window size; - * 2. time series length / available X pixels - * the time series divided by the total number of pixels in the existing - * zoom window. - * - * These heuristics could be refined, but this seems to provide a reasonable approximation for - * the stroke width. (1) excels when lots of successive points are close together in the Y direction, - * whereas (2) excels` when a line is very, very noisy (and thus the X direction is the main constraint). - */ - Math.max( - // the y-ish distance travelled - yIshDistanceTravelled, - // the time series length / available X pixels - xIshDistanceTravellled, - ), - ); - - return value; - }, - ); - - return { - subscribe: store.subscribe, - /** trigger an update when the data changes */ - setData(d) { - dataStore.set(d); - }, - }; -} - -// This is function equivalent of WithBisector -export function bisectData( - value: Date, - direction: "left" | "right" | "center", - accessor: keyof T, - data: ArrayLike, -): { position: number; entry: T } { - const bisect = bisector((d) => d[accessor])[direction]; - const position = bisect(data, value); - - return { - position, - entry: data[position], - }; -} -/** For a scale domain returns a formatter for axis label and super label */ -export function createTimeFormat( - scaleDomain: [Date, Date], - numberOfValues: number, -): [(d: Date) => string, ((d: Date) => string) | undefined] { - const diff = - Math.abs(scaleDomain[1]?.getTime() - scaleDomain[0]?.getTime()) / 1000; - if (!diff) return [timeFormat("%d %b"), timeFormat("%Y")]; - const gap = diff / (numberOfValues - 1); // time gap between two consecutive values + const lastNonZero = data.findLast((d) => d.count); + if (!lastNonZero) return ""; - // If the gap is less than a second, format in milliseconds - if (gap < 1) { - return [timeFormat("%M:%S.%L"), timeFormat("%H %d %b %Y")]; - } - // If the gap is less than a minute, format in seconds - else if (gap < 60) { - return [timeFormat("%M:%S"), timeFormat("%H %d %b %Y")]; - } - // If the gap is less than 24 hours, format in hours and minutes - else if (gap < 60 * 60 * 24) { - return [timeFormat("%H:%M"), timeFormat("%d %b %Y")]; - } - // If the gap is less than 30 days, format in days - else if (gap < 60 * 60 * 24 * 30) { - return [timeFormat("%b %d"), timeFormat("%Y")]; - } - // If the gap is less than a year, format in months - else if (gap < 60 * 60 * 24 * 365) { - return [timeFormat("%b"), timeFormat("%Y")]; - } - // Else format in years - else { - return [timeFormat("%Y"), undefined]; - } + const startX = xScale(data[0].low ?? 0) + separator; + const endX = xScale(lastNonZero.high ?? 0) - separator; + return `M${startX},${baseline} ${path} ${endX},${baseline} `; } diff --git a/web-common/src/components/time-series-chart/BarChart.svelte b/web-common/src/components/time-series-chart/BarChart.svelte new file mode 100644 index 00000000000..aa86902a63b --- /dev/null +++ b/web-common/src/components/time-series-chart/BarChart.svelte @@ -0,0 +1,99 @@ + + +{#if stacked} + {#each { length: visibleCount } as _, slot (slot)} + {@const ptIdx = visibleStart + slot} + {@const cx = plotLeft + (slot + 0.5) * geo.slotWidth} + {@const bx = cx - geo.bandWidth / 2} + {@const stackValues = series.map((s) => ({ + value: s.values[ptIdx] ?? 0, + color: s.color, + id: s.id, + }))} + {#each stackValues as seg, segIdx (seg.id)} + {#if seg.value !== 0} + {@const yBottom = yScale( + stackValues.slice(0, segIdx).reduce((sum, sv) => sum + sv.value, 0), + )} + {@const yTop = yScale( + stackValues + .slice(0, segIdx + 1) + .reduce((sum, sv) => sum + sv.value, 0), + )} + + {/if} + {/each} + {/each} +{:else} + {#each { length: visibleCount } as _, slot (slot)} + {@const ptIdx = visibleStart + slot} + {@const cx = plotLeft + (slot + 0.5) * geo.slotWidth} + {#each series as s, sIdx (s.id)} + {@const v = s.values[ptIdx] ?? null} + {#if v !== null} + {@const bx = + cx - geo.bandWidth / 2 + sIdx * (geo.singleBarWidth + geo.barGap)} + {@const by = Math.min(zeroY, yScale(v))} + {@const bh = Math.abs(zeroY - yScale(v))} + {@const r = Math.min(BAR_RADIUS, geo.singleBarWidth / 2, bh / 2)} + {@const isPositive = v >= 0} + + {/if} + {/each} + {/each} +{/if} diff --git a/web-common/src/components/time-series-chart/Line.svelte b/web-common/src/components/time-series-chart/Line.svelte index cd2018e3535..361238e2bff 100644 --- a/web-common/src/components/time-series-chart/Line.svelte +++ b/web-common/src/components/time-series-chart/Line.svelte @@ -1,11 +1,14 @@ {#if fill} - + - + + import { + createLineGenerator, + createAreaGenerator, + } from "@rilldata/web-common/components/data-graphic/utils"; + import type { + ChartSeries, + ChartScales, + } from "@rilldata/web-common/features/dashboards/time-series/measure-chart/types"; + + /** + * Rendering sparse data with null gaps + * + * 1. Null bridging: `bridgeSmallGaps` linearly interpolates across small + * gaps (< MAX_BRIDGE_GAP_PX) when `connectNulls` is on. Large gaps + * remain as nulls and produce natural line breaks. + * + * 2. Clip paths: The primary series needs clip paths because its area + * fill gradient would otherwise render across gaps (`defined` only + * affects line generators, not the filled path). + * - `seg-clip`: real data segments only (connectNulls off) + * - `full-clip`: real + bridged segments (connectNulls on, area fill) + * - `scrub-clip`: scrub selection rect — chart draws muted, then + * re-draws with original colors inside this clip + * Secondary series have no area fill, so they rely on the line + * generator's `defined` callback and only use `scrub-clip`. + * + * 3. Singletons: When `connectNulls` is off, isolated points (no adjacent + * non-null neighbors) are drawn as circles since there's no line + * segment to render. + */ + + const MAX_BRIDGE_GAP_PX = 40; + + interface Segment { + startIndex: number; + endIndex: number; + } + + interface BridgeResult { + values: (number | null)[]; + inputSegments: Segment[]; + } + + const chartId = Math.random().toString(36).slice(2, 11); + + export let series: ChartSeries[]; + export let scales: ChartScales; + export let hasScrubSelection: boolean = false; + export let scrubStartIndex: number | null = null; + export let scrubEndIndex: number | null = null; + export let connectNulls: boolean = true; + + $: lineGen = createLineGenerator({ + x: (_d, i) => scales.x(i), + y: (d) => scales.y(d ?? 0), + defined: (d) => d !== null, + }); + + $: areaGen = createAreaGenerator({ + x: (_d, i) => scales.x(i), + y0: scales.y(0), + y1: (d) => scales.y(d ?? 0), + defined: (d) => d !== null, + }); + + $: primarySeries = series[0]; + + $: primaryBridgeResult = primarySeries + ? bridgeSmallGaps(primarySeries.values, scales.x, connectNulls) + : { values: [] as (number | null)[], inputSegments: [] }; + $: primaryBridged = primaryBridgeResult.values; + + $: primaryLinePath = primarySeries ? (lineGen(primaryBridged) ?? "") : ""; + $: primaryAreaPath = primarySeries ? (areaGen(primaryBridged) ?? "") : ""; + + $: primaryRealSegments = primaryBridgeResult.inputSegments; + $: primarySegments = primarySeries ? computeSegments(primaryBridged) : []; + $: primarySingletons = + primarySeries && !connectNulls + ? primarySegments + .filter((s) => s.startIndex === s.endIndex) + .map((s) => s.startIndex) + : []; + + $: secondarySeries = series.slice(1).map((s) => { + const bridged = bridgeSmallGaps(s.values, scales.x, connectNulls); + const singletons = !connectNulls + ? computeSegments(bridged.values) + .filter((seg) => seg.startIndex === seg.endIndex) + .map((seg) => seg.startIndex) + : []; + return { ...s, bridgedValues: bridged.values, singletons }; + }); + + $: scrubClipX = + scrubStartIndex !== null && scrubEndIndex !== null + ? Math.min(scales.x(scrubStartIndex), scales.x(scrubEndIndex)) + : 0; + $: scrubClipWidth = + scrubStartIndex !== null && scrubEndIndex !== null + ? Math.abs(scales.x(scrubEndIndex) - scales.x(scrubStartIndex)) + : 0; + + $: primaryLineColor = primarySeries + ? hasScrubSelection + ? "var(--color-gray-500)" + : primarySeries.color + : "var(--color-gray-500)"; + + $: primaryAreaStart = primarySeries?.areaGradient + ? hasScrubSelection + ? "var(--color-gray-300)" + : primarySeries.areaGradient.dark + : "transparent"; + $: primaryAreaEnd = primarySeries?.areaGradient + ? hasScrubSelection + ? "var(--color-gray-50)" + : primarySeries.areaGradient.light + : "transparent"; + + function computeSegments(values: (number | null)[]): Segment[] { + const segments: Segment[] = []; + let segStart = -1; + for (let i = 0; i < values.length; i++) { + if (values[i] !== null) { + if (segStart === -1) segStart = i; + } else if (segStart !== -1) { + segments.push({ startIndex: segStart, endIndex: i - 1 }); + segStart = -1; + } + } + if (segStart !== -1) + segments.push({ startIndex: segStart, endIndex: values.length - 1 }); + return segments; + } + + function bridgeSmallGaps( + values: (number | null)[], + xScale: (i: number) => number, + shouldBridge: boolean, + ): BridgeResult { + const inputSegments = computeSegments(values); + + if (!shouldBridge || values.length < 3 || inputSegments.length <= 1) { + return { values, inputSegments }; + } + + const result = [...values]; + + for (let i = 0; i < inputSegments.length - 1; i++) { + const prev = inputSegments[i]; + const next = inputSegments[i + 1]; + const gapPx = xScale(next.startIndex) - xScale(prev.endIndex); + + if (gapPx <= MAX_BRIDGE_GAP_PX) { + const v0 = values[prev.endIndex]!; + const v1 = values[next.startIndex]!; + const span = next.startIndex - prev.endIndex; + for (let j = prev.endIndex + 1; j < next.startIndex; j++) { + const t = (j - prev.endIndex) / span; + result[j] = v0 + t * (v1 - v0); + } + } + } + + return { values: result, inputSegments }; + } + + + + {#if primarySeries?.areaGradient} + + + + + + + + + {/if} + + {#if primarySeries} + + + {#each primaryRealSegments as seg (seg.startIndex)} + {@const x = scales.x(seg.startIndex)} + {@const width = scales.x(seg.endIndex) - x} + + {/each} + + + + {#each primarySegments as seg (seg.startIndex)} + {@const x = scales.x(seg.startIndex)} + {@const width = scales.x(seg.endIndex) - x} + + {/each} + + {/if} + + {#if hasScrubSelection && scrubStartIndex !== null && scrubEndIndex !== null} + + + + {/if} + + + +{#if primarySeries?.areaGradient} + +{/if} + + +{#each secondarySeries as s (s.id)} + + {#each s.singletons as idx (idx)} + {@const v = s.values[idx] ?? 0} + + {/each} + {#if hasScrubSelection && scrubStartIndex !== null && scrubEndIndex !== null} + + + {#each s.singletons as idx (idx)} + {@const v = s.values[idx] ?? 0} + + {/each} + + {/if} +{/each} + + +{#if primarySeries} + {#if connectNulls} + + {:else} + + {/if} + + {#each primarySingletons as idx (idx)} + {@const v = primarySeries.values[idx] ?? 0} + + {/each} +{/if} + + +{#if hasScrubSelection && scrubStartIndex !== null && scrubEndIndex !== null && primarySeries} + + {#if primarySeries.areaGradient} + + {/if} + + {#each primarySingletons as idx (idx)} + {@const v = primarySeries.values[idx] ?? 0} + + {/each} + +{/if} + + diff --git a/web-common/src/components/vega/vega-config.ts b/web-common/src/components/vega/vega-config.ts index 506a7608fd0..5a2bb743e4c 100644 --- a/web-common/src/components/vega/vega-config.ts +++ b/web-common/src/components/vega/vega-config.ts @@ -1,4 +1,4 @@ -import { COMPARIONS_COLORS } from "@rilldata/web-common/features/dashboards/config"; +import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; import { getSequentialColorsAsHex } from "@rilldata/web-common/features/themes/palette-store"; import { themeManager } from "@rilldata/web-common/features/themes/theme-manager"; import { getChroma } from "@rilldata/web-common/features/themes/theme-utils"; @@ -170,7 +170,7 @@ export const getRillTheme: ( }, range: { category: (() => { - const defaultColors = COMPARIONS_COLORS.map((color) => + const defaultColors = COMPARISON_COLORS.map((color) => color.startsWith("var(") ? resolveCSSVariable(color, isDarkMode) : color, diff --git a/web-common/src/features/canvas/inspector/chart/field-config/ColorPaletteSelector.svelte b/web-common/src/features/canvas/inspector/chart/field-config/ColorPaletteSelector.svelte index 927142fb63f..c2ad5ff383d 100644 --- a/web-common/src/features/canvas/inspector/chart/field-config/ColorPaletteSelector.svelte +++ b/web-common/src/features/canvas/inspector/chart/field-config/ColorPaletteSelector.svelte @@ -11,7 +11,7 @@ getColorForValues, resolveCSSVariable, } from "@rilldata/web-common/features/components/charts/util"; - import { COMPARIONS_COLORS } from "@rilldata/web-common/features/dashboards/config"; + import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; import { ChevronDown, ChevronRight } from "lucide-svelte"; import { slide } from "svelte/transition"; @@ -40,7 +40,7 @@ function handleColorChange(value: string, newColor: string) { const valueIndex = colorValues.findIndex((v) => v === value); const defaultColorVar = - COMPARIONS_COLORS[valueIndex % COMPARIONS_COLORS.length]; + COMPARISON_COLORS[valueIndex % COMPARISON_COLORS.length]; // Convert the color back to a CSS variable reference if it matches a palette color const colorToSave = colorToVariableReference(newColor); diff --git a/web-common/src/features/column-profile/column-types/NumericProfile.svelte b/web-common/src/features/column-profile/column-types/NumericProfile.svelte index 782f4abb73d..c2db676a614 100644 --- a/web-common/src/features/column-profile/column-types/NumericProfile.svelte +++ b/web-common/src/features/column-profile/column-types/NumericProfile.svelte @@ -64,7 +64,6 @@ columnName, QueryServiceColumnNumericHistogramHistogramMethod.HISTOGRAM_METHOD_DIAGNOSTIC, enableProfiling, - active, ); let fdHistogram; $: if (isFloat(type)) { @@ -77,7 +76,6 @@ columnName, QueryServiceColumnNumericHistogramHistogramMethod.HISTOGRAM_METHOD_FD, enableProfiling, - active, ); } diff --git a/web-common/src/features/column-profile/column-types/TimestampProfile.svelte b/web-common/src/features/column-profile/column-types/TimestampProfile.svelte index b4918952549..77258e5929e 100644 --- a/web-common/src/features/column-profile/column-types/TimestampProfile.svelte +++ b/web-common/src/features/column-profile/column-types/TimestampProfile.svelte @@ -1,7 +1,6 @@
{columnName}
- -
- - - +
+
-
- - {#if $timeSeries?.data?.length && $timeSeries?.estimatedRollupInterval?.interval && $timeSeries?.smallestTimegrain} - - {/if} - +
+ {#if $timeSeries?.data?.length && $timeSeries?.estimatedRollupInterval?.interval && $timeSeries?.smallestTimegrain} + + {/if}
diff --git a/web-common/src/features/column-profile/column-types/details/NumericPlot.svelte b/web-common/src/features/column-profile/column-types/details/NumericPlot.svelte index 6769be5ee48..e606d674b1a 100644 --- a/web-common/src/features/column-profile/column-types/details/NumericPlot.svelte +++ b/web-common/src/features/column-profile/column-types/details/NumericPlot.svelte @@ -1,4 +1,4 @@ -
- - + + + { + mouseX = e.offsetX; + }} + on:mouseleave={() => { + mouseX = undefined; + }} > - - (dt.high + dt.low) / 2} - value={mouseoverValue?.x} - let:point - > - {#if data} - - - - + + + + {#if histPath?.length} + + + {/if} + + + + + + {#if hoveredBin} + + + - - - {#if point} - - - - - - - - - {/if} - - - {#if point?.low !== undefined} - - ({justEnoughPrecision(point?.low)}, {justEnoughPrecision( - point?.high, - )}{point?.high === data.at(-1)?.high ? ")" : "]"} - - {formatInteger(Math.trunc(point.count))} row{#if point.count !== 1}s{/if} - ({((point.count / totalRows) * 100).toFixed(2)}%) - - - {/if} + + + + + {/if} - - {#if focusPoint?.count !== undefined && focusPoint?.value && topK && summaryMode === "topk"} - - - - - - - {/if} - {/if} - - - - {#if rug} - + + {#if hoveredBin?.low !== undefined} + + ({justEnoughPrecision(hoveredBin?.low ?? 0)}, {justEnoughPrecision( + hoveredBin?.high ?? 0, + )}{hoveredBin?.high === data.at(-1)?.high ? ")" : "]"} + + {formatInteger(Math.trunc(hoveredBin.count ?? 0))} row{#if (hoveredBin.count ?? 0) !== 1}s{/if} + ({(((hoveredBin.count ?? 0) / totalRows) * 100).toFixed(2)}%) + + {/if} - - -
- {#if summaryMode === "summary" && summary} - {@const rowHeight = 24} -
- -
- {:else if topK && summaryMode === "topk"} -
- { - focusPoint = value; - }} - k={topKLimit} - {topK} - {totalRows} - colorClass="bg-red-200" - {type} + + + {#if focusPoint?.count !== undefined && focusPoint?.value && topK && summaryMode === "topk"} + + + -
+ {/if} -
-
-
+ {/if} + + + + {#if rug} + + + {#each rugBuckets as bucket, i (i)} + {#if bucket.length > 0} + + {/if} + {/each} + + + {/if} + + +
+ {#if summaryMode === "summary" && summary} + {@const rowHeight = 24} +
+ +
+ {:else if topK && summaryMode === "topk"} +
+ { + focusPoint = value; + }} + k={topKLimit} + {topK} + {totalRows} + colorClass="bg-primary-200" + {type} + /> +
+ {/if} +
+
diff --git a/web-common/src/features/column-profile/column-types/details/SummaryNumberPlot.svelte b/web-common/src/features/column-profile/column-types/details/SummaryNumberPlot.svelte index 04ef60d3707..d7ea8df4be0 100644 --- a/web-common/src/features/column-profile/column-types/details/SummaryNumberPlot.svelte +++ b/web-common/src/features/column-profile/column-types/details/SummaryNumberPlot.svelte @@ -1,18 +1,16 @@ {#if values} - - - {#each values as { label, value, format = undefined }, i} - - - - - + {#each values as { label, value, format = undefined }, i (i)} + {@const px = xScale(value ?? 0)} + {@const rowY = (values.length - i - 1) * rowHeight} + + + + + - - {label} - {format ? format(value) : value} - - - + {label} + {format && value !== undefined + ? format(value) + : (value ?? "—")} + + {/each} - + {/if} diff --git a/web-common/src/features/column-profile/column-types/histogram-utils.ts b/web-common/src/features/column-profile/column-types/histogram-utils.ts new file mode 100644 index 00000000000..c5f8be992f4 --- /dev/null +++ b/web-common/src/features/column-profile/column-types/histogram-utils.ts @@ -0,0 +1,52 @@ +import { extent, max, min } from "d3-array"; +import type { ScaleLinear } from "d3-scale"; +import { scaleLinear } from "d3-scale"; +import { barplotPolyline } from "@rilldata/web-common/components/data-graphic/utils"; +import { INTEGERS } from "@rilldata/web-common/lib/duckdb-data-types"; +import type { NumericHistogramBinsBin } from "@rilldata/web-common/runtime-client"; + +interface PlotBounds { + left: number; + right: number; + top: number; + bottom: number; +} + +interface SeparatorConfig { + threshold?: number; + size?: number; +} + +export function createHistogramScales( + data: NumericHistogramBinsBin[], + type: string, + plotBounds: PlotBounds, + separatorConfig?: SeparatorConfig, +): { + xScale: ScaleLinear; + yScale: ScaleLinear; + path: string; +} { + const { left, right, top, bottom } = plotBounds; + + const xMin = min(data, (d) => d.low); + const xMax = max(data, (d) => d.high); + const [, yMax] = extent(data, (d) => d.count); + + const xScale = scaleLinear() + .domain([xMin ?? 0, xMax ?? 1]) + .range([left, right]); + const yScale = scaleLinear() + .domain([0, yMax ?? 1]) + .range([bottom, top]); + + const threshold = separatorConfig?.threshold ?? 20; + const size = separatorConfig?.size ?? 0.25; + const separator = data?.length < threshold && INTEGERS.has(type) ? size : 0; + + const path = data + ? barplotPolyline(data, xScale, yScale, separator, false, 1) + : ""; + + return { xScale, yScale, path }; +} diff --git a/web-common/src/features/column-profile/column-types/sparks/NullPercentageSpark.svelte b/web-common/src/features/column-profile/column-types/sparks/NullPercentageSpark.svelte index 223146a3b4b..32e1887095a 100644 --- a/web-common/src/features/column-profile/column-types/sparks/NullPercentageSpark.svelte +++ b/web-common/src/features/column-profile/column-types/sparks/NullPercentageSpark.svelte @@ -31,8 +31,9 @@ style:font-size="{COLUMN_PROFILE_CONFIG.fontSize}px" class="ui-copy-number" class:text-gray-300={nullCount === 0} - >{singleDigitPercentage(percentage)} + {singleDigitPercentage(percentage)} + {#if nullCount > 0} diff --git a/web-common/src/features/column-profile/column-types/sparks/NumericSpark.svelte b/web-common/src/features/column-profile/column-types/sparks/NumericSpark.svelte index 23a9d47e33f..8db6dc1e16e 100644 --- a/web-common/src/features/column-profile/column-types/sparks/NumericSpark.svelte +++ b/web-common/src/features/column-profile/column-types/sparks/NumericSpark.svelte @@ -1,60 +1,51 @@ {#if data} - - + + + {#if d?.length} + + + {/if} - - - - + the distribution of the values of this column diff --git a/web-common/src/features/column-profile/column-types/sparks/TimestampSpark.svelte b/web-common/src/features/column-profile/column-types/sparks/TimestampSpark.svelte deleted file mode 100644 index b6fafac16ee..00000000000 --- a/web-common/src/features/column-profile/column-types/sparks/TimestampSpark.svelte +++ /dev/null @@ -1,57 +0,0 @@ - - -{#if data?.length} - - - - - - - -{/if} diff --git a/web-common/src/features/column-profile/queries.ts b/web-common/src/features/column-profile/queries.ts index c004e29624a..67032b4e76d 100644 --- a/web-common/src/features/column-profile/queries.ts +++ b/web-common/src/features/column-profile/queries.ts @@ -1,4 +1,3 @@ -import { convertTimestampPreview } from "@rilldata/web-common/lib/convertTimestampPreview"; import { createQueryServiceColumnCardinality, createQueryServiceColumnNullCount, @@ -11,9 +10,13 @@ import { QueryServiceColumnNumericHistogramHistogramMethod, type V1ProfileColumn, type V1TableColumnsResponse, + type V1TimeSeriesValue, } from "@rilldata/web-common/runtime-client"; import type { HTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; -import { getPriorityForColumn } from "@rilldata/web-common/runtime-client/http-request-queue/priorities"; +import { + getPriority, + getPriorityForColumn, +} from "@rilldata/web-common/runtime-client/http-request-queue/priorities"; import { keepPreviousData, type QueryObserverResult, @@ -228,6 +231,21 @@ export function getTopK( }); } +function convertPoint(point: V1TimeSeriesValue) { + const next = { + ...point, + count: point?.records?.count as number, + ts: point.ts ? new Date(point.ts) : new Date(0), + }; + if (next.count == null || !isFinite(next.count)) { + next.count = 0; + } + + return next; +} + +export type TimestampDataPoint = ReturnType; + export function getTimeSeriesAndSpark( instanceId: string, connector: string, @@ -296,28 +314,15 @@ export function getTimeSeriesAndSpark( return derived( [query, estimatedInterval, smallestTimeGrain], ([$query, $estimatedInterval, $smallestTimeGrain]) => { + const data = $query?.data?.rollup?.results?.map(convertPoint) || []; + + const spark = $query?.data?.rollup?.spark?.map(convertPoint) || []; return { isFetching: $query?.isFetching, estimatedRollupInterval: $estimatedInterval?.data, smallestTimegrain: $smallestTimeGrain?.data?.timeGrain, - data: convertTimestampPreview( - $query?.data?.rollup?.results?.map((di) => { - const next = { ...di, count: di?.records?.count as number }; - if (next.count == null || !isFinite(next.count)) { - next.count = 0; - } - return next; - }) || [], - ), - spark: convertTimestampPreview( - $query?.data?.rollup?.spark?.map((di) => { - const next = { ...di, count: di?.records?.count as number }; - if (next.count == null || !isFinite(next.count)) { - next.count = 0; - } - return next; - }) || [], - ), + data, + spark, }; }, ); @@ -332,7 +337,6 @@ export function getNumericHistogram( columnName: string, histogramMethod: QueryServiceColumnNumericHistogramHistogramMethod, enabled = true, - active = false, ) { return createQueryServiceColumnNumericHistogram( instanceId, @@ -343,7 +347,7 @@ export function getNumericHistogram( databaseSchema, columnName, histogramMethod, - priority: getPriorityForColumn("numeric-histogram", active), + priority: getPriority("numeric-histogram"), }, { query: { diff --git a/web-common/src/features/components/charts/combo/spec.ts b/web-common/src/features/components/charts/combo/spec.ts index fb1c8f9eee9..c1cd497ed0f 100644 --- a/web-common/src/features/components/charts/combo/spec.ts +++ b/web-common/src/features/components/charts/combo/spec.ts @@ -4,7 +4,7 @@ import type { ColorMapping, } from "@rilldata/web-common/features/components/charts/types"; import { resolveCSSVariable } from "@rilldata/web-common/features/components/charts/util"; -import { COMPARIONS_COLORS } from "@rilldata/web-common/features/dashboards/config"; +import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; import type { VisualizationSpec } from "svelte-vega"; import type { ColorDef, Field } from "vega-lite/build/src/channeldef"; import type { LayerSpec } from "vega-lite/build/src/spec/layer"; @@ -33,11 +33,11 @@ function getColorForField( } // Use qualitative palette colors for the two measures - if (encoding === "y1") return COMPARIONS_COLORS[0]; - if (encoding === "y2") return COMPARIONS_COLORS[1]; + if (encoding === "y1") return COMPARISON_COLORS[0]; + if (encoding === "y2") return COMPARISON_COLORS[1]; // Fallback to qualitative palette color 3 - return COMPARIONS_COLORS[2]; + return COMPARISON_COLORS[2]; } export function generateVLComboChartSpec( diff --git a/web-common/src/features/components/charts/util.ts b/web-common/src/features/components/charts/util.ts index 5bf9421a3af..cee08d5dd0c 100644 --- a/web-common/src/features/components/charts/util.ts +++ b/web-common/src/features/components/charts/util.ts @@ -1,5 +1,5 @@ import { CHART_CONFIG } from "@rilldata/web-common/features/components/charts/config"; -import { COMPARIONS_COLORS } from "@rilldata/web-common/features/dashboards/config"; +import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; import { type V1MetricsViewAggregationResponseDataItem } from "@rilldata/web-common/runtime-client"; import type { Color } from "chroma-js"; import chroma from "chroma-js"; @@ -188,7 +188,7 @@ export function getColorForValues( ); const colorVar = overrideColor?.color || - COMPARIONS_COLORS[index % COMPARIONS_COLORS.length]; + COMPARISON_COLORS[index % COMPARISON_COLORS.length]; return { value, @@ -301,8 +301,8 @@ export function colorToVariableReference( if (!resolvedColor || typeof window === "undefined") return resolvedColor; // Check all comparison colors (qualitative palette) - for (let i = 0; i < COMPARIONS_COLORS.length; i++) { - const varRef = COMPARIONS_COLORS[i]; + for (let i = 0; i < COMPARISON_COLORS.length; i++) { + const varRef = COMPARISON_COLORS[i]; const resolved = resolveCSSVariable(varRef, isDarkMode); // Compare colors (normalize by converting both to chroma and back) diff --git a/web-common/src/features/connectors/olap/TableInspector.svelte b/web-common/src/features/connectors/olap/TableInspector.svelte index c7f6c94e7aa..8c17b925585 100644 --- a/web-common/src/features/connectors/olap/TableInspector.svelte +++ b/web-common/src/features/connectors/olap/TableInspector.svelte @@ -87,7 +87,7 @@ diff --git a/web-common/src/features/dashboards/time-series/MeasureScrub.svelte b/web-common/src/features/dashboards/time-series/MeasureScrub.svelte deleted file mode 100644 index 1d23b473ee1..00000000000 --- a/web-common/src/features/dashboards/time-series/MeasureScrub.svelte +++ /dev/null @@ -1,308 +0,0 @@ - - -{#if start && stop} - - {@const numStart = Number(start)} - {@const numStop = Number(stop)} - {@const xStart = xScale(Math.min(numStart, numStop))} - {@const xEnd = xScale(Math.max(numStart, numStop))} - - {#if showLabels} - - {mouseoverTimeFormat(Math.min(numStart, numStop))} - - - - {mouseoverTimeFormat(Math.max(numStart, numStop))} - - - {/if} - - - - onMouseUp()} - > - - - -{/if} - - - - - - - - - - diff --git a/web-common/src/features/dashboards/time-series/MeasureValueMouseover.svelte b/web-common/src/features/dashboards/time-series/MeasureValueMouseover.svelte deleted file mode 100644 index 35925a3f2b8..00000000000 --- a/web-common/src/features/dashboards/time-series/MeasureValueMouseover.svelte +++ /dev/null @@ -1,229 +0,0 @@ - - - - - - {#if !(currentPointIsNull || comparisonPointIsNull) && x !== undefined && y !== undefined} - {#if showComparison && Math.abs(output.y - output.dy) > 8} - {@const bufferSize = Math.abs(output.y - output.dy) > 16 ? 8 : 4} - {@const yBuffer = !hasValidComparisonPoint - ? 0 - : !comparisonIsPositive - ? -bufferSize - : bufferSize} - - {@const sign = !comparisonIsPositive ? -1 : 1} - {@const dist = 3} - {@const signedDist = sign * dist} - {@const yLoc = output.y + bufferSize * sign} - {@const show = - Math.abs(output.y - output.dy) > 16 && hasValidComparisonPoint} - arrows - - {#if show} - - - {/if} - - - - - - - - - - - - - {/if} - {/if} - {#if !showComparison && x !== undefined && y !== null && y !== undefined && !currentPointIsNull} - - {/if} - - - - - diff --git a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte index dd1e0457b23..78455129d80 100644 --- a/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte +++ b/web-common/src/features/dashboards/time-series/MetricsTimeSeriesCharts.svelte @@ -1,12 +1,7 @@ - - - - diff --git a/web-common/src/features/dashboards/time-series/annotations-selectors.ts b/web-common/src/features/dashboards/time-series/annotations-selectors.ts index 3af665a6513..bb7efe6c364 100644 --- a/web-common/src/features/dashboards/time-series/annotations-selectors.ts +++ b/web-common/src/features/dashboards/time-series/annotations-selectors.ts @@ -1,85 +1,63 @@ import type { Annotation } from "@rilldata/web-common/components/data-graphic/marks/annotations.ts"; -import { useExploreValidSpec } from "@rilldata/web-common/features/explores/selectors.ts"; -import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config.ts"; import { prettyFormatTimeRange } from "@rilldata/web-common/lib/time/ranges/formatter.ts"; -import { getLocalIANA } from "@rilldata/web-common/lib/time/timezone"; -import { - type DashboardTimeControls, - Period, - TimeUnit, -} from "@rilldata/web-common/lib/time/types.ts"; +import { Period, TimeUnit } from "@rilldata/web-common/lib/time/types.ts"; import { - getQueryServiceMetricsViewAnnotationsQueryOptions, + createQueryServiceMetricsViewAnnotations, type V1MetricsViewAnnotationsResponseAnnotation, V1TimeGrain, } from "@rilldata/web-common/runtime-client"; -import { createQuery } from "@tanstack/svelte-query"; import { DateTime, Interval } from "luxon"; -import { derived, type Readable } from "svelte/store"; - -export function getAnnotationsForMeasure({ - instanceId, - exploreName, - measureName, - selectedTimeRange, - dashboardTimezone, -}: { - instanceId: string; - exploreName: string; - measureName: string; - selectedTimeRange: DashboardTimeControls | undefined; - dashboardTimezone: string; -}): Readable { - const exploreValidSpec = useExploreValidSpec(instanceId, exploreName); - const selectedPeriod = TIME_GRAIN[selectedTimeRange?.interval ?? ""] - ?.duration as Period | undefined; +import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config.ts"; +import { keepPreviousData } from "@tanstack/svelte-query"; - const annotationsQueryOptions = derived( - exploreValidSpec, - (exploreValidSpec) => { - const metricsViewSpec = exploreValidSpec.data?.metricsView; - const exploreSpec = exploreValidSpec.data?.explore; - const metricsViewName = exploreSpec?.metricsView ?? ""; +/** + * Creates a query that fetches annotations for a measure and transforms + * the raw response rows into sorted Annotation objects via `select`. + */ +export function createAnnotationsQuery( + instanceId: string, + metricsViewName: string, + measureName: string, + timeDimension: string | undefined, + timeStart: string | undefined, + timeEnd: string | undefined, + timeGranularity: V1TimeGrain | undefined, + timeZone: string, + enabled: boolean, +) { + const period = getPeriodFromTimeGrain(timeGranularity); + const grain = timeGranularity ?? V1TimeGrain.TIME_GRAIN_UNSPECIFIED; - return getQueryServiceMetricsViewAnnotationsQueryOptions( - instanceId, - metricsViewName, - { - timeRange: { - start: selectedTimeRange?.start.toISOString(), - end: selectedTimeRange?.end.toISOString(), - }, - timeGrain: selectedTimeRange?.interval, - measures: [measureName], - }, - { - query: { - enabled: - !!metricsViewSpec?.annotations?.length && - !!metricsViewName && - !!selectedTimeRange, - }, + return createQueryServiceMetricsViewAnnotations( + instanceId, + metricsViewName, + { + timeRange: { start: timeStart, end: timeEnd, timeDimension }, + timeGrain: timeGranularity, + measures: [measureName], + }, + { + query: { + select: (data) => { + const rows = data.rows; + if (!rows?.length) return [] as Annotation[]; + const list = rows.map((a) => + convertV1AnnotationsResponseItemToAnnotation( + a, + period, + grain, + timeZone, + ), + ); + list.sort((a, b) => a.startTime.toMillis() - b.startTime.toMillis()); + return list; }, - ); + enabled, + placeholderData: keepPreviousData, + refetchOnMount: false, + }, }, ); - - const annotationsQuery = createQuery(annotationsQueryOptions); - - return derived(annotationsQuery, (annotationsQuery) => { - const annotations = - annotationsQuery.data?.rows?.map((a) => - convertV1AnnotationsResponseItemToAnnotation( - a, - selectedPeriod, - selectedTimeRange?.interval ?? V1TimeGrain.TIME_GRAIN_UNSPECIFIED, - - dashboardTimezone, - ), - ) ?? []; - annotations.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); - return annotations; - }); } function convertV1AnnotationsResponseItemToAnnotation( @@ -87,9 +65,7 @@ function convertV1AnnotationsResponseItemToAnnotation( period: Period | undefined, selectedTimeGrain: V1TimeGrain, dashboardTimezone: string, -) { - const localTimezone = getLocalIANA(); - +): Annotation { let startTime = DateTime.fromISO(annotation.time as string, { zone: dashboardTimezone, }); @@ -116,12 +92,14 @@ function convertV1AnnotationsResponseItemToAnnotation( return { ...annotation, - startTime: startTime - .setZone(localTimezone, { keepLocalTime: true }) - .toJSDate(), - endTime: endTime - ?.setZone(localTimezone, { keepLocalTime: true }) - .toJSDate(), + startTime, + endTime, formattedTimeOrRange, }; } + +function getPeriodFromTimeGrain( + timeGrain: V1TimeGrain | string | undefined, +): Period | undefined { + return TIME_GRAIN[timeGrain ?? ""]?.duration as Period | undefined; +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/AnnotationPopoverController.ts b/web-common/src/features/dashboards/time-series/measure-chart/AnnotationPopoverController.ts new file mode 100644 index 00000000000..f1909942c60 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/AnnotationPopoverController.ts @@ -0,0 +1,70 @@ +import { writable } from "svelte/store"; +import { findHoveredGroup, type AnnotationGroup } from "./annotation-utils"; + +const POPOVER_DELAY_MS = 150; + +/** + * Manages annotation popover hover state: hit-testing mouse position against + * annotation groups, delayed hiding so the user can reach the popover, and + * popover-is-hovered tracking. + */ +export class AnnotationPopoverController { + readonly hoveredGroup = writable(null); + + private popoverHovered = false; + private timeout: ReturnType | null = null; + private currentGroup: AnnotationGroup | null = null; + + /** Call from SVG mousemove. */ + checkHover(e: MouseEvent, groups: AnnotationGroup[], isScrubbing: boolean) { + if (isScrubbing || groups.length === 0) { + this.scheduleClear(); + return; + } + const svg = e.currentTarget as SVGSVGElement; + const rect = svg.getBoundingClientRect(); + const hit = findHoveredGroup( + groups, + e.clientX - rect.left, + e.clientY - rect.top, + ); + if (hit) { + this.cancelTimeout(); + this.setGroup(hit); + } else if (this.currentGroup && !this.popoverHovered) { + this.scheduleClear(); + } + } + + /** Call when the popover itself is hovered / unhovered. */ + setPopoverHovered(hovered: boolean) { + this.popoverHovered = hovered; + this.cancelTimeout(); + if (!hovered) this.scheduleClear(); + } + + /** Schedule a delayed clear (e.g. on mouseleave). */ + scheduleClear() { + if (this.popoverHovered || this.timeout) return; + this.timeout = setTimeout(() => { + if (!this.popoverHovered) this.setGroup(null); + this.timeout = null; + }, POPOVER_DELAY_MS); + } + + destroy() { + this.cancelTimeout(); + } + + private setGroup(group: AnnotationGroup | null) { + this.currentGroup = group; + this.hoveredGroup.set(group); + } + + private cancelTimeout() { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + } +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/ComparisonTooltip.svelte b/web-common/src/features/dashboards/time-series/measure-chart/ComparisonTooltip.svelte new file mode 100644 index 00000000000..1f8e687ebf4 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/ComparisonTooltip.svelte @@ -0,0 +1,24 @@ + + + + + {valueFormatter(tooltipCurrentValue)} + + + vs {valueFormatter(tooltipComparisonValue)} + + {#if showDelta} + + ({tooltipDeltaPositive ? "+" : ""}{tooltipDeltaLabel}) + + {/if} + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/ExplainButton.svelte b/web-common/src/features/dashboards/time-series/measure-chart/ExplainButton.svelte new file mode 100644 index 00000000000..79a652ea2b5 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/ExplainButton.svelte @@ -0,0 +1,18 @@ + + + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte new file mode 100644 index 00000000000..e862ce6b9f9 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChart.svelte @@ -0,0 +1,324 @@ + + +
+ {#if !$visible || (isFetching && data.length === 0)} +
+ +
+ {:else if isError} +
+ {error ?? "Error loading data"} +
+ {:else if tddChartType !== TDDChart.DEFAULT && data.length > 0} +
+ +
+ {:else if data.length > 0} + + {:else} +
+ No data available +
+ {/if} +
diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationMarkers.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationMarkers.svelte new file mode 100644 index 00000000000..f1977e41f00 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationMarkers.svelte @@ -0,0 +1,81 @@ + + +{#each groups as group (group.index)} + {@const hovered = hoveredGroup === group} + {@const cx = group.left} + {@const cy = group.top + AnnotationHeight / 2} + +{/each} + +{#if hasRange && hoveredGroup} + + + + + + + + +{/if} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationPopover.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationPopover.svelte new file mode 100644 index 00000000000..d1484b94406 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartAnnotationPopover.svelte @@ -0,0 +1,113 @@ + + +{#if hoveredGroup} +
+ (showingMore = false)}> + + + + +
onHover(true)} + on:mouseleave={() => onHover(false)} + role="menu" + tabindex="-1" + > + {#each annotationsToShow as annotation, i (i)} +
+
+ {annotation.description} +
+
+ {annotation.formattedTimeOrRange} +
+
+ {/each} + {#if hasMoreAnnotations && !showingMore} + + {/if} +
+
+
+
+{/if} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartBody.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartBody.svelte new file mode 100644 index 00000000000..cad22a84b3f --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartBody.svelte @@ -0,0 +1,583 @@ + + +
+ { + const x = clampX(e.offsetX); + const fractionalIndex = xScale.invert(x); + + hoverState = { + index: fractionalIndex, + screenX: x, + screenY: e.offsetY, + isHovered: true, + }; + + // Update scrub if dragging + if (get(scrubController.state).isScrubbing) { + scrubController.update(x, xScale); + } + + annotationPopover.checkHover(e, annotationGroups, isScrubbing); + mousePageX = e.pageX; + mousePageY = e.pageY; + }} + on:mouseleave={handleSvgMouseLeave} + on:mousedown={handleMouseDown} + on:mouseup={handleMouseUp} + on:click={handleChartClick} + > + + + + + + + + + + + + {#if mode === "line"} + + {:else} + + {/if} + + + {#if !isScrubbing && hoveredPoint} + 0} + isBarMode={mode === "bar"} + visibleStart={0} + visibleEnd={dataLastIndex} + /> + {/if} + + + {#if !isScrubbing && hoveredPoint && !isComparingDimension} + + + {formatGrainBucket(hoveredPoint.ts, timeGranularity, interval)} + + + + {#if showComparison && !isLocallyHovered} + {@const showDelta = + tooltipComparisonValue !== null && !!tooltipDeltaLabel} + + {/if} + + {/if} + + + {#if singleSelectIdx !== null && singleSelectX !== null && isThisMeasureSelected} + {@const selPt = data[singleSelectIdx]} + {#if selPt?.value !== null && selPt?.value !== undefined} + + {/if} + {/if} + + + + {#if isLocallyHovered} + + {/if} + + {#if annotationGroups.length > 0} + + {/if} + + + + {#if !isScrubbing && isLocallyHovered && hoveredPoint && mousePageX !== null && mousePageY !== null} + + {/if} + + {#if annotationGroups.length > 0} + annotationPopover.setPopoverHovered(h)} + /> + {/if} + + + {#if !isScrubbing && explainX !== null} + + measureSelection.startAnomalyExplanationChat(metricsViewName)} + /> + {/if} +
diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartGrid.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartGrid.svelte new file mode 100644 index 00000000000..a0ec531aa91 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartGrid.svelte @@ -0,0 +1,60 @@ + + + + {#each yTicks as tick (tick)} + + {axisFormatter(tick)} + + + {/each} + + + + {#each xTickIndices as idx (idx)} + + {/each} + + + + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartHoverTooltip.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartHoverTooltip.svelte new file mode 100644 index 00000000000..4cc95e3764d --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartHoverTooltip.svelte @@ -0,0 +1,235 @@ + + +
+ {#if isComparingDimension} +
+
+ {formatGrainBucket(currentTs, timeGranularity, interval)} +
+ {#each dimTooltipEntries as entry (entry.label)} +
+ + {entry.label} + {formatter(entry.value)} +
+ {/each} +
+ {:else if showComparison} +
+
+ {formatter(currentValue)} + + {formatGrainBucket(currentTs, timeGranularity, interval)} +
+ +
+
+ vs +
+ +
+ {formatter(comparisonValue)} + + {#if comparisonTs} + {formatGrainBucket( + comparisonTs, + timeGranularity, + comparisonInterval, + )} + {/if} + +
+
+ + {#if absoluteDelta !== null && deltaLabel} + + {/if} + {:else} +
+ {formatter(currentValue)} + + {formatGrainBucket(currentTs, timeGranularity, interval)} + +
+ {/if} +
+ + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartPointIndicator.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartPointIndicator.svelte new file mode 100644 index 00000000000..d694b9709ac --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartPointIndicator.svelte @@ -0,0 +1,31 @@ + + + + + + + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartScrub.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartScrub.svelte new file mode 100644 index 00000000000..d87ff185eea --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartScrub.svelte @@ -0,0 +1,117 @@ + + + + + + + + + + +{#if hasSelection && orderedStartIdx !== null && orderedEndIdx !== null} + + + + + + + + + +{/if} + + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte new file mode 100644 index 00000000000..e8249c20c83 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartTooltip.svelte @@ -0,0 +1,156 @@ + + +{#if hoveredPoint} + + {#if !isComparingDimension && !showComparison} + + {/if} + + + {#if !isComparingDimension && showComparison && !currentPointIsNull} + {@const primaryBarX = isBarMode + ? barCenterX( + slotCenterX, + geo.bandWidth, + geo.singleBarWidth, + geo.barGap, + 1, + ) + : $tweenedX} + + {/if} + + + {#if isComparingDimension} + + {#each dimensionData as dim, i (i)} + {@const pt = dim.data[hoveredIndex]} + {@const bx = isBarMode + ? barCenterX( + slotCenterX, + geo.bandWidth, + geo.singleBarWidth, + geo.barGap, + i, + ) + : $tweenedX} + {#if pt?.value !== null && pt?.value !== undefined} + + {/if} + {/each} + {/if} + + + {#if !isComparingDimension && showComparison && hasValidComparisonPoint} + {@const compBarX = isBarMode + ? barCenterX( + slotCenterX, + geo.bandWidth, + geo.singleBarWidth, + geo.barGap, + 0, + ) + : $tweenedX} + + {/if} +{/if} + + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartXAxis.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartXAxis.svelte new file mode 100644 index 00000000000..e50542a48ee --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasureChartXAxis.svelte @@ -0,0 +1,187 @@ + + +
+ {#if bins.length > 0 && clientWidth > 0} + + {#each ticks as tick, tickIdx (tickIdx)} + + {tick.timeLine} + + {#if tick.dateLine} + + {tick.dateLine} + + {/if} + {/each} + + {/if} +
diff --git a/web-common/src/features/dashboards/time-series/measure-chart/MeasurePan.svelte b/web-common/src/features/dashboards/time-series/measure-chart/MeasurePan.svelte new file mode 100644 index 00000000000..dcd1c2688e9 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/MeasurePan.svelte @@ -0,0 +1,25 @@ + + +{#if canPanLeft && onPanLeft} + + + +{/if} +{#if canPanRight && onPanRight} + + + +{/if} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/PanButton.svelte b/web-common/src/features/dashboards/time-series/measure-chart/PanButton.svelte new file mode 100644 index 00000000000..106a4cebb2e --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/PanButton.svelte @@ -0,0 +1,26 @@ + + + + + + + diff --git a/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts b/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts new file mode 100644 index 00000000000..1c0ee03deca --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/ScrubController.ts @@ -0,0 +1,216 @@ +import { writable, get, type Readable, type Writable } from "svelte/store"; +import type { ScrubState } from "./types"; +import type { ScaleLinear } from "d3-scale"; + +type ScrubMode = "none" | "create" | "resize-start" | "resize-end" | "move"; + +const EMPTY_SCRUB: ScrubState = { + startIndex: null, + endIndex: null, + isScrubbing: false, +}; + +const EDGE_THRESHOLD_PX = 5; + +type XScale = ScaleLinear; + +/** + * Controller for chart scrub/brush selection interactions. + * Designed as a singleton shared across all measure charts. + * + * All charts share the same x-scale domain (0 to dataLength-1), + * so scrub state is shared and any chart can drive interactions. + */ +export class ScrubController { + private _state: Writable; + private mode: ScrubMode = "none"; + private moveStartX: number | null = null; + private moveStartIndices: { start: number; end: number } | null = null; + private dataLength: number = 0; + + readonly state: Readable; + + constructor(externalState?: Writable) { + this._state = externalState ?? writable(EMPTY_SCRUB); + this.state = this._state; + } + + /** Update the data length (used for clamping indices). */ + setDataLength(length: number): void { + this.dataLength = length; + } + + /** Initialize controller state from external selection (e.g., from URL/props). */ + initFromExternal(startIndex: number, endIndex: number): void { + this._state.set({ + startIndex, + endIndex, + isScrubbing: false, + }); + } + + /** Clamp index to valid range [0, dataLength-1]. */ + private clamp(index: number): number { + if (this.dataLength === 0) return 0; + return Math.max(0, Math.min(this.dataLength - 1, Math.round(index))); + } + + /** Determine scrub mode based on click position relative to existing selection. */ + private detectMode(screenX: number, xScale: XScale): ScrubMode { + const state = get(this._state); + + if (state.startIndex === null || state.endIndex === null) { + return "create"; + } + + const startX = xScale(state.startIndex); + const endX = xScale(state.endIndex); + + if (Math.abs(screenX - startX) <= EDGE_THRESHOLD_PX) return "resize-start"; + if (Math.abs(screenX - endX) <= EDGE_THRESHOLD_PX) return "resize-end"; + + const minX = Math.min(startX, endX); + const maxX = Math.max(startX, endX); + if ( + screenX > minX + EDGE_THRESHOLD_PX && + screenX < maxX - EDGE_THRESHOLD_PX + ) { + return "move"; + } + + return "create"; + } + + /** Get cursor style based on hover position relative to selection. */ + getCursorStyle(screenX: number | null, xScale: XScale): string { + const state = get(this._state); + + if (state.isScrubbing) return "cursor-ew-resize"; + + if ( + state.startIndex === null || + state.endIndex === null || + screenX === null + ) { + return "cursor-crosshair"; + } + + const startX = xScale(state.startIndex); + const endX = xScale(state.endIndex); + + if ( + Math.abs(screenX - startX) <= EDGE_THRESHOLD_PX || + Math.abs(screenX - endX) <= EDGE_THRESHOLD_PX + ) { + return "cursor-ew-resize"; + } + + const minX = Math.min(startX, endX); + const maxX = Math.max(startX, endX); + if ( + screenX > minX + EDGE_THRESHOLD_PX && + screenX < maxX - EDGE_THRESHOLD_PX + ) { + return "cursor-grab"; + } + + return "cursor-crosshair"; + } + + /** Start a scrub interaction. */ + start(screenX: number, xScale: XScale): void { + const state = get(this._state); + const index = this.clamp(xScale.invert(screenX)); + + this.mode = this.detectMode(screenX, xScale); + + if ( + this.mode === "move" && + state.startIndex !== null && + state.endIndex !== null + ) { + this.moveStartX = screenX; + this.moveStartIndices = { + start: state.startIndex, + end: state.endIndex, + }; + this._state.update((s) => ({ ...s, isScrubbing: true })); + } else if (this.mode === "create") { + this._state.set({ + startIndex: index, + endIndex: index, + isScrubbing: true, + }); + } else { + this._state.update((s) => ({ ...s, isScrubbing: true })); + } + } + + /** Update scrub position during drag. */ + update(screenX: number, xScale: XScale): void { + const state = get(this._state); + if (!state.isScrubbing) return; + + const index = this.clamp(xScale.invert(screenX)); + + switch (this.mode) { + case "create": + case "resize-end": + this._state.update((s) => ({ ...s, endIndex: index })); + break; + + case "resize-start": + this._state.update((s) => ({ ...s, startIndex: index })); + break; + + case "move": + if (this.moveStartX !== null && this.moveStartIndices) { + const deltaX = screenX - this.moveStartX; + const startPx = xScale(this.moveStartIndices.start); + const endPx = xScale(this.moveStartIndices.end); + this._state.update((s) => ({ + ...s, + startIndex: this.clamp(xScale.invert(startPx + deltaX)), + endIndex: this.clamp(xScale.invert(endPx + deltaX)), + })); + } + break; + } + } + + /** End scrub interaction. Returns true if selection was kept, false if cleared. */ + end(): boolean { + const state = get(this._state); + + if (!state.isScrubbing) { + this.clearMoveState(); + return state.startIndex !== null; + } + + // Clear if selection is too small (single point click) + if ( + state.startIndex !== null && + state.endIndex !== null && + Math.abs(state.startIndex - state.endIndex) < 0.5 + ) { + this.reset(); + return false; + } + + this._state.update((s) => ({ ...s, isScrubbing: false })); + this.clearMoveState(); + return true; + } + + /** Reset scrub state completely. */ + reset(): void { + this._state.set(EMPTY_SCRUB); + this.clearMoveState(); + } + + private clearMoveState(): void { + this.mode = "none"; + this.moveStartX = null; + this.moveStartIndices = null; + } +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts b/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts new file mode 100644 index 00000000000..186df95d25f --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/annotation-utils.ts @@ -0,0 +1,138 @@ +import type { Annotation } from "@rilldata/web-common/components/data-graphic/marks/annotations"; +import type { ChartScales, ChartConfig, TimeSeriesPoint } from "./types"; +import type { V1TimeGrain } from "@rilldata/web-common/runtime-client"; +import { V1TimeGrainToDateTimeUnit } from "@rilldata/web-common/lib/time/new-grains"; +import { dateToIndex } from "./utils"; + +export type AnnotationGroup = { + items: Annotation[]; + key: string; + index: number; + left: number; + right: number; + top: number; + bottom: number; + hasRange: boolean; +}; + +export const AnnotationWidth = 10; +export const AnnotationHeight = 10; + +/** + * Group annotations by time grain bucket, then compute pixel positions. + * All annotations whose startTime truncates to the same grain boundary + * (in the given timezone) are grouped together. + */ +export function groupAnnotations( + annotations: Annotation[], + scales: ChartScales, + data: TimeSeriesPoint[], + config: ChartConfig, + timeGrain: V1TimeGrain | undefined, +): AnnotationGroup[] { + if (annotations.length === 0 || data.length === 0) return []; + + const unit = timeGrain ? V1TimeGrainToDateTimeUnit[timeGrain] : "day"; + const diamondY = + config.plotBounds.top + config.plotBounds.height - AnnotationHeight; + + // Bucket annotations by their grain-truncated start time. + // Store the truncated millis so the visibility check and positioning + // use the bucket boundary (not the raw annotation time). + const buckets = new Map< + string, + { annotations: Annotation[]; hasRange: boolean; bucketMs: number } + >(); + + for (const a of annotations) { + const bucketStart = a.startTime.startOf(unit); + const key = + bucketStart.toISO() ?? + a.startTime.toISO() ?? + String(a.startTime.toMillis()); + + let bucket = buckets.get(key); + if (!bucket) { + bucket = { + annotations: [], + hasRange: false, + bucketMs: bucketStart.toMillis(), + }; + buckets.set(key, bucket); + } + bucket.annotations.push(a); + if (a.endTime) bucket.hasRange = true; + } + + // Convert buckets to groups with pixel positions + const groups: AnnotationGroup[] = []; + + // Data time range — annotations outside this are not visible + const dataStartMs = data[0].ts.toMillis(); + const dataEndMs = data[data.length - 1].ts.toMillis(); + + for (const [bucketKey, bucket] of buckets) { + // Use the grain-truncated bucket time for visibility and positioning. + // This ensures e.g. an hour annotation at 06:00 snaps to its day bucket + // at 00:00, which aligns with the day-grain data point grid. + if (bucket.bucketMs < dataStartMs || bucket.bucketMs > dataEndMs) continue; + + const startIdx = dateToIndex(data, bucket.bucketMs); + if (startIdx === null) continue; + + const left = scales.x(startIdx); + + // Compute right edge from the widest range annotation in the bucket + let right = left + AnnotationWidth; + for (const a of bucket.annotations) { + if (a.endTime) { + const endIdx = dateToIndex(data, a.endTime.toMillis()); + if (endIdx !== null) { + right = Math.max(right, scales.x(endIdx)); + } + } + } + + // Filter out-of-bounds groups + if ( + left < config.plotBounds.left || + left > config.plotBounds.left + config.plotBounds.width + ) { + continue; + } + + groups.push({ + items: bucket.annotations, + key: bucketKey, + index: startIdx, + left, + right: Math.min(right, config.plotBounds.left + config.plotBounds.width), + top: diamondY, + bottom: diamondY + AnnotationHeight, + hasRange: bucket.hasRange, + }); + } + + // Sort by x position + groups.sort((a, b) => a.left - b.left); + + return groups; +} + +export function findHoveredGroup( + groups: AnnotationGroup[], + mouseX: number, + mouseY: number, +): AnnotationGroup | null { + for (const group of groups) { + if ( + mouseY >= group.top - 2 && + mouseY <= group.bottom + 2 && + mouseX >= group.left - AnnotationWidth / 2 && + mouseX <= group.left + AnnotationWidth / 2 + 2 + ) { + return group; + } + } + return null; +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/chart-series.ts b/web-common/src/features/dashboards/time-series/measure-chart/chart-series.ts new file mode 100644 index 00000000000..635fcd0195c --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/chart-series.ts @@ -0,0 +1,79 @@ +import type { + TimeSeriesPoint, + DimensionSeriesData, + ChartSeries, + ChartMode, +} from "./types"; +import { + MainLineColor, + MainAreaColorGradientDark, + MainAreaColorGradientLight, + TimeComparisonLineColor, +} from "../chart-colors"; +import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; +import { formatMeasurePercentageDifference } from "@rilldata/web-common/lib/number-formatting/percentage-formatter"; +import { numberPartsToString } from "@rilldata/web-common/lib/number-formatting/utils/number-parts-utils"; +import { LINE_MODE_MIN_POINTS } from "./scales"; + +export function buildChartSeries( + data: TimeSeriesPoint[], + dimData: DimensionSeriesData[], + showComparison: boolean, +): ChartSeries[] { + if (dimData.length > 0) { + return dimData.map((dim, i) => ({ + id: `dim-${dim.dimensionValue ?? i}`, + values: dim.data.map((pt) => pt.value), + color: dim.color || COMPARISON_COLORS[i % COMPARISON_COLORS.length], + opacity: dim.isFetching ? 0.5 : 1, + strokeWidth: 1.5, + })); + } + + const result: ChartSeries[] = []; + + if (data.length > 0) { + result.push({ + id: "primary", + values: data.map((pt) => pt.value), + color: MainLineColor, + areaGradient: { + dark: MainAreaColorGradientDark, + light: MainAreaColorGradientLight, + }, + }); + } + + if (showComparison && data.length > 0) { + result.push({ + id: "comparison", + values: data.map((pt) => pt.comparisonValue ?? null), + color: TimeComparisonLineColor, + opacity: 0.5, + }); + } + + return result; +} + +export function determineMode(data: TimeSeriesPoint[]): ChartMode { + return data.length >= LINE_MODE_MIN_POINTS ? "line" : "bar"; +} + +export function computeTooltipDelta(point: TimeSeriesPoint | null) { + const currentValue = point?.value ?? null; + const comparisonValue = point?.comparisonValue ?? null; + const delta = + currentValue !== null && comparisonValue !== null && comparisonValue !== 0 + ? (currentValue - comparisonValue) / comparisonValue + : null; + return { + currentValue, + comparisonValue, + deltaLabel: + delta !== null + ? numberPartsToString(formatMeasurePercentageDifference(delta)) + : null, + deltaPositive: delta !== null && delta >= 0, + }; +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/hover-index.ts b/web-common/src/features/dashboards/time-series/measure-chart/hover-index.ts new file mode 100644 index 00000000000..69691a2d1b3 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/hover-index.ts @@ -0,0 +1,41 @@ +import { writable } from "svelte/store"; +import type { ScaleLinear } from "d3-scale"; + +export interface HoverRange { + start: number; + end: number; +} + +function createHoverIndex() { + const { subscribe, set: _set } = writable(undefined); + let currentOwner: string | null = null; + let _xScale: ScaleLinear | null = null; + + return { + subscribe, + /** Set a single hovered index (start === end). */ + set(index: number, owner: string) { + currentOwner = owner; + _set({ start: index, end: index }); + }, + /** Set a range of highlighted indices. */ + setRange(start: number, end: number, owner: string) { + currentOwner = owner; + _set({ start: Math.min(start, end), end: Math.max(start, end) }); + }, + clear(owner: string) { + if (owner === currentOwner) { + currentOwner = null; + _set(undefined); + } + }, + registerScale(scale: ScaleLinear) { + _xScale = scale; + }, + get xScale() { + return _xScale; + }, + }; +} + +export const hoverIndex = createHoverIndex(); diff --git a/web-common/src/features/dashboards/time-series/measure-chart/interactions.ts b/web-common/src/features/dashboards/time-series/measure-chart/interactions.ts new file mode 100644 index 00000000000..ef3102f9541 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/interactions.ts @@ -0,0 +1,39 @@ +import { writable, type Writable } from "svelte/store"; +import type { HoverState } from "./types"; + +/** + * Create an IntersectionObserver-based visibility store. + * Used for lazy-loading chart data when the chart scrolls into view. + */ +export function createVisibilityObserver(rootMargin = "120px"): { + visible: Writable; + observe: (element: HTMLElement, root?: HTMLElement | null) => () => void; +} { + const visible = writable(false); + + function observe( + element: HTMLElement, + root: HTMLElement | null = null, + ): () => void { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + visible.set(true); + observer.unobserve(element); + } + }, + { root, rootMargin, threshold: 0 }, + ); + observer.observe(element); + return () => observer.disconnect(); + } + + return { visible, observe }; +} + +export const EMPTY_HOVER: HoverState = { + index: null, + screenX: null, + screenY: null, + isHovered: false, +}; diff --git a/web-common/src/features/dashboards/time-series/measure-chart/scales.ts b/web-common/src/features/dashboards/time-series/measure-chart/scales.ts new file mode 100644 index 00000000000..cd5cca812de --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/scales.ts @@ -0,0 +1,164 @@ +import { min, max } from "d3-array"; +import type { + TimeSeriesPoint, + DimensionSeriesData, + ChartConfig, +} from "./types"; + +interface ExtentConfig { + includeZero: boolean; + paddingFactor: number; + minRange?: number; +} + +/** + * Default extent configuration. + */ +export const LINE_MODE_MIN_POINTS = 6; +export const X_PAD = 8; +export const MARGIN_RIGHT = 40; + +/** + * Compute which data indices get x-axis tick marks. + * Bar mode shows every point; line mode shows start, middle, end. + */ +export function computeXTickIndices( + mode: "line" | "bar", + dataLength: number, +): number[] { + const lastIndex = Math.max(0, dataLength - 1); + if (mode === "bar") { + return Array.from({ length: dataLength }, (_, i) => i); + } + if (lastIndex >= 2) return [0, Math.floor(lastIndex / 2), lastIndex]; + if (lastIndex >= 1) return [0, lastIndex]; + return [0]; +} + +const DEFAULT_EXTENT_CONFIG: ExtentConfig = { + includeZero: true, + paddingFactor: 1.3, +}; + +/** + * Compute nice Y extent with padding. + * Sets extents to 0 if it makes sense; otherwise, inflates each extent component. + * Ported from utils.ts niceMeasureExtents. + */ +export function computeNiceYExtent( + smallest: number, + largest: number, + config: ExtentConfig = DEFAULT_EXTENT_CONFIG, +): [number, number] { + const { includeZero, paddingFactor, minRange } = config; + + // Handle edge case where both are 0 + if (smallest === 0 && largest === 0) { + return [0, 1]; + } + + // Handle NaN or invalid values + if (!Number.isFinite(smallest) || !Number.isFinite(largest)) { + return [0, 1]; + } + + let yMin: number; + let yMax: number; + + if (includeZero) { + // Include zero in the extent when appropriate + yMin = smallest < 0 ? smallest * paddingFactor : 0; + yMax = largest > 0 ? largest * paddingFactor : 0; + } else { + yMin = smallest * paddingFactor; + yMax = largest * paddingFactor; + } + + // Ensure minimum range if specified + if (minRange !== undefined && yMax - yMin < minRange) { + const mid = (yMin + yMax) / 2; + yMin = mid - minRange / 2; + yMax = mid + minRange / 2; + } + + return [yMin, yMax]; +} + +/** + * Compute combined Y extent from all data sources. + * Includes main data, comparison data, and dimension data. + */ +export function computeYExtent( + data: TimeSeriesPoint[], + dimensionData: DimensionSeriesData[], + showComparison: boolean, +): [number, number] { + const values: number[] = []; + const hasDimensionData = dimensionData.length > 0; + + // Main data values — skip when dimension comparison is active, + // since only individual dimension series are rendered (not the aggregate). + if (!hasDimensionData) { + for (const d of data) { + if (d.value !== null && Number.isFinite(d.value)) { + values.push(d.value); + } + if ( + showComparison && + d.comparisonValue !== null && + d.comparisonValue !== undefined && + Number.isFinite(d.comparisonValue) + ) { + values.push(d.comparisonValue); + } + } + } + + // Dimension data values + for (const dim of dimensionData) { + for (const d of dim.data) { + if (d.value !== null && Number.isFinite(d.value)) { + values.push(d.value); + } + } + } + + if (values.length === 0) { + return [0, 1]; + } + + return [min(values) ?? 0, max(values) ?? 1]; +} + +/** + * Compute chart configuration from dimensions. + */ +export function computeChartConfig( + width: number, + height: number, + isExpanded: boolean, +): ChartConfig { + const margin = { + top: 4, // Space for data readout labels + right: MARGIN_RIGHT, + bottom: isExpanded ? 25 : 10, + left: 0, + }; + + const plotWidth = Math.max(0, width - margin.left - margin.right); + const plotHeight = Math.max(0, height - margin.top - margin.bottom); + + return { + width, + height, + margin, + plotBounds: { + left: margin.left, + right: margin.left + plotWidth, + top: margin.top, + bottom: margin.top + plotHeight, + width: plotWidth, + height: plotHeight, + }, + }; +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/types.ts b/web-common/src/features/dashboards/time-series/measure-chart/types.ts new file mode 100644 index 00000000000..71d4d73eee6 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/types.ts @@ -0,0 +1,134 @@ +import type { ScaleLinear } from "d3-scale"; +import type { DateTime } from "luxon"; + +/** + * Strongly-typed time series data point. + * Replaces the string-based accessor pattern from TimeSeriesDatum. + */ +export interface TimeSeriesPoint { + /** Primary timestamp for the data point */ + ts: DateTime; + /** The measure value (nullable for gaps) */ + value: number | null; + /** Comparison value when time comparison is active */ + comparisonValue?: number | null; + /** Comparison timestamp */ + comparisonTs?: DateTime; +} + +/** + * Dimension comparison data item. + */ +export interface DimensionSeriesData { + /** Dimension value (e.g., "USA", "Canada") */ + dimensionValue: string | null; + /** Color for this dimension series */ + color: string; + /** Time series data for this dimension */ + data: TimeSeriesPoint[]; + /** Loading state */ + isFetching: boolean; + /** Total value for percent calculations */ + total?: number; +} + +/** + * Chart margin configuration. + */ +export interface ChartMargin { + top: number; + right: number; + bottom: number; + left: number; +} + +/** + * Computed plot bounds within the SVG. + */ +export interface PlotBounds { + left: number; + right: number; + top: number; + bottom: number; + width: number; + height: number; +} + +/** + * Chart configuration (replaces GraphicContext props). + */ +export interface ChartConfig { + width: number; + height: number; + margin: ChartMargin; + plotBounds: PlotBounds; +} + +/** + * Scale types for the chart. + */ +export interface ChartScales { + x: ScaleLinear; + y: ScaleLinear; +} + +/** + * Scrub/selection state. + */ +export interface ScrubState { + /** Start index (fractional, from xScale.invert) */ + startIndex: number | null; + /** End index (fractional, from xScale.invert) */ + endIndex: number | null; + isScrubbing: boolean; +} + +/** + * Mouseover/hover state. + */ +export interface HoverState { + /** Hovered index (fractional) */ + index: number | null; + /** Screen x coordinate */ + screenX: number | null; + /** Screen y coordinate */ + screenY: number | null; + /** Is mouse currently over the chart */ + isHovered: boolean; +} + +/** + * A generic series descriptor for the pure TimeSeriesChart renderer. + * Decoupled from measure/dimension semantics. + */ +export interface ChartSeries { + /** Unique identifier for this series */ + id: string; + /** Values array — one per bucket, null for gaps */ + values: (number | null)[]; + /** Stroke/fill color */ + color: string; + /** Dash pattern for the stroke */ + strokeDasharray?: string; + /** Opacity override (default 1) */ + opacity?: number; + /** Area gradient colors — only the first/primary series typically gets this */ + areaGradient?: { dark: string; light: string }; + /** Stroke width */ + strokeWidth?: number; +} + +/** + * A single tick on the x-axis. + */ +export interface AxisTick { + x: number; + anchor: string; + timeLine: string; + dateLine: string; +} + +/** + * Rendering mode for TimeSeriesChart. + */ +export type ChartMode = "line" | "bar"; diff --git a/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts b/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts new file mode 100644 index 00000000000..5f286f119d5 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/use-dimension-data.ts @@ -0,0 +1,171 @@ +import { + createAndExpression, + createInExpression, + filterExpressions, + sanitiseExpression, +} from "@rilldata/web-common/features/dashboards/stores/filter-utils"; +import { + createQueryServiceMetricsViewAggregation, + type V1Expression, + type V1MetricsViewAggregationResponse, + type V1TimeGrain, + type V1TimeSeriesValue, +} from "@rilldata/web-common/runtime-client"; +import type { HTTPError } from "@rilldata/web-common/runtime-client/fetchWrapper"; +import { + keepPreviousData, + type CreateQueryResult, +} from "@tanstack/svelte-query"; +import { transformAggregateDimensionData, prepareTimeSeries } from "../utils"; +import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; +import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config"; +import { DateTime } from "luxon"; +import type { DimensionSeriesData, TimeSeriesPoint } from "./types"; +import type { V1MetricsViewTimeSeriesResponse } from "@rilldata/web-common/runtime-client"; +import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient"; + +/** + * Creates an aggregation query for dimension comparison data. + * Used by MeasureChart to fetch per-dimension-value time series. + */ +export function createDimensionAggregationQuery( + instanceId: string, + metricsViewName: string, + measureName: string, + dimensionName: string, + dimensionValues: (string | null)[], + where: V1Expression | undefined, + timeDimension: string, + timeStart: string | undefined, + timeEnd: string | undefined, + timeGranularity: V1TimeGrain, + timeZone: string, + enabled: boolean, +): CreateQueryResult { + const baseFilter = where + ? (filterExpressions(where, () => true) ?? createAndExpression([])) + : createAndExpression([]); + const updatedFilter = createAndExpression([ + ...(baseFilter.cond?.exprs ?? []), + createInExpression(dimensionName, dimensionValues), + ]); + + return createQueryServiceMetricsViewAggregation( + instanceId, + metricsViewName, + { + measures: [{ name: measureName }], + dimensions: [ + { name: dimensionName }, + { name: timeDimension, timeGrain: timeGranularity, timeZone }, + ], + where: sanitiseExpression(updatedFilter, undefined), + timeRange: { + start: timeStart, + end: timeEnd, + timeDimension, + }, + sort: [ + { desc: true, name: measureName }, + { desc: false, name: timeDimension }, + ], + // Upper bound: dimensions × time-grain buckets. Matches the limit + // used in multiple-dimension-queries.ts. Results exceeding this are + // silently truncated, which is acceptable since the leaderboard caps + // visible dimensions well below this threshold. + limit: "10000", + offset: "0", + }, + { + query: { + enabled: enabled && dimensionValues.length > 0, + placeholderData: keepPreviousData, + }, + }, + queryClient, + ); +} + +/** + * Pure function: transforms aggregation response data into DimensionSeriesData[]. + */ +export function buildDimensionSeriesData( + measureName: string, + dimensionName: string, + dimensionValues: (string | null)[], + timeDimension: string, + timeGranularity: V1TimeGrain, + timeZone: string, + primaryTimeSeriesData: V1MetricsViewTimeSeriesResponse["data"], + aggData: V1MetricsViewAggregationResponse["data"], + comparisonTimeSeriesData: V1MetricsViewTimeSeriesResponse["data"] | undefined, + compAggData: V1MetricsViewAggregationResponse["data"] | undefined, + isFetching: boolean, +): DimensionSeriesData[] { + if (!dimensionValues.length || !primaryTimeSeriesData?.length) return []; + + const measures = [measureName]; + + const transformedData = transformAggregateDimensionData( + timeDimension, + dimensionName, + measures, + dimensionValues, + primaryTimeSeriesData, + aggData || [], + ); + + let comparisonData: V1TimeSeriesValue[][] = []; + if (comparisonTimeSeriesData && compAggData) { + comparisonData = transformAggregateDimensionData( + timeDimension, + dimensionName, + measures, + dimensionValues, + comparisonTimeSeriesData, + compAggData, + ); + } + + const grainDuration = TIME_GRAIN[timeGranularity]?.duration; + const results: DimensionSeriesData[] = []; + + for (let i = 0; i < dimensionValues.length; i++) { + const prepData = prepareTimeSeries( + transformedData[i], + comparisonData[i], + grainDuration, + timeZone, + ); + + const data: TimeSeriesPoint[] = prepData.map((datum) => { + const compKey = `comparison.${measureName}`; + const compTsKey = "comparison.ts"; + return { + ts: datum.ts + ? DateTime.fromJSDate(datum.ts, { zone: timeZone }) + : DateTime.invalid("missing"), + value: (datum[measureName] as number | null) ?? null, + comparisonValue: + compKey in datum + ? ((datum[compKey] as number | null) ?? null) + : undefined, + comparisonTs: + compTsKey in datum && datum[compTsKey] + ? DateTime.fromJSDate(datum[compTsKey] as Date, { + zone: timeZone, + }) + : undefined, + }; + }); + + results.push({ + dimensionValue: dimensionValues[i], + color: COMPARISON_COLORS[i % COMPARISON_COLORS.length] || "", + data, + isFetching, + }); + } + + return results; +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/use-measure-time-series.ts b/web-common/src/features/dashboards/time-series/measure-chart/use-measure-time-series.ts new file mode 100644 index 00000000000..13418cacad1 --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/use-measure-time-series.ts @@ -0,0 +1,44 @@ +import type { V1MetricsViewTimeSeriesResponse } from "@rilldata/web-common/runtime-client"; +import type { TimeSeriesPoint } from "./types"; +import { DateTime } from "luxon"; + +/** + * Transform raw API time series data to typed TimeSeriesPoint[]. + * Minimal processing: just extract ts, value, and comparison fields. + * No intermediate position computation — rendering uses indices directly. + */ +export function transformTimeSeriesData( + primary: V1MetricsViewTimeSeriesResponse["data"], + comparison: V1MetricsViewTimeSeriesResponse["data"] | undefined, + measureName: string, + timezone: string, +): TimeSeriesPoint[] { + if (!primary) return []; + + return primary.map((originalPt, i) => { + const comparisonPt = comparison?.[i]; + + if (!originalPt?.ts) { + return { ts: DateTime.invalid("Invalid timestamp"), value: null }; + } + + const ts = DateTime.fromISO(originalPt.ts, { zone: timezone }); + + if (!ts.isValid) { + return { ts: DateTime.invalid("Invalid timestamp"), value: null }; + } + + const value = (originalPt.records?.[measureName] as number | null) ?? null; + + let comparisonValue: number | null | undefined = undefined; + let comparisonTs: DateTime | undefined = undefined; + + if (comparisonPt?.ts) { + comparisonValue = + (comparisonPt.records?.[measureName] as number | null) ?? null; + comparisonTs = DateTime.fromISO(comparisonPt.ts, { zone: timezone }); + } + + return { ts, value, comparisonValue, comparisonTs }; + }); +} diff --git a/web-common/src/features/dashboards/time-series/measure-chart/utils.ts b/web-common/src/features/dashboards/time-series/measure-chart/utils.ts new file mode 100644 index 00000000000..6f072d7bb9f --- /dev/null +++ b/web-common/src/features/dashboards/time-series/measure-chart/utils.ts @@ -0,0 +1,71 @@ +import type { TimeSeriesPoint } from "./types"; + +/** + * Clamp a fractional index to the nearest valid array index. + */ +export function snapIndex(idx: number, length: number): number { + return Math.max(0, Math.min(length - 1, Math.round(idx))); +} + +/** + * Find the data index whose timestamp is closest to the given millisecond timestamp. + */ +export function dateToIndex( + data: TimeSeriesPoint[], + ms: number, +): number | null { + if (data.length === 0) return null; + let best = 0; + let bestDist = Infinity; + for (let i = 0; i < data.length; i++) { + const dist = Math.abs(data[i].ts.toMillis() - ms); + if (dist < bestDist) { + bestDist = dist; + best = i; + } + } + return best; +} + +export interface BarSlotGeometry { + slotWidth: number; + gap: number; + bandWidth: number; + barGap: number; + singleBarWidth: number; +} + +/** + * Compute the bar slot geometry for grouped bar charts. + */ +export function computeBarSlotGeometry( + plotWidth: number, + visibleCount: number, + barCount: number, +): BarSlotGeometry { + const slotWidth = plotWidth / Math.max(1, visibleCount); + const gap = slotWidth * 0.2; + const bandWidth = slotWidth - gap; + const barGap = barCount > 1 ? 2 : 0; + const totalGaps = barGap * (barCount - 1); + const singleBarWidth = (bandWidth - totalGaps) / barCount; + return { slotWidth, gap, bandWidth, barGap, singleBarWidth }; +} + +/** + * Compute the x position of a bar center within a slot. + */ +export function barCenterX( + slotCenterX: number, + bandWidth: number, + singleBarWidth: number, + barGap: number, + barIndex: number, +): number { + return ( + slotCenterX - + bandWidth / 2 + + barIndex * (singleBarWidth + barGap) + + singleBarWidth / 2 + ); +} diff --git a/web-common/src/features/dashboards/time-series/measure-selection/ExplainButton.svelte b/web-common/src/features/dashboards/time-series/measure-selection/ExplainButton.svelte deleted file mode 100644 index 1b16eb5e7b0..00000000000 --- a/web-common/src/features/dashboards/time-series/measure-selection/ExplainButton.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - -{#if forThisMeasure} -
- -
-{/if} diff --git a/web-common/src/features/dashboards/time-series/measure-selection/MeasureSelection.svelte b/web-common/src/features/dashboards/time-series/measure-selection/MeasureSelection.svelte deleted file mode 100644 index 7b9f8ccfcef..00000000000 --- a/web-common/src/features/dashboards/time-series/measure-selection/MeasureSelection.svelte +++ /dev/null @@ -1,82 +0,0 @@ - - -{#if showLine} - d[xAccessor]} value={$start} let:point> - {#if point && inBounds(internalXMin, internalXMax, point[xAccessor])} - - {/if} - -{:else if showBox && $start && $end} - {@const xStart = $xScale($start)} - {@const xEnd = $xScale($end)} - - - - - - - - - - - -{/if} diff --git a/web-common/src/features/dashboards/time-series/measure-selection/measure-selection.ts b/web-common/src/features/dashboards/time-series/measure-selection/measure-selection.ts index 5936f6c3366..c13faece111 100644 --- a/web-common/src/features/dashboards/time-series/measure-selection/measure-selection.ts +++ b/web-common/src/features/dashboards/time-series/measure-selection/measure-selection.ts @@ -5,10 +5,6 @@ import { } from "@rilldata/web-common/features/chat/core/context/inline-context.ts"; import { sidebarActions } from "@rilldata/web-common/features/chat/layouts/sidebar/sidebar-store.ts"; import { get, writable } from "svelte/store"; -import type { - GraphicScale, - SimpleDataGraphicConfiguration, -} from "@rilldata/web-common/components/data-graphic/state/types"; import { featureFlags } from "@rilldata/web-common/features/feature-flags.ts"; import { getExploreNameStore } from "@rilldata/web-common/features/dashboards/nav-utils.ts"; import { derived } from "svelte/store"; @@ -17,10 +13,6 @@ export class MeasureSelection { public readonly measure = writable(null); public readonly start = writable(null); public readonly end = writable(null); - // Calculated x,y coordinates of the measure selection point. - // This uses GraphicScale and SimpleDataGraphicConfiguration that is not available outside `SimpleDataGraphic`. - public readonly x = writable(null); - public readonly y = writable(null); public setStart(measure: string, start: Date) { this.measure.set(measure); @@ -34,39 +26,10 @@ export class MeasureSelection { this.end.set(end); } - /** - * Calculate the point on the graph where the measure selection should be drawn. - * scaler and config are only available within the `MeasureSelection` wrapped in `SimpleDataGraphic`. - * But it is used in `ExplainButton` that is outside the `SimpleDataGraphic` wrapper to avoid click issues. - * That is why this updates the x & y stores directly. - * - * @param start - * @param end - * @param scaler - * @param config - */ - public calculatePoint( - start: Date, - end: Date | null, - scaler: GraphicScale, - config: SimpleDataGraphicConfiguration, - ) { - const startX = scaler(start); - const endX = end ? scaler(end) : startX; - - const x = Math.round((startX + endX) / 2); - const y = config.bottom; - - this.x.set(x); - this.y.set(y); - } - public clear() { this.measure.set(null); this.start.set(null); this.end.set(null); - this.x.set(null); - this.y.set(null); } public hasSelection() { diff --git a/web-common/src/features/dashboards/time-series/multiple-dimension-queries.ts b/web-common/src/features/dashboards/time-series/multiple-dimension-queries.ts index b3a534e9d96..f18161e9235 100644 --- a/web-common/src/features/dashboards/time-series/multiple-dimension-queries.ts +++ b/web-common/src/features/dashboards/time-series/multiple-dimension-queries.ts @@ -8,7 +8,7 @@ import { } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; import { createBatches } from "@rilldata/web-common/lib/arrayUtils"; import { type Readable, derived } from "svelte/store"; -import { COMPARIONS_COLORS } from "@rilldata/web-common/features/dashboards/config"; +import { COMPARISON_COLORS } from "@rilldata/web-common/features/dashboards/config"; import { getDimensionFilterWithSearch } from "@rilldata/web-common/features/dashboards/dimension-table/dimension-table-utils"; import { SortDirection, @@ -413,7 +413,7 @@ export function getDimensionValueTimeSeries( results.push({ value, total, - color: COMPARIONS_COLORS[i] ? COMPARIONS_COLORS[i] : "", + color: COMPARISON_COLORS[i] ? COMPARISON_COLORS[i] : "", data: prepData, isFetching, }); diff --git a/web-common/src/features/dashboards/time-series/round-to-nearest-time-unit.spec.ts b/web-common/src/features/dashboards/time-series/round-to-nearest-time-unit.spec.ts deleted file mode 100644 index a264c82fbd0..00000000000 --- a/web-common/src/features/dashboards/time-series/round-to-nearest-time-unit.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { roundToNearestTimeUnit } from "./round-to-nearest-time-unit"; -import { describe, it, expect } from "vitest"; - -describe("roundToNearestTimeUnit", () => { - it("rounds to nearest minute", () => { - const date = new Date("2023-03-29T12:34:56"); - const expectedResult = new Date("2023-03-29T12:35:00"); - expect(roundToNearestTimeUnit(date, "minute")).toEqual(expectedResult); - }); - - it("rounds to nearest hour", () => { - const date = new Date("2023-03-29T12:34:56"); - const expectedResult = new Date("2023-03-29T13:00:00"); - expect(roundToNearestTimeUnit(date, "hour")).toEqual(expectedResult); - }); - - it("rounds to nearest day", () => { - const date = new Date("2023-03-29T12:34:56"); - const expectedResult = new Date("2023-03-30T00:00:00"); - expect(roundToNearestTimeUnit(date, "day")).toEqual(expectedResult); - }); - - it("rounds to nearest month", () => { - const date = new Date("2023-03-14T12:34:56"); - const expectedResult = new Date("2023-03-01T00:00:00"); - expect(roundToNearestTimeUnit(date, "month")).toEqual(expectedResult); - }); -}); diff --git a/web-common/src/features/dashboards/time-series/round-to-nearest-time-unit.ts b/web-common/src/features/dashboards/time-series/round-to-nearest-time-unit.ts deleted file mode 100644 index 6fb88013543..00000000000 --- a/web-common/src/features/dashboards/time-series/round-to-nearest-time-unit.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { DateTime, type DateTimeUnit } from "luxon"; - -export function roundToNearestTimeUnit(date: Date, unit: DateTimeUnit): Date { - const dateTime = DateTime.fromJSDate(date); - if (!DateTime.isDateTime(dateTime)) { - throw new Error("Invalid Luxon DateTime object"); - } - - const unitMap: Record = { - year: "month", - quarter: "month", - month: "day", - week: "weekday", - day: "hour", - hour: "minute", - minute: "second", - second: "millisecond", - millisecond: "millisecond", - }; - // get smallest unit - const smallerUnit = unitMap[unit]; - - const smallestValue = dateTime.get(smallerUnit); - let roundUp = false; - if (smallerUnit === "millisecond") { - roundUp = smallestValue >= 500; - } else if (smallerUnit === "second") { - roundUp = smallestValue >= 30; - } else if (smallerUnit === "minute") { - roundUp = smallestValue >= 30; - } else if (smallerUnit === "hour") { - roundUp = smallestValue >= 12; - } else if (smallerUnit === "day") { - roundUp = smallestValue >= 15; - } else if (smallerUnit === "weekday") { - roundUp = smallestValue >= 3; - } else if (smallerUnit === "month") { - roundUp = smallestValue >= 6; - } - - const unitValue = dateTime.get(unit as keyof DateTime); - const roundedValue = roundUp ? unitValue + 1 : unitValue; - - let roundedDateTime: DateTime; - - if (unit === "week") { - roundedDateTime = dateTime.startOf("day")[roundUp ? "plus" : "minus"]({ - day: roundUp ? 7 - smallestValue : smallestValue, - }); - } else { - roundedDateTime = dateTime - .startOf(unit as DateTimeUnit, { useLocaleWeeks: true }) - .set({ [unit]: roundedValue }); - } - - return roundedDateTime.toJSDate(); -} - -export function roundDownToTimeUnit( - date: Date, - unit: DateTimeUnit | keyof DateTime, -) { - const dateTime = DateTime.fromJSDate(date); - if (!DateTime.isDateTime(dateTime)) { - throw new Error("Invalid Luxon DateTime object"); - } - - return dateTime - .startOf(unit as DateTimeUnit, { useLocaleWeeks: true }) - .toJSDate(); -} diff --git a/web-common/src/features/dashboards/time-series/utils.ts b/web-common/src/features/dashboards/time-series/utils.ts index 40b292f724d..a27c9ae8218 100644 --- a/web-common/src/features/dashboards/time-series/utils.ts +++ b/web-common/src/features/dashboards/time-series/utils.ts @@ -1,26 +1,20 @@ -import type { GraphicScale } from "@rilldata/web-common/components/data-graphic/state/types"; -import { bisectData } from "@rilldata/web-common/components/data-graphic/utils"; import { createIndexMap } from "@rilldata/web-common/features/dashboards/pivot/pivot-utils"; import { createAndExpression, filterExpressions, matchExpressionByName, } from "@rilldata/web-common/features/dashboards/stores/filter-utils"; -import { chartInteractionColumn } from "@rilldata/web-common/features/dashboards/time-dimension-details/time-dimension-data-store"; import type { V1Expression, V1MetricsViewAggregationResponseDataItem, V1TimeSeriesValue, } from "@rilldata/web-common/runtime-client"; -import type { DateTimeUnit } from "luxon"; -import { get } from "svelte/store"; import { convertISOStringToJSDateWithSameTimeAsSelectedTimeZone, removeZoneOffset, } from "../../../lib/time/timezone"; import { getDurationMultiple, getOffset } from "../../../lib/time/transforms"; import { TimeOffsetType } from "../../../lib/time/types"; -import { roundToNearestTimeUnit } from "./round-to-nearest-time-unit"; import type { TimeSeriesDatum } from "./timeseries-data-store"; /** sets extents to 0 if it makes sense; otherwise, inflates each extent component */ @@ -58,42 +52,6 @@ export function toComparisonKeys(d, offsetDuration: string, zone: string) { }, {}); } -export function updateChartInteractionStore( - xHoverValue: undefined | number | Date, - yHoverValue: undefined | string | null, - isAllTime: boolean, - formattedData: TimeSeriesDatum[], -) { - let xHoverColNum: number | undefined = undefined; - - const slicedData = isAllTime - ? formattedData?.slice(1) - : formattedData?.slice(1, -1); - - if (xHoverValue && xHoverValue instanceof Date) { - const { position } = bisectData( - xHoverValue, - "center", - "ts_position", - slicedData, - ); - xHoverColNum = position; - } - - const currentCol = get(chartInteractionColumn); - - if ( - currentCol?.xHover !== xHoverColNum || - currentCol?.yHover !== yHoverValue - ) { - chartInteractionColumn.update((state) => ({ - ...state, - yHover: yHoverValue, - xHover: xHoverColNum, - })); - } -} - export function prepareTimeSeries( original: V1TimeSeriesValue[], comparison: V1TimeSeriesValue[] | undefined, @@ -134,30 +92,6 @@ export function prepareTimeSeries( }); } -export function getBisectedTimeFromCordinates( - value: number, - scaleStore: GraphicScale, - accessor: string, - data: TimeSeriesDatum[], - grainLabel: DateTimeUnit, -): Date | null { - const roundedValue = roundToNearestTimeUnit( - new Date(scaleStore.invert(value)), - grainLabel, - ); - const { entry: bisector } = bisectData( - roundedValue, - "center", - accessor, - data, - ); - if (!bisector || typeof bisector === "number") return null; - const bisected = bisector[accessor]; - if (!bisected) return null; - - return new Date(bisected); -} - /** * The dates in the charts are in the local timezone, this util method * removes the selected timezone offset and adds the local offset diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index 32bde3d3b31..ff9b721f691 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -190,8 +190,6 @@ {#if hasTimeSeries} {:else} diff --git a/web-common/src/features/models/inspector/WorkspaceInspector.svelte b/web-common/src/features/models/inspector/WorkspaceInspector.svelte index 68562a9474a..9b9f406a83e 100644 --- a/web-common/src/features/models/inspector/WorkspaceInspector.svelte +++ b/web-common/src/features/models/inspector/WorkspaceInspector.svelte @@ -302,6 +302,6 @@ diff --git a/web-common/src/lib/convertTimestampPreview.ts b/web-common/src/lib/convertTimestampPreview.ts deleted file mode 100644 index 88cdf9277c8..00000000000 --- a/web-common/src/lib/convertTimestampPreview.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { removeLocalTimezoneOffset as remove } from "@rilldata/web-common/lib/time/timezone"; - -export function convertTimestampPreviewFcn( - ts, - removeLocalTimezoneOffset = false, -) { - return removeLocalTimezoneOffset ? remove(new Date(ts)) : new Date(ts); -} - -/** used to convert a timestamp preview from the server for a sparkline. */ -export function convertTimestampPreview(d, removeLocalTimezoneOffset = false) { - return d.map((di) => { - const pi = { ...di }; - pi.ts = convertTimestampPreviewFcn(di.ts, removeLocalTimezoneOffset); - return pi; - }); -} diff --git a/web-common/src/lib/duckdb-data-types.ts b/web-common/src/lib/duckdb-data-types.ts index 5f481525620..05e6313b392 100644 --- a/web-common/src/lib/duckdb-data-types.ts +++ b/web-common/src/lib/duckdb-data-types.ts @@ -154,7 +154,7 @@ export const CATEGORICAL_TOKENS: ColorTokens = { export const NUMERIC_TOKENS: ColorTokens = { textClass: "text-red-800", - bgClass: "bg-red-200", + bgClass: "bg-primary-200", vizFillClass: "fill-red-300", vizStrokeClass: "stroke-red-300", }; diff --git a/web-common/src/lib/time/ranges/formatter.ts b/web-common/src/lib/time/ranges/formatter.ts index cef432d236a..0e6a081c02d 100644 --- a/web-common/src/lib/time/ranges/formatter.ts +++ b/web-common/src/lib/time/ranges/formatter.ts @@ -1,4 +1,7 @@ -import { V1TimeGrainToOrder } from "@rilldata/web-common/lib/time/new-grains.ts"; +import { + V1TimeGrainToOrder, + V1TimeGrainToDateTimeUnit, +} from "@rilldata/web-common/lib/time/new-grains.ts"; import { V1TimeGrain, type V1TimeRange, @@ -167,3 +170,78 @@ export function formatDateTimeByGrain( return dt.toLocaleString(format); } + +/** + * Formats a grain-sized bucket for hover labels and scrub readouts. + * For hour and finer grains, delegates to formatDateTimeByGrain. + * For day+ grains, shows the date range of the bucket, with partial + * bucket detection when the dashboard interval trims the first/last bucket. + */ +export function formatGrainBucket( + dt: DateTime, + grain: V1TimeGrain = V1TimeGrain.TIME_GRAIN_UNSPECIFIED, + interval?: Interval, +): string { + if (!dt.isValid) return "Invalid date"; + + const grainOrder = getCorrectGrainOrder(grain); + + // Hour and finer: delegate to existing formatter + if (grainOrder <= hourGrainOrder) { + return formatDateTimeByGrain(dt, grain); + } + + const unit = V1TimeGrainToDateTimeUnit[grain]; + const bucketEnd = dt.plus({ [unit + "s"]: 1 }); + + const effectiveStart = + interval?.start && interval.start > dt ? interval.start : dt; + const effectiveEnd = + interval?.end && interval.end < bucketEnd ? interval.end : bucketEnd; + + // Day grain: show "Apr 17, 2025" or partial "Apr 17, 2025 (2 PM – 12 AM)" + if (grainOrder === dayGrainOrder) { + const datePart = dt.toLocaleString({ + month: "short", + day: "numeric", + year: "numeric", + }); + + const isPartial = + !effectiveStart.equals(dt) || !effectiveEnd.equals(bucketEnd); + if (!isPartial) return datePart; + + const timeFmt: DateTimeFormatOptions = { + hour: "numeric", + hour12: true, + }; + if (effectiveStart.minute !== 0) timeFmt.minute = "2-digit"; + + const endFmt: DateTimeFormatOptions = { + hour: "numeric", + hour12: true, + }; + if (effectiveEnd.minute !== 0) endFmt.minute = "2-digit"; + + const startTime = effectiveStart.toLocaleString(timeFmt); + const endTime = effectiveEnd.toLocaleString(endFmt); + return `${datePart} (${startTime} – ${endTime})`; + } + + // Week, month, quarter, year: show range "Apr 17 – 23, 2025" + const inclusiveEnd = effectiveEnd.minus({ millisecond: 1 }); + const rangeInterval = Interval.fromDateTimes(effectiveStart, inclusiveEnd); + if (!rangeInterval.isValid) { + return dt.toLocaleString({ + month: "short", + day: "numeric", + year: "numeric", + }); + } + + return rangeInterval.toLocaleString({ + month: "short", + day: "numeric", + year: "numeric", + }); +} diff --git a/web-common/tests/fixtures/rill-dev-fixtures.ts b/web-common/tests/fixtures/rill-dev-fixtures.ts index a89930a22f4..c34160b10f4 100644 --- a/web-common/tests/fixtures/rill-dev-fixtures.ts +++ b/web-common/tests/fixtures/rill-dev-fixtures.ts @@ -31,7 +31,15 @@ export const rillDev = base.extend({ rillDevBrowserState: [undefined, { option: true }], rillDevPage: async ( - { browser, project, projectDir, cliHomeDir, rillDevBrowserState }, + { + browser, + project, + projectDir, + cliHomeDir, + rillDevBrowserState, + timezoneId, + locale, + }, use, ) => { const TEST_PORT = await getOpenPort(); @@ -99,20 +107,18 @@ export const rillDev = base.extend({ } }); - const context = await browser.newContext( - rillDevBrowserState - ? { - storageState: rillDevBrowserState, - } - : { - storageState: { cookies: [], origins: [] }, - }, - ); + const context = await browser.newContext({ + storageState: rillDevBrowserState ?? { cookies: [], origins: [] }, + ...(timezoneId ? { timezoneId } : {}), + ...(locale ? { locale } : {}), + }); const page = await context.newPage(); await page.goto(`http://localhost:${TEST_PORT}`); - // Seems to help with issues related to DOM elements not being ready + // Give the runtime time to reconcile initial resources. Tests that + // navigate directly to explore URLs (via page.goto) need the explore + // to exist before navigation. await page.waitForTimeout(1500); await use(page); diff --git a/web-common/tests/utils/explore-interactions.ts b/web-common/tests/utils/explore-interactions.ts index dcc7e1353f6..fd2d4845f88 100644 --- a/web-common/tests/utils/explore-interactions.ts +++ b/web-common/tests/utils/explore-interactions.ts @@ -14,3 +14,9 @@ export async function interactWithTimeRangeMenu( page.getByRole("menu", { name: "Select time range" }), ).not.toBeVisible(); } + +export async function setDashboardTimezone(page: Page, timezone: string) { + const currentUrl = new URL(page.url()); + currentUrl.searchParams.set("tz", timezone); + await page.goto(currentUrl.toString()); +} diff --git a/web-local/tests/explores/annotations.spec.ts b/web-local/tests/explores/annotations.spec.ts new file mode 100644 index 00000000000..374ab18e680 --- /dev/null +++ b/web-local/tests/explores/annotations.spec.ts @@ -0,0 +1,421 @@ +import { expect, type Page } from "@playwright/test"; +import { test } from "../setup/base"; +import { gotoNavEntry } from "../utils/waitHelpers"; +import { interactWithTimeRangeMenu } from "@rilldata/web-common/tests/utils/explore-interactions"; +import { formatGrainBucket } from "@rilldata/web-common/lib/time/ranges/formatter"; +import { DateTime } from "luxon"; +import { V1TimeGrain } from "@rilldata/web-common/runtime-client/gen/index.schemas"; +import axios from "axios"; + +// Annotation timestamps as they'll be serialized from DuckDB (UTC). +// All annotations that may be visible at day grain in "Last 7 days": +const DAY_ANNOTATION_TIMES = [ + "2022-03-24T00:00:00Z", // Point A + "2022-03-25T00:00:00Z", // Range E (start) + "2022-03-26T00:00:00Z", // Point B + "2022-03-27T00:00:00Z", // Point C + "2022-03-28T00:00:00Z", // Point D + "2022-03-30T06:00:00Z", // Hour E (snaps to Mar 30 at day grain in UTC) + "2022-03-30T14:00:00Z", // Hour F (same day bucket as E in UTC, but splits to Mar 30 in LA) +]; + +// Hour-level annotations visible in "Last 24 hours": +const HOUR_ANNOTATION_TIMES = [ + "2022-03-30T06:00:00Z", // Hour E + "2022-03-30T14:00:00Z", // Hour F +]; + +const ANNOTATION_FILES: { path: string; blob: string }[] = [ + { + path: "models/AdBids_point_annotations.sql", + blob: [ + "select TIMESTAMP '2022-03-24' as time, 'Point annotation A' as description", + "union all", + "select TIMESTAMP '2022-03-26' as time, 'Point annotation B' as description", + "union all", + "select TIMESTAMP '2022-03-27' as time, 'Point annotation C' as description", + "union all", + "select TIMESTAMP '2022-03-28' as time, 'Point annotation D' as description", + ].join("\n"), + }, + { + path: "models/AdBids_range_annotations.sql", + blob: [ + "select TIMESTAMP '2022-03-25' as time,", + " TIMESTAMP '2022-03-27' as time_end,", + " 'Range annotation C' as description", + ].join("\n"), + }, + { + path: "models/AdBids_hour_annotations.sql", + blob: [ + "select TIMESTAMP '2022-03-30 06:00:00' as time, 'Hour annotation E' as description", + "union all", + "select TIMESTAMP '2022-03-30 14:00:00' as time, 'Hour annotation F' as description", + ].join("\n"), + }, +]; + +const METRICS_YAML_WITH_ANNOTATIONS = `# Metrics view YAML +# Reference documentation: https://docs.rilldata.com/reference/project-files/metrics-views + +version: 1 +type: metrics_view + +display_name: Adbids +table: AdBids_model +timeseries: timestamp + +dimensions: + - name: publisher + display_name: Publisher + column: publisher + - name: domain + display_name: Domain + column: domain + - name: timestamp + display_name: Timestamp + column: timestamp + type: time + - name: offset_timestamp + display_name: Offset Timestamp + column: offset_timestamp + type: time + +measures: + - name: total_records + display_name: Total records + expression: COUNT(*) + description: "" + format_preset: humanize + - name: bid_price_sum + display_name: Sum of Bid Price + expression: SUM(bid_price) + description: "" + format_preset: humanize + +annotations: + - model: AdBids_point_annotations + measures: ['total_records'] + - model: AdBids_range_annotations + measures: ['total_records'] + - model: AdBids_hour_annotations + measures: ['total_records'] +`; + +// Write annotation models + updated metrics YAML via the runtime API. +async function installAnnotations(page: Page) { + const base = new URL(page.url()).origin; + const putFile = (path: string, blob: string) => + axios.post(`${base}/v1/instances/default/files/entry`, { + path, + blob, + create: true, + }); + + for (const f of ANNOTATION_FILES) { + await putFile(f.path, f.blob); + } + await putFile("metrics/AdBids_metrics.yaml", METRICS_YAML_WITH_ANNOTATIONS); +} + +// The app sets Settings.defaultLocale = "en" globally, so chart labels are +// always English regardless of the browser's navigator.language. +const CHART_LOCALE = "en"; + +function expectedDates( + times: string[], + grain: V1TimeGrain, + dashboardTZ: string, +): Set { + const dates = times.map((ts) => + formatGrainBucket( + DateTime.fromISO(ts, { zone: dashboardTZ, locale: CHART_LOCALE }), + grain, + ), + ); + return new Set(dates); +} + +async function setupDashboard(page: Page, dashboardTZ: string) { + await page.getByLabel("/dashboards").click(); + await gotoNavEntry(page, "/dashboards/AdBids_metrics_explore.yaml"); + + // Wait for the base project to finish reconciling + await expect( + page.getByRole("button", { name: /Total records/ }).first(), + ).toBeVisible({ timeout: 30_000 }); + + // Write annotation files while still in the editor view. This triggers + // re-reconciliation in the background. + await installAnnotations(page); + + // Navigate directly to the explore with the timezone already set. + const base = new URL(page.url()).origin; + const exploreUrl = `${base}/explore/AdBids_metrics_explore?tz=${encodeURIComponent(dashboardTZ)}`; + await page.goto(exploreUrl); + + await expect( + page.getByRole("button", { name: /Total records/ }).first(), + ).toBeVisible({ timeout: 30_000 }); + + // Wait for annotation query responses to arrive and the chart to + // finish re-rendering. Without this, menu interactions race against + // DOM element detachment from the annotation-triggered re-render. + await page.waitForTimeout(2000); +} + +async function selectGrain(page: Page, grain: string) { + const grainSelector = page.getByLabel("Select aggregation grain"); + await grainSelector.click(); + await page.getByRole("menuitem", { name: grain, exact: true }).click(); + await expect(grainSelector).toContainText(grain, { timeout: 5000 }); +} + +/** + * For each diamond marker on the chart, hover at its x-position to trigger + * the date readout, then verify the displayed date is in the expected set. + */ +async function verifyDiamondDates( + page: Page, + chart: ReturnType, + expected: Set, +) { + const diamonds = chart.locator('rect[aria-label="annotation marker"]'); + const dateReadout = page + .getByLabel("total_records primary time label") + .first(); + + // Wait for the chart to settle by retrying until the first diamond's + // date is in the expected set (handles time-range / grain re-render). + await expect(async () => { + await expect(diamonds.first()).toBeVisible(); + const box = await chart.boundingBox(); + expect(box).toBeTruthy(); + const centerY = box!.y + box!.height / 2; + const dBox = await diamonds.first().boundingBox(); + expect(dBox).toBeTruthy(); + await page.mouse.move(dBox!.x + dBox!.width / 2, centerY); + await expect(dateReadout).toBeVisible(); + const text = (await dateReadout.textContent())?.trim(); + expect(text).toBeTruthy(); + expect([...expected]).toContain(text!); + }).toPass({ timeout: 10_000 }); + + // Chart is now in the correct state — verify all diamonds. + const count = await diamonds.count(); + expect(count).toBeGreaterThanOrEqual(1); + + const box = await chart.boundingBox(); + if (!box) throw new Error("Chart bounding box not found"); + const centerY = box.y + box.height / 2; + + const matchedDates: string[] = []; + + for (let i = 0; i < count; i++) { + const dBox = await diamonds.nth(i).boundingBox(); + if (!dBox) continue; + + await page.mouse.move(dBox.x + dBox.width / 2, centerY); + await expect(dateReadout).toBeVisible({ timeout: 2000 }); + + const dateText = (await dateReadout.textContent())?.trim(); + expect(dateText).toBeTruthy(); + expect( + [...expected], + + `Diamond ${i} date "${dateText}" not in expected set`, + ).toContain(dateText!); + matchedDates.push(dateText!); + } + + expect(matchedDates.length).toBeGreaterThanOrEqual(1); + expect(matchedDates.length).toBe(count); +} + +// --------------------------------------------------------------------------- +// Group 1: Rendering coverage — test all dashboard TZs with a fixed system TZ. +// This covers the actual rendering logic (different offsets shift annotations +// to different day/hour buckets). +// --------------------------------------------------------------------------- +const DASHBOARD_TIMEZONES = ["UTC", "Asia/Kolkata", "America/Los_Angeles"]; + +test.describe("annotations (rendering)", () => { + test.use({ project: "AdBids", timezoneId: "UTC", locale: "en-US" }); + // Extra time for installAnnotations (file writes + reconciliation). + test.setTimeout(60_000); + + for (const dashboardTZ of DASHBOARD_TIMEZONES) { + test.describe(`dashboard: ${dashboardTZ}`, () => { + test("day-level annotations placed at correct dates", async ({ + page, + }) => { + await setupDashboard(page, dashboardTZ); + + await interactWithTimeRangeMenu(page, async () => { + await page.getByRole("menuitem", { name: "Last 7 days" }).click(); + }); + await selectGrain(page, "day"); + + const chart = page + .getByLabel("Measure Chart for total_records") + .first(); + await expect(chart).toBeVisible({ timeout: 10_000 }); + + const expected = expectedDates( + DAY_ANNOTATION_TIMES, + V1TimeGrain.TIME_GRAIN_DAY, + dashboardTZ, + ); + await verifyDiamondDates(page, chart, expected); + }); + + test("hour-level annotations placed at correct times", async ({ + page, + }) => { + await setupDashboard(page, dashboardTZ); + + await interactWithTimeRangeMenu(page, async () => { + await page.getByRole("menuitem", { name: "Last 24 hours" }).click(); + }); + await selectGrain(page, "hour"); + + const chart = page + .getByLabel("Measure Chart for total_records") + .first(); + await expect(chart).toBeVisible({ timeout: 10_000 }); + + const expected = expectedDates( + HOUR_ANNOTATION_TIMES, + V1TimeGrain.TIME_GRAIN_HOUR, + dashboardTZ, + ); + await verifyDiamondDates(page, chart, expected); + }); + + test("popover shows on hover over annotation diamond", async ({ + page, + }) => { + await setupDashboard(page, dashboardTZ); + + await interactWithTimeRangeMenu(page, async () => { + await page.getByRole("menuitem", { name: "Last 7 days" }).click(); + }); + await selectGrain(page, "day"); + + const chart = page + .getByLabel("Measure Chart for total_records") + .first(); + await expect(chart).toBeVisible({ timeout: 10_000 }); + + const diamonds = chart.locator('rect[aria-label="annotation marker"]'); + await expect(diamonds.first()).toBeVisible({ timeout: 10_000 }); + + // Hover directly at each diamond's position near the bottom of + // the chart until a popover appears. + const box = await chart.boundingBox(); + if (!box) throw new Error("Chart bounding box not found"); + const hoverY = box.y + box.height - 8; + + let popoverFound = false; + const count = await diamonds.count(); + + for (let i = 0; i < count; i++) { + const dBox = await diamonds.nth(i).boundingBox(); + if (!dBox) continue; + + await page.mouse.move(dBox.x + dBox.width / 2, hoverY); + await page.waitForTimeout(100); + + const popover = page + .locator('[role="menu"]') + .filter({ hasText: /annotation/i }); + + if ( + (await popover.count()) > 0 && + (await popover.first().isVisible()) + ) { + popoverFound = true; + const text = await popover.first().textContent(); + expect(text).toMatch( + /Point annotation|Range annotation|Hour annotation/, + ); + break; + } + } + + expect(popoverFound).toBe(true); + }); + + test("no diamond markers on bid_price_sum chart", async ({ page }) => { + await setupDashboard(page, dashboardTZ); + + await interactWithTimeRangeMenu(page, async () => { + await page.getByRole("menuitem", { name: "Last 7 days" }).click(); + }); + await selectGrain(page, "day"); + + const chart = page + .getByLabel("Measure Chart for bid_price_sum") + .first(); + await expect(chart).toBeVisible({ timeout: 10_000 }); + + // Wait for the total_records chart to settle first (ensures data loaded). + const trChart = page + .getByLabel("Measure Chart for total_records") + .first(); + const trDiamonds = trChart.locator( + 'rect[aria-label="annotation marker"]', + ); + await expect(trDiamonds.first()).toBeVisible({ timeout: 10_000 }); + + const diamonds = chart.locator('rect[aria-label="annotation marker"]'); + await expect(diamonds).toHaveCount(0); + }); + }); + } +}); + +// --------------------------------------------------------------------------- +// Group 2: System TZ independence — prove that changing the browser's system +// timezone does not affect annotation placement. We run the day-level test +// (most sensitive to TZ shifts) with non-UTC system TZs against the LA +// dashboard TZ (largest offset from UTC). +// --------------------------------------------------------------------------- +const INDEPENDENCE_CONFIGS = [ + { systemTZ: "America/New_York", locale: "en-US" }, + { systemTZ: "Europe/Prague", locale: "de-DE" }, +]; + +for (const sys of INDEPENDENCE_CONFIGS) { + test.describe(`annotations system TZ independence (system: ${sys.systemTZ})`, () => { + test.use({ + project: "AdBids", + timezoneId: sys.systemTZ, + locale: sys.locale, + }); + + test("day-level annotations match UTC baseline (dashboard: America/Los_Angeles)", async ({ + page, + }) => { + const dashboardTZ = "America/Los_Angeles"; + + await setupDashboard(page, dashboardTZ); + + await interactWithTimeRangeMenu(page, async () => { + await page.getByRole("menuitem", { name: "Last 7 days" }).click(); + }); + await selectGrain(page, "day"); + + const chart = page.getByLabel("Measure Chart for total_records").first(); + await expect(chart).toBeVisible({ timeout: 10_000 }); + + const expected = expectedDates( + DAY_ANNOTATION_TIMES, + V1TimeGrain.TIME_GRAIN_DAY, + dashboardTZ, + ); + await verifyDiamondDates(page, chart, expected); + }); + }); +} diff --git a/web-local/tests/explores/scrub.spec.ts b/web-local/tests/explores/scrub.spec.ts new file mode 100644 index 00000000000..1f43ee81b04 --- /dev/null +++ b/web-local/tests/explores/scrub.spec.ts @@ -0,0 +1,153 @@ +import { expect, type Page } from "@playwright/test"; +import { test } from "../setup/base"; +import { gotoNavEntry } from "../utils/waitHelpers"; +import { interactWithTimeRangeMenu } from "@rilldata/web-common/tests/utils/explore-interactions"; + +async function setupDashboard(page: Page) { + await page.getByLabel("/dashboards").click(); + await gotoNavEntry(page, "/dashboards/AdBids_metrics_explore.yaml"); + + const bigNumber = page + .locator(".big-number") + .filter({ hasText: "Total records" }); + await expect(bigNumber).toBeVisible({ timeout: 10_000 }); + + await page.getByRole("button", { name: "Preview" }).click(); + await expect(bigNumber).toBeVisible({ timeout: 10_000 }); + + await interactWithTimeRangeMenu(page, async () => { + await page.getByRole("menuitem", { name: "All Time" }).click(); + }); + await page.waitForTimeout(1000); + + const valueLocator = bigNumber.locator('div[role="button"]'); + await expect(valueLocator).toBeVisible({ timeout: 5000 }); + + const chartSvg = page.locator('svg[aria-label*="Measure Chart"]').first(); + await expect(chartSvg).toBeVisible({ timeout: 5000 }); + const box = await chartSvg.boundingBox(); + expect(box).toBeTruthy(); + + return { valueLocator, chartSvg, box: box! }; +} + +/** Drag across startPct–endPct (0–1) of chart width to create a scrub selection. */ +async function scrub( + page: Page, + box: { x: number; y: number; width: number; height: number }, + startPct: number, + endPct: number, +) { + const startX = box.x + box.width * startPct; + const endX = box.x + box.width * endPct; + const centerY = box.y + box.height / 2; + + await page.mouse.move(startX, centerY); + await page.mouse.down(); + await page.mouse.move(endX, centerY, { steps: 15 }); + await page.mouse.up(); + await page.waitForTimeout(1500); +} + +test.describe("chart scrub and zoom", () => { + test.use({ project: "AdBids" }); + + test("scrub selection updates big number, zoom changes time range", async ({ + page, + }) => { + const { valueLocator, box } = await setupDashboard(page); + const initialValue = await valueLocator.textContent(); + + await scrub(page, box, 0.2, 0.6); + + const scrubValue = await valueLocator.textContent(); + expect(scrubValue).toBeTruthy(); + expect(scrubValue).not.toBe(initialValue); + + await expect(page.getByLabel("Zoom")).toBeVisible({ timeout: 3000 }); + + await page.keyboard.press("z"); + await page.waitForTimeout(1500); + + await expect(page.getByLabel("Undo zoom")).toBeVisible({ timeout: 3000 }); + const timeRangeText = await page + .getByLabel("Select time range") + .textContent(); + expect(timeRangeText).toContain("Custom"); + + const zoomedValue = await valueLocator.textContent(); + expect(zoomedValue).toBe(scrubValue); + }); + + test("move scrub range updates big number", async ({ page }) => { + const { valueLocator, box } = await setupDashboard(page); + + await scrub(page, box, 0.2, 0.5); + const scrubValue = await valueLocator.textContent(); + expect(scrubValue).toBeTruthy(); + + // Grab center of selection (35%) and drag right to 65% + const grabX = box.x + box.width * 0.35; + const dropX = box.x + box.width * 0.65; + const centerY = box.y + box.height / 2; + + await page.mouse.move(grabX, centerY); + await page.mouse.down(); + await page.mouse.move(dropX, centerY, { steps: 15 }); + await page.mouse.up(); + await page.waitForTimeout(1500); + + const movedValue = await valueLocator.textContent(); + expect(movedValue).toBeTruthy(); + expect(movedValue).not.toBe(scrubValue); + await expect(page.getByLabel("Zoom")).toBeVisible({ timeout: 3000 }); + }); + + test("resize scrub start edge updates big number", async ({ page }) => { + const { valueLocator, box } = await setupDashboard(page); + + await scrub(page, box, 0.3, 0.7); + const scrubValue = await valueLocator.textContent(); + expect(scrubValue).toBeTruthy(); + + // Drag left edge from 30% to 10% + const edgeX = box.x + box.width * 0.3; + const newEdgeX = box.x + box.width * 0.1; + const centerY = box.y + box.height / 2; + + await page.mouse.move(edgeX, centerY); + await page.mouse.down(); + await page.mouse.move(newEdgeX, centerY, { steps: 15 }); + await page.mouse.up(); + await page.waitForTimeout(1500); + + const resizedValue = await valueLocator.textContent(); + expect(resizedValue).toBeTruthy(); + expect(resizedValue).not.toBe(scrubValue); + await expect(page.getByLabel("Zoom")).toBeVisible({ timeout: 3000 }); + }); + + test("resize scrub end edge updates big number", async ({ page }) => { + const { valueLocator, box } = await setupDashboard(page); + + await scrub(page, box, 0.2, 0.5); + const scrubValue = await valueLocator.textContent(); + expect(scrubValue).toBeTruthy(); + + // Drag right edge from 50% to 80% + const edgeX = box.x + box.width * 0.5; + const newEdgeX = box.x + box.width * 0.8; + const centerY = box.y + box.height / 2; + + await page.mouse.move(edgeX, centerY); + await page.mouse.down(); + await page.mouse.move(newEdgeX, centerY, { steps: 15 }); + await page.mouse.up(); + await page.waitForTimeout(1500); + + const resizedValue = await valueLocator.textContent(); + expect(resizedValue).toBeTruthy(); + expect(resizedValue).not.toBe(scrubValue); + await expect(page.getByLabel("Zoom")).toBeVisible({ timeout: 3000 }); + }); +}); diff --git a/web-local/tests/explores/time-grain-derivation.spec.ts b/web-local/tests/explores/time-grain-derivation.spec.ts index 0b2e08cacde..a16a12a11ca 100644 --- a/web-local/tests/explores/time-grain-derivation.spec.ts +++ b/web-local/tests/explores/time-grain-derivation.spec.ts @@ -1,4 +1,4 @@ -import { expect } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; import { test } from "../setup/base"; test.describe("Time grain derivation from URL", () => { @@ -6,7 +6,7 @@ test.describe("Time grain derivation from URL", () => { // Helper to run a time grain derivation test async function testGrainDerivation( - page: import("@playwright/test").Page, + page: Page, timeRange: string, expectedGrain: string, ) { @@ -29,128 +29,51 @@ test.describe("Time grain derivation from URL", () => { expect(grain).toBe(expectedGrain); } - // Basic ISO duration tests - test("derives day grain for P7D (7 days)", async ({ page }) => { - await testGrainDerivation(page, "P7D", "day"); - }); - - test("derives hour grain for PT6H (6 hours)", async ({ page }) => { - await testGrainDerivation(page, "PT6H", "hour"); - }); - - test("derives week grain for P4W (4 weeks)", async ({ page }) => { - await testGrainDerivation(page, "P4W", "week"); - }); - - // Rill time syntax tests with "as of latest" format - test("derives day grain for 365d as of latest/h+1h", async ({ page }) => { - // 365 days at hour grain = 8760 buckets (exceeds 1500 limit) - // Should use day grain = 365 buckets - await testGrainDerivation(page, "365d as of latest/h+1h", "day"); - }); - - test("derives day grain for 12M as of latest/m+1m", async ({ page }) => { - await testGrainDerivation(page, "12M as of latest/m+1m", "day"); - }); - - test("derives month grain for 365M as of latest/d+1d", async ({ page }) => { - // 365 months at day grain = ~11,000 buckets (exceeds 1500 limit) - // Should use month grain = 365 buckets - await testGrainDerivation(page, "365M as of latest/d+1d", "month"); - }); - - // Edge cases around bucket limits (1500 max) - test("derives hour grain for 24h as of latest/h", async ({ page }) => { - await testGrainDerivation(page, "24h as of latest/h", "hour"); - }); - - test("derives day grain for 90d as of latest/d", async ({ page }) => { - await testGrainDerivation(page, "90d as of latest/d", "day"); - }); - - test("derives month grain for 2y as of latest/M", async ({ page }) => { - await testGrainDerivation(page, "2y as of latest/M", "month"); - }); - - test("derives week grain for 5y as of latest/d", async ({ page }) => { - // 5 years = ~1825 days (exceeds 1500 limit at day grain) - // Should use week grain = ~260 buckets - await testGrainDerivation(page, "5y as of latest/d", "week"); - }); - - // Period-to-date tests - test("derives day grain for week-to-date", async ({ page }) => { - await testGrainDerivation(page, "rill-WTD", "day"); - }); - - test("derives day grain for month-to-date", async ({ page }) => { - await testGrainDerivation(page, "rill-MTD", "day"); - }); - - // Snap grain should influence derived grain - test("derives hour grain for 7d as of latest/h", async ({ page }) => { - // 7 days with hour snap = 168 buckets (under 1500) - // Snap grain (hour) should be used - await testGrainDerivation(page, "7d as of latest/h", "hour"); - }); - - test("derives week grain for 52w as of latest/w", async ({ page }) => { - // 52 weeks = 52 buckets at week grain - await testGrainDerivation(page, "52w as of latest/w", "week"); - }); - - // Bucket limit boundary tests (1500 max buckets) - test("derives minute grain for 24h as of latest/m (1440 buckets, under limit)", async ({ - page, - }) => { - // 24 hours = 1440 minutes (just under 1500 limit) - await testGrainDerivation(page, "24h as of latest/m", "minute"); - }); - - test("derives hour grain for 26h as of latest/m (1560 buckets, over limit)", async ({ - page, - }) => { - // 26 hours = 1560 minutes (just over 1500 limit) - // Should fall back to hour grain = 26 buckets - await testGrainDerivation(page, "26h as of latest/m", "hour"); - }); - - test("derives day grain for 62d as of latest/h (1488 buckets, under limit)", async ({ - page, - }) => { - // 62 days = 1488 hours (just under 1500 limit) - await testGrainDerivation(page, "62d as of latest/h", "hour"); - }); - - test("derives day grain for 64d as of latest/h (1536 buckets, over limit)", async ({ - page, - }) => { - // 64 days = 1536 hours (just over 1500 limit) - // Should fall back to day grain = 64 buckets - await testGrainDerivation(page, "64d as of latest/h", "day"); - }); - - // Very large intervals - test("derives year grain for 10y as of latest/M", async ({ page }) => { - // 10 years = 120 months at month grain - await testGrainDerivation(page, "10y as of latest/M", "month"); - }); - - test("derives month grain for 6y as of latest/d", async ({ page }) => { - // 6 years = ~2190 days (exceeds 1500 limit at day grain) - // Should use week grain = ~312 buckets, or month = 72 buckets - await testGrainDerivation(page, "6y as of latest/d", "week"); - }); - - // Quarter grain tests - test("derives quarter grain for 3y as of latest/Q", async ({ page }) => { - // 3 years = 12 quarters - await testGrainDerivation(page, "3y as of latest/Q", "quarter"); - }); - - test("derives quarter grain for 20y as of latest/M", async ({ page }) => { - // 20 years = 240 months (under 1500 at month grain) - // Should use month grain - await testGrainDerivation(page, "20y as of latest/M", "month"); + const cases: [string, string, string][] = [ + // Basic ISO duration tests + ["P7D (7 days)", "P7D", "day"], + ["PT6H (6 hours)", "PT6H", "hour"], + ["P4W (4 weeks)", "P4W", "week"], + + // Rill time syntax tests with "as of latest" format + ["365d as of latest/h+1h", "365d as of latest/h+1h", "day"], + ["12M as of latest/m+1m", "12M as of latest/m+1m", "day"], + ["365M as of latest/d+1d", "365M as of latest/d+1d", "month"], + + // Edge cases around bucket limits (1500 max) + ["24h as of latest/h", "24h as of latest/h", "hour"], + ["90d as of latest/d", "90d as of latest/d", "day"], + ["2y as of latest/M", "2y as of latest/M", "month"], + ["5y as of latest/d", "5y as of latest/d", "week"], + + // Period-to-date tests + ["week-to-date", "rill-WTD", "day"], + ["month-to-date", "rill-MTD", "day"], + + // Snap grain should influence derived grain + ["7d as of latest/h", "7d as of latest/h", "hour"], + ["52w as of latest/w", "52w as of latest/w", "week"], + + // Bucket limit boundary tests (1500 max buckets) + ["24h as of latest/m (1440 buckets)", "24h as of latest/m", "minute"], + ["26h as of latest/m (1560 buckets)", "26h as of latest/m", "hour"], + ["62d as of latest/h (1488 buckets)", "62d as of latest/h", "hour"], + ["64d as of latest/h (1536 buckets)", "64d as of latest/h", "day"], + + // Very large intervals + ["10y as of latest/M", "10y as of latest/M", "month"], + ["6y as of latest/d", "6y as of latest/d", "week"], + + // Quarter grain tests + ["3y as of latest/Q", "3y as of latest/Q", "quarter"], + ["20y as of latest/M", "20y as of latest/M", "month"], + ]; + + test("derives correct grain for all time ranges", async ({ page }) => { + for (const [label, timeRange, expectedGrain] of cases) { + await test.step(`${label} → ${expectedGrain}`, async () => { + await testGrainDerivation(page, timeRange, expectedGrain); + }); + } }); }); diff --git a/web-local/tests/explores/timeseries.spec.ts b/web-local/tests/explores/timeseries.spec.ts index 7b32d8f23ad..3ad262c41db 100644 --- a/web-local/tests/explores/timeseries.spec.ts +++ b/web-local/tests/explores/timeseries.spec.ts @@ -5,15 +5,21 @@ import { type TimeSeriesValue, } from "../utils/dataSpecifcHelpers"; import { gotoNavEntry } from "../utils/waitHelpers"; -import { interactWithTimeRangeMenu } from "@rilldata/web-common/tests/utils/explore-interactions"; +import { + interactWithTimeRangeMenu, + setDashboardTimezone, +} from "@rilldata/web-common/tests/utils/explore-interactions"; import { DateTime } from "luxon"; import { V1TimeGrain } from "@rilldata/web-common/runtime-client/gen/index.schemas"; -import { formatDateTimeByGrain } from "@rilldata/web-common/lib/time/ranges/formatter"; +import { formatGrainBucket } from "@rilldata/web-common/lib/time/ranges/formatter"; import { createMeasureValueFormatter } from "@rilldata/web-common/lib/number-formatting/format-measure-value"; import { V1TimeGrainToDateTimeUnit } from "@rilldata/web-common/lib/time/new-grains"; const HOVER_STEP_PX = 5; +// The app sets Settings.defaultLocale = "en" globally. +const CHART_LOCALE = "en"; + interface TimeRangeTestCase { menuItem: string; expectedDataPoints: number; @@ -23,12 +29,12 @@ interface TimeRangeTestCase { const TIME_RANGE_TEST_CASES: TimeRangeTestCase[] = [ { menuItem: "Last 7 days", - expectedDataPoints: 9, + expectedDataPoints: 7, grain: V1TimeGrain.TIME_GRAIN_DAY, }, { menuItem: "Last 24 hours", - expectedDataPoints: 26, + expectedDataPoints: 24, grain: V1TimeGrain.TIME_GRAIN_HOUR, }, ]; @@ -42,6 +48,7 @@ async function verifyChartTooltipData( apiData: { data: TimeSeriesValue[] }, grain: V1TimeGrain, measureName: string, + dashboardTZ: string, ) { const chart = page.getByLabel(`Measure Chart for ${measureName}`).first(); const box = await chart.boundingBox(); @@ -50,8 +57,8 @@ async function verifyChartTooltipData( const centerY = box.y + box.height / 2; let verifiedPoints = 0; let lastDateText: string | undefined; - // Exclude first and last data points as they're not rendered - const expectedPoints = apiData.data.length - 2; + + const expectedPoints = apiData.data.length; for (let x = box.x; x < box.x + box.width; x += HOVER_STEP_PX) { await page.mouse.move(x, centerY); @@ -63,10 +70,13 @@ async function verifyChartTooltipData( if (!dateText || dateText === lastDateText) continue; lastDateText = dateText; - // Skip the first data point (index 0) as chart starts from second point - const point = apiData.data[verifiedPoints + 1]; - const dateTime = DateTime.fromISO(point.ts, { zone: "UTC" }); - const pattern = formatDateTimeByGrain(dateTime, grain); + + const point = apiData.data[verifiedPoints]; + const dateTime = DateTime.fromISO(point.ts, { + zone: dashboardTZ, + locale: CHART_LOCALE, + }); + const pattern = formatGrainBucket(dateTime, grain); // Verify the date label await expect(dateLabel).toHaveText(pattern, { timeout: 2000 }); @@ -91,13 +101,19 @@ async function verifyChartTooltipData( } } -const TIMEZONES = ["UTC", "Europe/Prague", "Asia/Kolkata"] as const; +// --------------------------------------------------------------------------- +// Group 1: Rendering coverage — test dashboard TZ variations with a fixed +// system TZ. Covers the actual label formatting and value display logic. +// --------------------------------------------------------------------------- +const DASHBOARD_TIMEZONES = ["UTC", "America/Los_Angeles"]; -for (const timezone of TIMEZONES) { - test.describe(`timeseries charts (${timezone})`, () => { - test.use({ project: "AdBids", timezoneId: timezone }); +test.describe("timeseries charts (rendering)", () => { + test.use({ project: "AdBids", timezoneId: "UTC", locale: "en-US" }); - test("chart data matches API response", async ({ page }) => { + for (const dashboardTZ of DASHBOARD_TIMEZONES) { + test(`chart data matches API response (dashboard: ${dashboardTZ})`, async ({ + page, + }) => { await page.getByLabel("/dashboards").click(); await gotoNavEntry(page, "/dashboards/AdBids_metrics_explore.yaml"); @@ -111,6 +127,8 @@ for (const timezone of TIMEZONES) { page.getByRole("button", { name: /Total records/ }).first(), ).toBeVisible({ timeout: 5000 }); + await setDashboardTimezone(page, dashboardTZ); + for (const testCase of TIME_RANGE_TEST_CASES) { await interactWithTimeRangeMenu(page, async () => { await page.getByRole("menuitem", { name: testCase.menuItem }).click(); @@ -141,8 +159,72 @@ for (const timezone of TIMEZONES) { apiData, testCase.grain, "total_records", + dashboardTZ, ); } }); + } +}); + +// --------------------------------------------------------------------------- +// Group 2: System TZ independence — prove that a non-UTC system timezone +// does not affect chart rendering. Uses Europe/Prague (de-DE locale) as the +// system TZ with LA dashboard TZ to maximize the mismatch. +// --------------------------------------------------------------------------- +test.describe("timeseries charts system TZ independence", () => { + test.use({ project: "AdBids", timezoneId: "Europe/Prague", locale: "de-DE" }); + + test("chart data matches UTC baseline (dashboard: America/Los_Angeles)", async ({ + page, + }) => { + const dashboardTZ = "America/Los_Angeles"; + + await page.getByLabel("/dashboards").click(); + await gotoNavEntry(page, "/dashboards/AdBids_metrics_explore.yaml"); + + await expect( + page.getByRole("button", { name: /Total records/ }).first(), + ).toBeVisible({ timeout: 5000 }); + + await page.getByRole("button", { name: "Preview" }).click(); + + await expect( + page.getByRole("button", { name: /Total records/ }).first(), + ).toBeVisible({ timeout: 5000 }); + + await setDashboardTimezone(page, dashboardTZ); + + for (const testCase of TIME_RANGE_TEST_CASES) { + await interactWithTimeRangeMenu(page, async () => { + await page.getByRole("menuitem", { name: testCase.menuItem }).click(); + }); + + await page + .getByRole("button", { + name: "Select aggregation grain", + }) + .click(); + + const timeseriesPromise = interceptTimeseriesResponse(page); + await page + .getByRole("menuitem", { + name: V1TimeGrainToDateTimeUnit[testCase.grain], + exact: true, + }) + .click(); + + await page.waitForTimeout(500); + + const apiData = await timeseriesPromise; + expect(apiData.data.length).toBe(testCase.expectedDataPoints); + + await verifyChartTooltipData( + page, + apiData, + testCase.grain, + "total_records", + dashboardTZ, + ); + } }); -} +}); diff --git a/web-local/tests/utils/dataSpecifcHelpers.ts b/web-local/tests/utils/dataSpecifcHelpers.ts index 8e920d61c8b..21da33bc334 100644 --- a/web-local/tests/utils/dataSpecifcHelpers.ts +++ b/web-local/tests/utils/dataSpecifcHelpers.ts @@ -107,9 +107,9 @@ export function interceptTimeseriesResponse( * Gets the chart container element for timeseries */ export function getChartContainer(page: Page) { - // The chart SVG has role="application" and contains path elements for the line + // The chart SVG has an aria-label and contains path elements for the line return page - .locator('svg[role="application"]') + .locator('svg[aria-label*="Measure Chart"]') .filter({ has: page.locator("path") }) .first(); }